codex-claude-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.
@@ -0,0 +1,1035 @@
1
+ /**
2
+ * API Routes
3
+ * All HTTP route wiring and handlers.
4
+ */
5
+
6
+ import express from 'express';
7
+ import { join, dirname } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ import {
11
+ getActiveAccount,
12
+ setActiveAccount,
13
+ removeAccount,
14
+ listAccounts,
15
+ refreshActiveAccount,
16
+ refreshAccountToken,
17
+ refreshAllAccounts,
18
+ importFromCodex,
19
+ getStatus,
20
+ loadAccounts,
21
+ saveAccounts,
22
+ updateAccountAuth,
23
+ updateAccountQuota,
24
+ getAccountQuota,
25
+ isTokenExpiredOrExpiringSoon,
26
+ ACCOUNTS_FILE
27
+ } from '../account-manager.js';
28
+
29
+ import {
30
+ getAuthorizationUrl,
31
+ generatePKCE,
32
+ generateState,
33
+ startCallbackServer,
34
+ exchangeCodeForTokens,
35
+ OAUTH_CONFIG,
36
+ extractCodeFromInput,
37
+ extractAccountInfo
38
+ } from '../oauth.js';
39
+
40
+ import { sendMessageStream, sendMessage } from '../direct-api.js';
41
+ import { sendKiloMessageStream, sendKiloMessage } from '../kilo-api.js';
42
+ import { formatSSEEvent } from '../response-streamer.js';
43
+
44
+ import {
45
+ fetchModels,
46
+ fetchUsage,
47
+ getAccountQuota as fetchAccountQuota,
48
+ getModelsAndQuota
49
+ } from '../model-api.js';
50
+
51
+ import {
52
+ readClaudeConfig,
53
+ setProxyMode,
54
+ setDirectMode,
55
+ getClaudeConfigPath
56
+ } from '../claude-config.js';
57
+
58
+ import { convertAnthropicToResponsesAPI } from '../format-converter.js';
59
+ import { logger } from '../utils/logger.js';
60
+ import { getServerSettings, setServerSettings } from '../server-settings.js';
61
+
62
+ const __dirname = dirname(fileURLToPath(import.meta.url));
63
+
64
+ const CLAUDE_MODEL_MAP = {
65
+ 'claude-opus-4-5': 'gpt-5.3-codex',
66
+ 'claude-opus-4-5-20250514': 'gpt-5.3-codex',
67
+ 'claude-sonnet-4-5': 'gpt-5.2',
68
+ 'claude-sonnet-4-5-20250514': 'gpt-5.2',
69
+ 'claude-sonnet-4-20250514': 'gpt-5.2',
70
+ 'claude-haiku-4-20250514': 'kilo',
71
+ 'claude-haiku-3-5-20250514': 'kilo',
72
+ 'claude-3-5-sonnet-20240620': 'gpt-5.2',
73
+ 'claude-3-opus-20240229': 'gpt-5.3-codex',
74
+ 'claude-3-sonnet-20240229': 'gpt-5.2',
75
+ 'claude-3-haiku-20240307': 'kilo',
76
+ 'sonnet': 'gpt-5.2',
77
+ 'opus': 'gpt-5.3-codex',
78
+ 'haiku': 'kilo',
79
+ 'gpt-5.3-codex': 'gpt-5.3-codex',
80
+ 'gpt-5.2-codex': 'gpt-5.2-codex',
81
+ 'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
82
+ 'gpt-5.1-codex': 'gpt-5.1-codex',
83
+ 'gpt-5-codex': 'gpt-5-codex',
84
+ 'gpt-5.2': 'gpt-5.2',
85
+ 'gpt-5.1': 'gpt-5.1',
86
+ 'gpt-5': 'gpt-5',
87
+ 'gpt-5.1-codex-mini': 'gpt-5.1-codex-mini',
88
+ 'gpt-5-codex-mini': 'gpt-5-codex-mini'
89
+ };
90
+
91
+ function mapClaudeModel(model) {
92
+ const modelLower = model.toLowerCase();
93
+
94
+ if (CLAUDE_MODEL_MAP[model]) {
95
+ return CLAUDE_MODEL_MAP[model];
96
+ }
97
+
98
+ if (modelLower.startsWith('claude-')) {
99
+ const cleanModel = modelLower.replace(/^claude-/, '');
100
+ if (cleanModel.includes('opus')) return 'gpt-5.3-codex';
101
+ if (cleanModel.includes('sonnet')) return 'gpt-5.2';
102
+ if (cleanModel.includes('haiku')) return 'kilo';
103
+ }
104
+
105
+ for (const [key, value] of Object.entries(CLAUDE_MODEL_MAP)) {
106
+ if (modelLower.includes(key.toLowerCase())) {
107
+ return value;
108
+ }
109
+ }
110
+
111
+ return 'gpt-5.2';
112
+ }
113
+
114
+ function isKiloModel(mappedModel) {
115
+ return mappedModel === 'kilo';
116
+ }
117
+
118
+ function resolveKiloModel() {
119
+ const settings = getServerSettings();
120
+ if (settings.haikuKiloModel === 'minimax-2.5') {
121
+ return 'minimax/minimax-m2.5:free';
122
+ }
123
+ return 'z-ai/glm-5:free';
124
+ }
125
+
126
+ async function getCredentialsOrError() {
127
+ const account = getActiveAccount();
128
+ if (!account) {
129
+ logger.info('No active account found');
130
+ return null;
131
+ }
132
+ if (!account.accessToken || !account.accountId) {
133
+ logger.info(`Account ${account.email} missing token or accountId`);
134
+ return null;
135
+ }
136
+
137
+ if (isTokenExpiredOrExpiringSoon(account)) {
138
+ logger.info(`Token expired/expiring soon for ${account.email}, refreshing...`);
139
+ const result = await refreshAccountToken(account.email);
140
+ if (!result.success) {
141
+ logger.error(`Failed to refresh token: ${result.message}`);
142
+ return null;
143
+ }
144
+ const refreshedAccount = getActiveAccount();
145
+ if (!refreshedAccount) {
146
+ logger.error('Failed to get refreshed account');
147
+ return null;
148
+ }
149
+ logger.info(`Using refreshed token for ${refreshedAccount.email}`);
150
+ return {
151
+ accessToken: refreshedAccount.accessToken,
152
+ accountId: refreshedAccount.accountId,
153
+ email: refreshedAccount.email
154
+ };
155
+ }
156
+
157
+ return {
158
+ accessToken: account.accessToken,
159
+ accountId: account.accountId,
160
+ email: account.email
161
+ };
162
+ }
163
+
164
+ async function handleMessages(req, res) {
165
+ const startTime = Date.now();
166
+ const body = req.body;
167
+ const requestedModel = body.model || 'gpt-5.2';
168
+
169
+ const mappedModel = mapClaudeModel(requestedModel);
170
+ const isStreaming = body.stream !== false;
171
+
172
+ const isKilo = isKiloModel(mappedModel);
173
+ const kiloTarget = isKilo ? resolveKiloModel() : null;
174
+ const upstreamModel = isKilo ? kiloTarget : mappedModel;
175
+ const responseModelForMessages = requestedModel;
176
+
177
+ let model = upstreamModel;
178
+
179
+ if (!isKilo) {
180
+ const creds = await getCredentialsOrError();
181
+ if (!creds) {
182
+ logger.response(401, { error: 'No active account' });
183
+ return res.status(401).json({
184
+ type: 'error',
185
+ error: {
186
+ type: 'authentication_error',
187
+ message: 'No active account with valid credentials. Add an account via /accounts/add'
188
+ }
189
+ });
190
+ }
191
+
192
+ logger.request('POST', '/v1/messages', {
193
+ model: upstreamModel,
194
+ account: creds.email,
195
+ stream: isStreaming,
196
+ messages: body.messages?.length || 0,
197
+ tools: body.tools?.length || 0
198
+ });
199
+
200
+ const anthropicRequest = {
201
+ ...body,
202
+ model: upstreamModel
203
+ };
204
+
205
+ if (!isStreaming) {
206
+ try {
207
+ const response = await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
208
+ const duration = Date.now() - startTime;
209
+ const tokens = response.usage?.output_tokens || 0;
210
+ logger.response(200, { model: upstreamModel, tokens, duration });
211
+ res.json({
212
+ ...response,
213
+ model: responseModelForMessages
214
+ });
215
+ } catch (error) {
216
+ const duration = Date.now() - startTime;
217
+ logger.response(500, { model, error: error.message, duration });
218
+
219
+ if (error.message.includes('AUTH_EXPIRED')) {
220
+ return res.status(401).json({
221
+ type: 'error',
222
+ error: {
223
+ type: 'authentication_error',
224
+ message: 'Token expired. Please refresh or re-authenticate.'
225
+ }
226
+ });
227
+ }
228
+
229
+ if (error.message.includes('RATE_LIMITED')) {
230
+ return res.status(429).json({
231
+ type: 'error',
232
+ error: {
233
+ type: 'rate_limit_error',
234
+ message: error.message
235
+ }
236
+ });
237
+ }
238
+
239
+ res.status(500).json({
240
+ type: 'error',
241
+ error: { type: 'api_error', message: error.message }
242
+ });
243
+ }
244
+ return;
245
+ }
246
+
247
+ res.setHeader('Content-Type', 'text/event-stream');
248
+ res.setHeader('Cache-Control', 'no-cache');
249
+ res.setHeader('Connection', 'keep-alive');
250
+ res.setHeader('X-Accel-Buffering', 'no');
251
+ res.flushHeaders();
252
+
253
+ try {
254
+ const eventStream = sendMessageStream(anthropicRequest, creds.accessToken, creds.accountId);
255
+
256
+ for await (const event of eventStream) {
257
+ res.write(formatSSEEvent(event));
258
+ }
259
+
260
+ res.write('data: [DONE]\n\n');
261
+ res.end();
262
+
263
+ const duration = Date.now() - startTime;
264
+ logger.response(200, { model, duration });
265
+
266
+ } catch (error) {
267
+ const duration = Date.now() - startTime;
268
+ logger.response(500, { model, error: error.message, duration });
269
+
270
+ if (!res.headersSent) {
271
+ if (error.message.includes('AUTH_EXPIRED')) {
272
+ return res.status(401).json({
273
+ type: 'error',
274
+ error: { type: 'authentication_error', message: 'Token expired' }
275
+ });
276
+ }
277
+ res.status(500).json({
278
+ type: 'error',
279
+ error: { type: 'api_error', message: error.message }
280
+ });
281
+ } else {
282
+ res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message: error.message } })}\n\n`);
283
+ res.end();
284
+ }
285
+ }
286
+ return;
287
+ }
288
+
289
+ logger.request('POST', '/v1/messages', {
290
+ model: kiloTarget,
291
+ account: 'kilo',
292
+ stream: isStreaming,
293
+ messages: body.messages?.length || 0,
294
+ tools: body.tools?.length || 0
295
+ });
296
+
297
+ const anthropicRequest = {
298
+ ...body,
299
+ model: upstreamModel
300
+ };
301
+
302
+ if (!isStreaming) {
303
+ try {
304
+ const response = await sendKiloMessage(anthropicRequest, kiloTarget);
305
+ const duration = Date.now() - startTime;
306
+ const tokens = response.usage?.output_tokens || 0;
307
+ logger.response(200, { model: kiloTarget, tokens, duration });
308
+ res.json({
309
+ id: response.id || anthropicRequest.id || undefined,
310
+ type: 'message',
311
+ role: 'assistant',
312
+ content: response.content,
313
+ model: responseModelForMessages,
314
+ stop_reason: response.stopReason,
315
+ stop_sequence: null,
316
+ usage: response.usage
317
+ });
318
+ } catch (error) {
319
+ const duration = Date.now() - startTime;
320
+ logger.response(500, { model: kiloTarget, error: error.message, duration });
321
+
322
+ res.status(500).json({
323
+ type: 'error',
324
+ error: { type: 'api_error', message: error.message }
325
+ });
326
+ }
327
+ return;
328
+ }
329
+
330
+ res.setHeader('Content-Type', 'text/event-stream');
331
+ res.setHeader('Cache-Control', 'no-cache');
332
+ res.setHeader('Connection', 'keep-alive');
333
+ res.setHeader('X-Accel-Buffering', 'no');
334
+ res.flushHeaders();
335
+
336
+ try {
337
+ const eventStream = sendKiloMessageStream(anthropicRequest, kiloTarget);
338
+
339
+ for await (const event of eventStream) {
340
+ res.write(formatSSEEvent(event));
341
+ }
342
+
343
+ res.write('data: [DONE]\n\n');
344
+ res.end();
345
+
346
+ const duration = Date.now() - startTime;
347
+ logger.response(200, { model: kiloTarget, duration });
348
+
349
+ } catch (error) {
350
+ const duration = Date.now() - startTime;
351
+ logger.response(500, { model: kiloTarget, error: error.message, duration });
352
+
353
+ if (!res.headersSent) {
354
+ res.status(500).json({
355
+ type: 'error',
356
+ error: { type: 'api_error', message: error.message }
357
+ });
358
+ } else {
359
+ res.write(`event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: 'api_error', message: error.message } })}\n\n`);
360
+ res.end();
361
+ }
362
+ }
363
+ }
364
+
365
+ async function handleChatCompletion(req, res) {
366
+ const startTime = Date.now();
367
+ const body = req.body;
368
+ const requestedModel = body.model || 'gpt-5.2';
369
+
370
+ const mappedModel = mapClaudeModel(requestedModel);
371
+
372
+ const isKilo = isKiloModel(mappedModel);
373
+ const kiloTarget = isKilo ? resolveKiloModel() : null;
374
+ const upstreamModel = isKilo ? kiloTarget : mappedModel;
375
+ let creds = null;
376
+
377
+ const responseModel = requestedModel;
378
+
379
+ if (!isKilo) {
380
+ creds = await getCredentialsOrError();
381
+ if (!creds) {
382
+ logger.response(401, { error: 'No active account' });
383
+ return res.status(401).json({
384
+ type: 'error',
385
+ error: {
386
+ type: 'authentication_error',
387
+ message: 'No active account. Add an account via /accounts/add'
388
+ }
389
+ });
390
+ }
391
+ }
392
+
393
+ const anthropicRequest = {
394
+ model: upstreamModel,
395
+ messages: [],
396
+ system: null,
397
+ stream: false
398
+ };
399
+
400
+ if (body.messages) {
401
+ const systemMsg = body.messages.find(m => m.role === 'system');
402
+ if (systemMsg) {
403
+ anthropicRequest.system = systemMsg.content;
404
+ }
405
+ anthropicRequest.messages = body.messages
406
+ .filter(m => m.role !== 'system')
407
+ .map(m => {
408
+ if (m.role === 'tool') {
409
+ return {
410
+ role: 'user',
411
+ content: [{
412
+ type: 'tool_result',
413
+ tool_use_id: m.tool_call_id,
414
+ content: m.content
415
+ }]
416
+ };
417
+ }
418
+
419
+ if (m.role === 'assistant' && m.tool_calls) {
420
+ const content = [{ type: 'text', text: m.content || '' }];
421
+ for (const call of m.tool_calls) {
422
+ content.push({
423
+ type: 'tool_use',
424
+ id: call.id,
425
+ name: call.function.name,
426
+ input: JSON.parse(call.function.arguments)
427
+ });
428
+ }
429
+ return { role: 'assistant', content };
430
+ }
431
+
432
+ return m;
433
+ });
434
+ }
435
+
436
+ if (body.tools) {
437
+ anthropicRequest.tools = body.tools.map(t => ({
438
+ name: t.function.name,
439
+ description: t.function.description,
440
+ input_schema: t.function.parameters
441
+ }));
442
+ }
443
+
444
+ logger.request('POST', '/v1/chat/completions', {
445
+ model: upstreamModel,
446
+ account: isKilo ? 'kilo' : creds.email,
447
+ messages: body.messages?.length || 0,
448
+ tools: body.tools?.length || 0
449
+ });
450
+
451
+ try {
452
+ const response = isKilo
453
+ ? await sendKiloMessage(anthropicRequest, kiloTarget)
454
+ : await sendMessage(anthropicRequest, creds.accessToken, creds.accountId);
455
+
456
+ const content = response.content || [];
457
+ const textContent = content.find(c => c.type === 'text');
458
+ const toolUses = content.filter(c => c.type === 'tool_use');
459
+
460
+ const message = {
461
+ role: 'assistant',
462
+ content: textContent?.text || ''
463
+ };
464
+
465
+ if (toolUses.length > 0) {
466
+ message.tool_calls = toolUses.map(t => ({
467
+ id: t.id,
468
+ type: 'function',
469
+ function: {
470
+ name: t.name,
471
+ arguments: JSON.stringify(t.input)
472
+ }
473
+ }));
474
+ }
475
+
476
+ const duration = Date.now() - startTime;
477
+ const tokens = response.usage?.output_tokens || 0;
478
+ logger.response(200, { model: upstreamModel, tokens, duration });
479
+
480
+ res.json({
481
+ id: response.id,
482
+ object: 'chat.completion',
483
+ created: Math.floor(Date.now() / 1000),
484
+ model: responseModel,
485
+ choices: [{
486
+ index: 0,
487
+ message: message,
488
+ finish_reason: toolUses.length > 0 ? 'tool_calls' : 'stop'
489
+ }],
490
+ usage: {
491
+ prompt_tokens: response.usage?.input_tokens || 0,
492
+ completion_tokens: response.usage?.output_tokens || 0,
493
+ total_tokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
494
+ }
495
+ });
496
+
497
+ } catch (error) {
498
+ const duration = Date.now() - startTime;
499
+ logger.response(500, { model: upstreamModel, error: error.message, duration });
500
+ res.status(500).json({
501
+ type: 'error',
502
+ error: { type: 'api_error', message: error.message }
503
+ });
504
+ }
505
+
506
+ return;
507
+ }
508
+
509
+ export function registerApiRoutes(app, { port }) {
510
+ app.use(express.static(join(__dirname, '..', '..', 'public')));
511
+
512
+ app.get('/health', (req, res) => {
513
+ const status = getStatus();
514
+ res.json({
515
+ status: 'ok',
516
+ ...status,
517
+ configPath: ACCOUNTS_FILE
518
+ });
519
+ });
520
+
521
+ app.post('/v1/chat/completions', handleChatCompletion);
522
+ app.post('/v1/messages', handleMessages);
523
+
524
+ app.post('/v1/messages/count_tokens', (req, res) => {
525
+ const body = req.body;
526
+ let text = '';
527
+
528
+ if (body.system) {
529
+ if (typeof body.system === 'string') {
530
+ text += body.system + ' ';
531
+ } else if (Array.isArray(body.system)) {
532
+ for (const block of body.system) {
533
+ if (block.type === 'text') {
534
+ text += block.text + ' ';
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ if (body.tools) {
541
+ for (const tool of body.tools) {
542
+ text += JSON.stringify(tool) + ' ';
543
+ }
544
+ }
545
+
546
+ if (body.messages && body.messages.length > 0) {
547
+ for (const msg of body.messages) {
548
+ if (typeof msg.content === 'string') {
549
+ text += msg.content + ' ';
550
+ } else if (Array.isArray(msg.content)) {
551
+ for (const block of msg.content) {
552
+ if (block.type === 'text') {
553
+ text += block.text + ' ';
554
+ } else if (block.type === 'tool_use' || block.type === 'tool_result') {
555
+ text += JSON.stringify(block) + ' ';
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ const approxTokens = Math.ceil(text.length / 4);
563
+ res.json({ input_tokens: approxTokens });
564
+ });
565
+
566
+ // Settings
567
+ app.get('/settings/haiku-model', (req, res) => {
568
+ const settings = getServerSettings();
569
+ res.json({
570
+ success: true,
571
+ haikuKiloModel: settings.haikuKiloModel
572
+ });
573
+ });
574
+
575
+ app.post('/settings/haiku-model', (req, res) => {
576
+ const { haikuKiloModel } = req.body || {};
577
+ if (!['glm-5', 'minimax-2.5'].includes(haikuKiloModel)) {
578
+ return res.status(400).json({
579
+ success: false,
580
+ error: 'Invalid haikuKiloModel. Use glm-5 or minimax-2.5.'
581
+ });
582
+ }
583
+ const settings = setServerSettings({ haikuKiloModel });
584
+ res.json({ success: true, haikuKiloModel: settings.haikuKiloModel });
585
+ });
586
+
587
+ // Account Management API
588
+ app.get('/accounts', (req, res) => {
589
+ res.json(listAccounts());
590
+ });
591
+
592
+ app.get('/accounts/status', (req, res) => {
593
+ res.json(getStatus());
594
+ });
595
+
596
+ const activeCallbackServers = new Map();
597
+
598
+ app.post('/accounts/oauth/cleanup', (req, res) => {
599
+ for (const [port, server] of activeCallbackServers) {
600
+ try { server.close(); } catch (e) {}
601
+ }
602
+ activeCallbackServers.clear();
603
+ res.json({ success: true, message: 'OAuth servers cleaned up' });
604
+ });
605
+
606
+ app.post('/accounts/add', async (req, res) => {
607
+ const { port } = req.body || {};
608
+ const callbackPort = port || OAUTH_CONFIG.callbackPort;
609
+
610
+ const { verifier } = generatePKCE();
611
+ const state = generateState();
612
+
613
+ const oauthUrl = getAuthorizationUrl(verifier, state, callbackPort);
614
+
615
+ let serverResult;
616
+ try {
617
+ for (const [p, s] of activeCallbackServers) {
618
+ if (p === callbackPort) {
619
+ try { s.close(); } catch (e) {}
620
+ activeCallbackServers.delete(p);
621
+ }
622
+ }
623
+
624
+ serverResult = startCallbackServer(callbackPort, state, 120000);
625
+ } catch (err) {
626
+ return res.status(500).json({
627
+ error: 'Failed to start OAuth callback server',
628
+ message: err.message,
629
+ status: 'error'
630
+ });
631
+ }
632
+
633
+ activeCallbackServers.set(callbackPort, serverResult.server);
634
+
635
+ serverResult.promise.then((result) => {
636
+ activeCallbackServers.delete(callbackPort);
637
+
638
+ if (result && result.code) {
639
+ exchangeCodeForTokens(result.code, verifier)
640
+ .then(tokens => {
641
+ const accountInfo = extractAccountInfo(tokens);
642
+
643
+ const currentData = loadAccounts();
644
+
645
+ const existingIndex = currentData.accounts.findIndex(a => a.email === accountInfo.email);
646
+ if (existingIndex >= 0) {
647
+ currentData.accounts[existingIndex] = {
648
+ ...currentData.accounts[existingIndex],
649
+ ...accountInfo
650
+ };
651
+ } else {
652
+ currentData.accounts.push(accountInfo);
653
+ }
654
+
655
+ currentData.activeAccount = accountInfo.email;
656
+
657
+ saveAccounts(currentData);
658
+ updateAccountAuth(accountInfo);
659
+
660
+ logger.info(`Added account: ${accountInfo.email}`);
661
+ })
662
+ .catch(err => {
663
+ logger.error(`OAuth token exchange failed: ${err.message}`);
664
+ });
665
+ }
666
+ }).catch(() => {
667
+ activeCallbackServers.delete(callbackPort);
668
+ });
669
+
670
+ res.json({
671
+ status: 'oauth_url',
672
+ oauth_url: oauthUrl,
673
+ state,
674
+ callback_port: callbackPort
675
+ });
676
+ });
677
+
678
+ app.post('/accounts/switch', (req, res) => {
679
+ const { email } = req.body || {};
680
+ if (!email) {
681
+ return res.status(400).json({ success: false, message: 'Email is required' });
682
+ }
683
+ const result = setActiveAccount(email);
684
+ if (result.success) {
685
+ logger.info(`Switched to account: ${email}`);
686
+ }
687
+ res.json(result);
688
+ });
689
+
690
+ app.post('/accounts/:email/refresh', async (req, res) => {
691
+ const email = decodeURIComponent(req.params.email);
692
+ const result = await refreshAccountToken(email);
693
+ if (result.success) {
694
+ logger.info(`Refreshed token for: ${email}`);
695
+ }
696
+ res.json(result);
697
+ });
698
+
699
+ app.post('/accounts/refresh/all', async (req, res) => {
700
+ const result = await refreshAllAccounts();
701
+ res.json(result);
702
+ });
703
+
704
+ app.delete('/accounts/:email', (req, res) => {
705
+ const email = decodeURIComponent(req.params.email);
706
+ const result = removeAccount(email);
707
+ if (result.success) {
708
+ logger.info(`Removed account: ${email}`);
709
+ }
710
+ res.json(result);
711
+ });
712
+
713
+ app.post('/accounts/import', (req, res) => {
714
+ const result = importFromCodex();
715
+ res.json(result);
716
+ });
717
+
718
+ app.post('/accounts/refresh', async (req, res) => {
719
+ const result = await refreshActiveAccount();
720
+ res.json(result);
721
+ });
722
+
723
+ app.post('/accounts/add/manual', async (req, res) => {
724
+ const { code, verifier } = req.body || {};
725
+ if (!code) {
726
+ return res.status(400).json({ success: false, error: 'Code is required' });
727
+ }
728
+
729
+ try {
730
+ const extractedCode = extractCodeFromInput(code);
731
+ const tokens = await exchangeCodeForTokens(extractedCode, verifier);
732
+ const accountInfo = extractAccountInfo(tokens);
733
+
734
+ const currentData = loadAccounts();
735
+ const existingIndex = currentData.accounts.findIndex(a => a.email === accountInfo.email);
736
+
737
+ if (existingIndex >= 0) {
738
+ currentData.accounts[existingIndex] = {
739
+ ...currentData.accounts[existingIndex],
740
+ ...accountInfo
741
+ };
742
+ } else {
743
+ currentData.accounts.push(accountInfo);
744
+ }
745
+
746
+ currentData.activeAccount = accountInfo.email;
747
+ saveAccounts(currentData);
748
+ updateAccountAuth(accountInfo);
749
+
750
+ logger.info(`Added account via manual OAuth: ${accountInfo.email}`);
751
+ res.json({ success: true, message: `Account ${accountInfo.email} added successfully` });
752
+ } catch (err) {
753
+ logger.error(`Manual OAuth failed: ${err.message}`);
754
+ res.status(400).json({ success: false, error: err.message });
755
+ }
756
+ });
757
+
758
+ app.get('/accounts/quota/all', async (req, res) => {
759
+ const accounts = listAccounts();
760
+ const results = { accounts: [] };
761
+
762
+ for (const account of accounts.accounts || []) {
763
+ try {
764
+ const quota = await getAccountQuota(account.email);
765
+ results.accounts.push({
766
+ email: account.email,
767
+ quota: quota || null
768
+ });
769
+ } catch (err) {
770
+ results.accounts.push({
771
+ email: account.email,
772
+ quota: null
773
+ });
774
+ }
775
+ }
776
+
777
+ res.json(results);
778
+ });
779
+
780
+ app.get('/accounts/quota', async (req, res) => {
781
+ const { email, refresh } = req.query;
782
+ const data = loadAccounts();
783
+
784
+ let account;
785
+ if (email) {
786
+ account = data.accounts.find(a => a.email === email);
787
+ } else {
788
+ account = getActiveAccount();
789
+ }
790
+
791
+ if (!account) {
792
+ return res.status(404).json({
793
+ success: false,
794
+ error: email ? `Account not found: ${email}` : 'No active account'
795
+ });
796
+ }
797
+
798
+ const cachedQuota = getAccountQuota(account.email);
799
+ const isStale = !cachedQuota ||
800
+ (Date.now() - new Date(cachedQuota.lastChecked).getTime() > 5 * 60 * 1000);
801
+
802
+ if (refresh === 'true' || isStale) {
803
+ try {
804
+ const quotaData = await fetchAccountQuota(account.accessToken, account.accountId);
805
+ updateAccountQuota(account.email, quotaData);
806
+
807
+ res.json({
808
+ success: true,
809
+ email: account.email,
810
+ quota: quotaData,
811
+ cached: false
812
+ });
813
+ } catch (error) {
814
+ logger.error(`Failed to fetch quota: ${error.message}`);
815
+
816
+ if (cachedQuota) {
817
+ res.json({
818
+ success: true,
819
+ email: account.email,
820
+ quota: cachedQuota,
821
+ cached: true,
822
+ warning: 'Using cached data due to fetch error'
823
+ });
824
+ } else {
825
+ res.status(500).json({
826
+ success: false,
827
+ error: error.message
828
+ });
829
+ }
830
+ }
831
+ } else {
832
+ res.json({
833
+ success: true,
834
+ email: account.email,
835
+ quota: cachedQuota,
836
+ cached: true
837
+ });
838
+ }
839
+ });
840
+
841
+ app.get('/accounts/models', async (req, res) => {
842
+ const { email } = req.query;
843
+ const data = loadAccounts();
844
+
845
+ let account;
846
+ if (email) {
847
+ account = data.accounts.find(a => a.email === email);
848
+ } else {
849
+ account = getActiveAccount();
850
+ }
851
+
852
+ if (!account) {
853
+ return res.status(404).json({
854
+ success: false,
855
+ error: email ? `Account not found: ${email}` : 'No active account'
856
+ });
857
+ }
858
+
859
+ try {
860
+ const models = await fetchModels(account.accessToken, account.accountId);
861
+ res.json({
862
+ success: true,
863
+ email: account.email,
864
+ models
865
+ });
866
+ } catch (error) {
867
+ logger.error(`Failed to fetch models: ${error.message}`);
868
+ res.status(500).json({
869
+ success: false,
870
+ error: error.message
871
+ });
872
+ }
873
+ });
874
+
875
+ app.get('/accounts/usage', async (req, res) => {
876
+ const { email } = req.query;
877
+ const data = loadAccounts();
878
+
879
+ let account;
880
+ if (email) {
881
+ account = data.accounts.find(a => a.email === email);
882
+ } else {
883
+ account = getActiveAccount();
884
+ }
885
+
886
+ if (!account) {
887
+ return res.status(404).json({
888
+ success: false,
889
+ error: email ? `Account not found: ${email}` : 'No active account'
890
+ });
891
+ }
892
+
893
+ try {
894
+ const usage = await fetchUsage(account.accessToken, account.accountId);
895
+ res.json({
896
+ success: true,
897
+ email: account.email,
898
+ usage
899
+ });
900
+ } catch (error) {
901
+ logger.error(`Failed to fetch usage: ${error.message}`);
902
+ res.status(500).json({
903
+ success: false,
904
+ error: error.message
905
+ });
906
+ }
907
+ });
908
+
909
+ app.get('/v1/models', async (req, res) => {
910
+ const creds = await getCredentialsOrError();
911
+ if (!creds) {
912
+ return res.json({
913
+ object: 'list',
914
+ data: [
915
+ { id: 'gpt-5.3-codex', object: 'model', owned_by: 'openai' },
916
+ { id: 'gpt-5.2-codex', object: 'model', owned_by: 'openai' },
917
+ { id: 'gpt-5.1-codex', object: 'model', owned_by: 'openai' },
918
+ { id: 'gpt-5.2', object: 'model', owned_by: 'openai' },
919
+ { id: 'claude-opus-4-5-20250514', object: 'model', owned_by: 'anthropic' },
920
+ { id: 'claude-sonnet-4-5-20250514', object: 'model', owned_by: 'anthropic' },
921
+ { id: 'claude-haiku-4-20250514', object: 'model', owned_by: 'anthropic' }
922
+ ]
923
+ });
924
+ }
925
+
926
+ try {
927
+ const models = await fetchModels(creds.accessToken, creds.accountId);
928
+ const modelList = models.map(m => ({
929
+ id: m.id,
930
+ object: 'model',
931
+ created: Math.floor(Date.now() / 1000),
932
+ owned_by: 'openai',
933
+ description: m.description
934
+ }));
935
+ res.json({ object: 'list', data: modelList });
936
+ } catch (error) {
937
+ logger.error(`Failed to fetch models: ${error.message}`);
938
+ res.json({
939
+ object: 'list',
940
+ data: [
941
+ { id: 'gpt-5.3-codex', object: 'model', owned_by: 'openai' },
942
+ { id: 'gpt-5.2-codex', object: 'model', owned_by: 'openai' },
943
+ { id: 'gpt-5.1-codex', object: 'model', owned_by: 'openai' },
944
+ { id: 'gpt-5.2', object: 'model', owned_by: 'openai' }
945
+ ]
946
+ });
947
+ }
948
+ });
949
+
950
+ // Claude CLI Configuration
951
+
952
+ app.get('/claude/config', async (req, res) => {
953
+ try {
954
+ const config = await readClaudeConfig();
955
+ const configPath = getClaudeConfigPath();
956
+ res.json({
957
+ success: true,
958
+ configPath,
959
+ config
960
+ });
961
+ } catch (error) {
962
+ res.status(500).json({ success: false, error: error.message });
963
+ }
964
+ });
965
+
966
+ app.post('/claude/config/proxy', async (req, res) => {
967
+ try {
968
+ const proxyUrl = `http://localhost:${port}`;
969
+ const models = {
970
+ default: 'claude-sonnet-4-5',
971
+ opus: 'claude-opus-4-5',
972
+ sonnet: 'claude-sonnet-4-5',
973
+ haiku: 'claude-haiku-4'
974
+ };
975
+
976
+ const config = await setProxyMode(proxyUrl, models);
977
+ res.json({
978
+ success: true,
979
+ message: `Claude CLI configured to use proxy at ${proxyUrl}`,
980
+ config
981
+ });
982
+ } catch (error) {
983
+ res.status(500).json({ success: false, error: error.message });
984
+ }
985
+ });
986
+
987
+ app.post('/claude/config/direct', async (req, res) => {
988
+ try {
989
+ const { apiKey } = req.body;
990
+ if (!apiKey) {
991
+ return res.status(400).json({ success: false, error: 'API key required' });
992
+ }
993
+
994
+ const config = await setDirectMode(apiKey);
995
+ res.json({
996
+ success: true,
997
+ message: 'Claude CLI configured to use direct Anthropic API',
998
+ config
999
+ });
1000
+ } catch (error) {
1001
+ res.status(500).json({ success: false, error: error.message });
1002
+ }
1003
+ });
1004
+
1005
+ // Logs API
1006
+ app.get('/api/logs', (req, res) => {
1007
+ res.json({
1008
+ status: 'ok',
1009
+ logs: logger.getHistory()
1010
+ });
1011
+ });
1012
+
1013
+ app.get('/api/logs/stream', (req, res) => {
1014
+ res.setHeader('Content-Type', 'text/event-stream');
1015
+ res.setHeader('Cache-Control', 'no-cache');
1016
+ res.setHeader('Connection', 'keep-alive');
1017
+
1018
+ const sendLog = (log) => {
1019
+ res.write(`data: ${JSON.stringify(log)}\n\n`);
1020
+ };
1021
+
1022
+ if (req.query.history === 'true') {
1023
+ const history = logger.getHistory();
1024
+ history.forEach(log => sendLog(log));
1025
+ }
1026
+
1027
+ logger.on('log', sendLog);
1028
+
1029
+ req.on('close', () => {
1030
+ logger.off('log', sendLog);
1031
+ });
1032
+ });
1033
+ }
1034
+
1035
+ export default { registerApiRoutes };