commons-proxy 2.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 (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +757 -0
  3. package/bin/cli.js +146 -0
  4. package/package.json +97 -0
  5. package/public/Complaint Details.pdf +0 -0
  6. package/public/Cyber Crime Portal.pdf +0 -0
  7. package/public/app.js +229 -0
  8. package/public/css/src/input.css +523 -0
  9. package/public/css/style.css +1 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +549 -0
  12. package/public/js/components/account-manager.js +356 -0
  13. package/public/js/components/add-account-modal.js +414 -0
  14. package/public/js/components/claude-config.js +420 -0
  15. package/public/js/components/dashboard/charts.js +605 -0
  16. package/public/js/components/dashboard/filters.js +362 -0
  17. package/public/js/components/dashboard/stats.js +110 -0
  18. package/public/js/components/dashboard.js +236 -0
  19. package/public/js/components/logs-viewer.js +100 -0
  20. package/public/js/components/models.js +36 -0
  21. package/public/js/components/server-config.js +349 -0
  22. package/public/js/config/constants.js +102 -0
  23. package/public/js/data-store.js +375 -0
  24. package/public/js/settings-store.js +58 -0
  25. package/public/js/store.js +99 -0
  26. package/public/js/translations/en.js +367 -0
  27. package/public/js/translations/id.js +412 -0
  28. package/public/js/translations/pt.js +308 -0
  29. package/public/js/translations/tr.js +358 -0
  30. package/public/js/translations/zh.js +373 -0
  31. package/public/js/utils/account-actions.js +189 -0
  32. package/public/js/utils/error-handler.js +96 -0
  33. package/public/js/utils/model-config.js +42 -0
  34. package/public/js/utils/ui-logger.js +143 -0
  35. package/public/js/utils/validators.js +77 -0
  36. package/public/js/utils.js +69 -0
  37. package/public/proxy-server-64.png +0 -0
  38. package/public/views/accounts.html +361 -0
  39. package/public/views/dashboard.html +484 -0
  40. package/public/views/logs.html +97 -0
  41. package/public/views/models.html +331 -0
  42. package/public/views/settings.html +1327 -0
  43. package/src/account-manager/credentials.js +378 -0
  44. package/src/account-manager/index.js +462 -0
  45. package/src/account-manager/onboarding.js +112 -0
  46. package/src/account-manager/rate-limits.js +369 -0
  47. package/src/account-manager/storage.js +160 -0
  48. package/src/account-manager/strategies/base-strategy.js +109 -0
  49. package/src/account-manager/strategies/hybrid-strategy.js +339 -0
  50. package/src/account-manager/strategies/index.js +79 -0
  51. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  52. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  53. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  54. package/src/account-manager/strategies/trackers/index.js +9 -0
  55. package/src/account-manager/strategies/trackers/quota-tracker.js +120 -0
  56. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +155 -0
  57. package/src/auth/database.js +169 -0
  58. package/src/auth/oauth.js +548 -0
  59. package/src/auth/token-extractor.js +117 -0
  60. package/src/cli/accounts.js +648 -0
  61. package/src/cloudcode/index.js +29 -0
  62. package/src/cloudcode/message-handler.js +510 -0
  63. package/src/cloudcode/model-api.js +248 -0
  64. package/src/cloudcode/rate-limit-parser.js +235 -0
  65. package/src/cloudcode/request-builder.js +93 -0
  66. package/src/cloudcode/session-manager.js +47 -0
  67. package/src/cloudcode/sse-parser.js +121 -0
  68. package/src/cloudcode/sse-streamer.js +293 -0
  69. package/src/cloudcode/streaming-handler.js +615 -0
  70. package/src/config.js +125 -0
  71. package/src/constants.js +407 -0
  72. package/src/errors.js +242 -0
  73. package/src/fallback-config.js +29 -0
  74. package/src/format/content-converter.js +193 -0
  75. package/src/format/index.js +20 -0
  76. package/src/format/request-converter.js +255 -0
  77. package/src/format/response-converter.js +120 -0
  78. package/src/format/schema-sanitizer.js +673 -0
  79. package/src/format/signature-cache.js +88 -0
  80. package/src/format/thinking-utils.js +648 -0
  81. package/src/index.js +148 -0
  82. package/src/modules/usage-stats.js +205 -0
  83. package/src/providers/anthropic-provider.js +258 -0
  84. package/src/providers/base-provider.js +157 -0
  85. package/src/providers/cloudcode.js +94 -0
  86. package/src/providers/copilot.js +399 -0
  87. package/src/providers/github-provider.js +287 -0
  88. package/src/providers/google-provider.js +192 -0
  89. package/src/providers/index.js +211 -0
  90. package/src/providers/openai-compatible.js +265 -0
  91. package/src/providers/openai-provider.js +271 -0
  92. package/src/providers/openrouter-provider.js +325 -0
  93. package/src/providers/setup.js +83 -0
  94. package/src/server.js +870 -0
  95. package/src/utils/claude-config.js +245 -0
  96. package/src/utils/helpers.js +51 -0
  97. package/src/utils/logger.js +142 -0
  98. package/src/utils/native-module-helper.js +162 -0
  99. package/src/webui/index.js +1134 -0
@@ -0,0 +1,1134 @@
1
+ /**
2
+ * WebUI Module - Optional web interface for account management
3
+ *
4
+ * This module provides a web-based UI for:
5
+ * - Dashboard with real-time model quota visualization
6
+ * - Account management (add via OAuth, enable/disable, refresh, remove)
7
+ * - Live server log streaming with filtering
8
+ * - Claude CLI configuration editor
9
+ *
10
+ * Usage in server.js:
11
+ * import { mountWebUI } from './webui/index.js';
12
+ * mountWebUI(app, __dirname, accountManager);
13
+ */
14
+
15
+ import path from 'path';
16
+ import crypto from 'crypto';
17
+ import { readFileSync } from 'fs';
18
+ import { fileURLToPath } from 'url';
19
+ import express from 'express';
20
+ import { getPublicConfig, saveConfig, config } from '../config.js';
21
+ import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS, PROVIDER_CONFIG, PROVIDER_NAMES } from '../constants.js';
22
+ import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
23
+ import { logger } from '../utils/logger.js';
24
+ import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
25
+ import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
26
+ import { getAllAuthProviders, getAuthProvider } from '../providers/index.js';
27
+
28
+ // Get package version
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+ let packageVersion = '1.0.0';
32
+ try {
33
+ const packageJsonPath = path.join(__dirname, '../../package.json');
34
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
35
+ packageVersion = packageJson.version;
36
+ } catch (error) {
37
+ logger.warn('[WebUI] Could not read package.json version, using default');
38
+ }
39
+
40
+ // OAuth state storage (state -> { server, verifier, state, timestamp })
41
+ // Maps state ID to active OAuth flow data
42
+ const pendingOAuthFlows = new Map();
43
+
44
+ /**
45
+ * WebUI Helper Functions - Direct account manipulation
46
+ * These functions work around AccountManager's limited API by directly
47
+ * manipulating the accounts.json config file (non-invasive approach for PR)
48
+ */
49
+
50
+ /**
51
+ * Set account enabled/disabled state
52
+ */
53
+ async function setAccountEnabled(email, enabled) {
54
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
55
+ const account = accounts.find(a => a.email === email);
56
+ if (!account) {
57
+ throw new Error(`Account ${email} not found`);
58
+ }
59
+ account.enabled = enabled;
60
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
61
+ logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`);
62
+ }
63
+
64
+ /**
65
+ * Remove account from config
66
+ */
67
+ async function removeAccount(email) {
68
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
69
+ const index = accounts.findIndex(a => a.email === email);
70
+ if (index === -1) {
71
+ throw new Error(`Account ${email} not found`);
72
+ }
73
+ accounts.splice(index, 1);
74
+ // Adjust activeIndex if needed
75
+ const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex;
76
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex);
77
+ logger.info(`[WebUI] Account ${email} removed`);
78
+ }
79
+
80
+ /**
81
+ * Add new account to config
82
+ * @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only)
83
+ */
84
+ async function addAccount(accountData) {
85
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
86
+
87
+ // Check if account already exists
88
+ const existingIndex = accounts.findIndex(a => a.email === accountData.email);
89
+ if (existingIndex !== -1) {
90
+ // Update existing account
91
+ accounts[existingIndex] = {
92
+ ...accounts[existingIndex],
93
+ ...accountData,
94
+ enabled: true,
95
+ isInvalid: false,
96
+ invalidReason: null,
97
+ addedAt: accounts[existingIndex].addedAt || new Date().toISOString()
98
+ };
99
+ logger.info(`[WebUI] Account ${accountData.email} updated`);
100
+ } else {
101
+ // Check MAX_ACCOUNTS limit before adding new account
102
+ if (accounts.length >= MAX_ACCOUNTS) {
103
+ throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`);
104
+ }
105
+ // Add new account
106
+ accounts.push({
107
+ ...accountData,
108
+ enabled: true,
109
+ isInvalid: false,
110
+ invalidReason: null,
111
+ modelRateLimits: {},
112
+ lastUsed: null,
113
+ addedAt: new Date().toISOString()
114
+ });
115
+ logger.info(`[WebUI] Account ${accountData.email} added`);
116
+ }
117
+
118
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
119
+ }
120
+
121
+ /**
122
+ * Auth Middleware - Optional password protection for WebUI
123
+ * Password can be set via WEBUI_PASSWORD env var or config.json
124
+ */
125
+ function createAuthMiddleware() {
126
+ return (req, res, next) => {
127
+ const password = config.webuiPassword;
128
+ if (!password) return next();
129
+
130
+ // Determine if this path should be protected
131
+ const isApiRoute = req.path.startsWith('/api/');
132
+ const isAuthUrl = req.path === '/api/auth/url';
133
+ const isConfigGet = req.path === '/api/config' && req.method === 'GET';
134
+ const isProtected = (isApiRoute && !isAuthUrl && !isConfigGet) || req.path === '/account-limits' || req.path === '/health';
135
+
136
+ if (isProtected) {
137
+ const providedPassword = req.headers['x-webui-password'] || req.query.password;
138
+ if (providedPassword !== password) {
139
+ return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' });
140
+ }
141
+ }
142
+ next();
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Mount WebUI routes and middleware on Express app
148
+ * @param {Express} app - Express application instance
149
+ * @param {string} dirname - __dirname of the calling module (for static file path)
150
+ * @param {AccountManager} accountManager - Account manager instance
151
+ */
152
+ export function mountWebUI(app, dirname, accountManager) {
153
+ // Apply auth middleware
154
+ app.use(createAuthMiddleware());
155
+
156
+ // Serve static files from public directory
157
+ app.use(express.static(path.join(dirname, '../public')));
158
+
159
+ // ==========================================
160
+ // Account Management API
161
+ // ==========================================
162
+
163
+ /**
164
+ * GET /api/accounts - List all accounts with status
165
+ */
166
+ app.get('/api/accounts', async (req, res) => {
167
+ try {
168
+ const status = accountManager.getStatus();
169
+ res.json({
170
+ status: 'ok',
171
+ accounts: status.accounts,
172
+ summary: {
173
+ total: status.total,
174
+ available: status.available,
175
+ rateLimited: status.rateLimited,
176
+ invalid: status.invalid
177
+ }
178
+ });
179
+ } catch (error) {
180
+ res.status(500).json({ status: 'error', error: error.message });
181
+ }
182
+ });
183
+
184
+ /**
185
+ * POST /api/accounts/:email/refresh - Refresh specific account token
186
+ */
187
+ app.post('/api/accounts/:email/refresh', async (req, res) => {
188
+ try {
189
+ const { email } = req.params;
190
+ accountManager.clearTokenCache(email);
191
+ accountManager.clearProjectCache(email);
192
+ res.json({
193
+ status: 'ok',
194
+ message: `Token cache cleared for ${email}`
195
+ });
196
+ } catch (error) {
197
+ res.status(500).json({ status: 'error', error: error.message });
198
+ }
199
+ });
200
+
201
+ /**
202
+ * POST /api/accounts/:email/toggle - Enable/disable account
203
+ */
204
+ app.post('/api/accounts/:email/toggle', async (req, res) => {
205
+ try {
206
+ const { email } = req.params;
207
+ const { enabled } = req.body;
208
+
209
+ if (typeof enabled !== 'boolean') {
210
+ return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
211
+ }
212
+
213
+ await setAccountEnabled(email, enabled);
214
+
215
+ // Reload AccountManager to pick up changes
216
+ await accountManager.reload();
217
+
218
+ res.json({
219
+ status: 'ok',
220
+ message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
221
+ });
222
+ } catch (error) {
223
+ res.status(500).json({ status: 'error', error: error.message });
224
+ }
225
+ });
226
+
227
+ /**
228
+ * DELETE /api/accounts/:email - Remove account
229
+ */
230
+ app.delete('/api/accounts/:email', async (req, res) => {
231
+ try {
232
+ const { email } = req.params;
233
+ await removeAccount(email);
234
+
235
+ // Reload AccountManager to pick up changes
236
+ await accountManager.reload();
237
+
238
+ res.json({
239
+ status: 'ok',
240
+ message: `Account ${email} removed`
241
+ });
242
+ } catch (error) {
243
+ res.status(500).json({ status: 'error', error: error.message });
244
+ }
245
+ });
246
+
247
+ /**
248
+ * POST /api/accounts/reload - Reload accounts from disk
249
+ */
250
+ app.post('/api/accounts/reload', async (req, res) => {
251
+ try {
252
+ // Reload AccountManager from disk
253
+ await accountManager.reload();
254
+
255
+ const status = accountManager.getStatus();
256
+ res.json({
257
+ status: 'ok',
258
+ message: 'Accounts reloaded from disk',
259
+ summary: status.summary
260
+ });
261
+ } catch (error) {
262
+ res.status(500).json({ status: 'error', error: error.message });
263
+ }
264
+ });
265
+
266
+ /**
267
+ * GET /api/accounts/export - Export accounts
268
+ */
269
+ app.get('/api/accounts/export', async (req, res) => {
270
+ try {
271
+ const { accounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
272
+
273
+ // Export only essential fields for portability
274
+ const exportData = accounts
275
+ .filter(acc => acc.source !== 'database')
276
+ .map(acc => {
277
+ const essential = { email: acc.email };
278
+ // Use snake_case for compatibility
279
+ if (acc.refreshToken) {
280
+ essential.refresh_token = acc.refreshToken;
281
+ }
282
+ if (acc.apiKey) {
283
+ essential.api_key = acc.apiKey;
284
+ }
285
+ return essential;
286
+ });
287
+
288
+ // Return plain array for simpler format
289
+ res.json(exportData);
290
+ } catch (error) {
291
+ logger.error('[WebUI] Export accounts error:', error);
292
+ res.status(500).json({ status: 'error', error: error.message });
293
+ }
294
+ });
295
+
296
+ /**
297
+ * POST /api/accounts/import - Batch import accounts
298
+ */
299
+ app.post('/api/accounts/import', async (req, res) => {
300
+ try {
301
+ // Support both wrapped format { accounts: [...] } and plain array [...]
302
+ let importAccounts = req.body;
303
+ if (req.body.accounts && Array.isArray(req.body.accounts)) {
304
+ importAccounts = req.body.accounts;
305
+ }
306
+
307
+ if (!Array.isArray(importAccounts) || importAccounts.length === 0) {
308
+ return res.status(400).json({
309
+ status: 'error',
310
+ error: 'accounts must be a non-empty array'
311
+ });
312
+ }
313
+
314
+ const results = { added: [], updated: [], failed: [] };
315
+
316
+ // Load existing accounts once before the loop
317
+ const { accounts: existingAccounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
318
+ const existingEmails = new Set(existingAccounts.map(a => a.email));
319
+
320
+ for (const acc of importAccounts) {
321
+ try {
322
+ // Validate required fields
323
+ if (!acc.email) {
324
+ results.failed.push({ email: acc.email || 'unknown', reason: 'Missing email' });
325
+ continue;
326
+ }
327
+
328
+ // Support both snake_case and camelCase
329
+ const refreshToken = acc.refresh_token || acc.refreshToken;
330
+ const apiKey = acc.api_key || acc.apiKey;
331
+
332
+ // Must have at least one credential
333
+ if (!refreshToken && !apiKey) {
334
+ results.failed.push({ email: acc.email, reason: 'Missing refresh_token or api_key' });
335
+ continue;
336
+ }
337
+
338
+ // Check if account already exists
339
+ const exists = existingEmails.has(acc.email);
340
+
341
+ // Add account
342
+ await addAccount({
343
+ email: acc.email,
344
+ source: apiKey ? 'manual' : 'oauth',
345
+ refreshToken: refreshToken,
346
+ apiKey: apiKey
347
+ });
348
+
349
+ if (exists) {
350
+ results.updated.push(acc.email);
351
+ } else {
352
+ results.added.push(acc.email);
353
+ }
354
+ } catch (err) {
355
+ results.failed.push({ email: acc.email, reason: err.message });
356
+ }
357
+ }
358
+
359
+ // Reload AccountManager
360
+ await accountManager.reload();
361
+
362
+ logger.info(`[WebUI] Import complete: ${results.added.length} added, ${results.updated.length} updated, ${results.failed.length} failed`);
363
+
364
+ res.json({
365
+ status: 'ok',
366
+ results,
367
+ message: `Imported ${results.added.length + results.updated.length} accounts`
368
+ });
369
+ } catch (error) {
370
+ logger.error('[WebUI] Import accounts error:', error);
371
+ res.status(500).json({ status: 'error', error: error.message });
372
+ }
373
+ });
374
+
375
+ // ==========================================
376
+ // Provider Management API
377
+ // ==========================================
378
+
379
+ /**
380
+ * GET /api/providers - List all available authentication providers
381
+ */
382
+ app.get('/api/providers', (req, res) => {
383
+ try {
384
+ const providers = getAllAuthProviders();
385
+ // Add additional metadata from PROVIDER_CONFIG
386
+ const enrichedProviders = providers.map(p => ({
387
+ ...p,
388
+ config: PROVIDER_CONFIG[p.id] || {}
389
+ }));
390
+
391
+ res.json({
392
+ status: 'ok',
393
+ providers: enrichedProviders
394
+ });
395
+ } catch (error) {
396
+ logger.error('[WebUI] Error getting providers:', error);
397
+ res.status(500).json({ status: 'error', error: error.message });
398
+ }
399
+ });
400
+
401
+ /**
402
+ * POST /api/providers/:providerId/validate - Validate credentials for a provider
403
+ */
404
+ app.post('/api/providers/:providerId/validate', async (req, res) => {
405
+ try {
406
+ const { providerId } = req.params;
407
+ const { email, apiKey, customApiEndpoint } = req.body;
408
+
409
+ if (!email) {
410
+ return res.status(400).json({ status: 'error', error: 'email is required' });
411
+ }
412
+
413
+ // Create temporary account object for validation
414
+ const tempAccount = {
415
+ email,
416
+ apiKey,
417
+ customApiEndpoint,
418
+ provider: providerId
419
+ };
420
+
421
+ // Get provider and validate
422
+ const provider = getAuthProvider(providerId);
423
+ const result = await provider.validateCredentials(tempAccount);
424
+
425
+ res.json({
426
+ status: 'ok',
427
+ valid: result.valid,
428
+ email: result.email,
429
+ error: result.error || null
430
+ });
431
+ } catch (error) {
432
+ logger.error('[WebUI] Error validating provider credentials:', error);
433
+ res.status(500).json({ status: 'error', error: error.message });
434
+ }
435
+ });
436
+
437
+ /**
438
+ * POST /api/accounts/add - Add account with provider
439
+ */
440
+ app.post('/api/accounts/add', async (req, res) => {
441
+ try {
442
+ const { email, provider, apiKey, customApiEndpoint } = req.body;
443
+
444
+ if (!email) {
445
+ return res.status(400).json({ status: 'error', error: 'email is required' });
446
+ }
447
+
448
+ if (!provider) {
449
+ return res.status(400).json({ status: 'error', error: 'provider is required' });
450
+ }
451
+
452
+ // For non-Google, non-Copilot providers, API key is required
453
+ if (provider !== 'google' && provider !== 'copilot' && !apiKey) {
454
+ return res.status(400).json({ status: 'error', error: 'apiKey is required for this provider' });
455
+ }
456
+
457
+ // Add account
458
+ await addAccount({
459
+ email,
460
+ provider,
461
+ source: provider === 'google' ? 'oauth' : 'manual',
462
+ apiKey,
463
+ customApiEndpoint
464
+ });
465
+
466
+ // Reload AccountManager
467
+ await accountManager.reload();
468
+
469
+ res.json({
470
+ status: 'ok',
471
+ message: `Account ${email} added with provider ${provider}`
472
+ });
473
+ } catch (error) {
474
+ logger.error('[WebUI] Error adding account:', error);
475
+ res.status(500).json({ status: 'error', error: error.message });
476
+ }
477
+ });
478
+
479
+ // ==========================================
480
+ // GitHub Copilot Device Auth API
481
+ // ==========================================
482
+
483
+ // Pending Copilot device auth flows
484
+ const pendingCopilotFlows = new Map();
485
+
486
+ /**
487
+ * POST /api/copilot/device-auth - Initiate Copilot device authorization
488
+ */
489
+ app.post('/api/copilot/device-auth', async (req, res) => {
490
+ try {
491
+ const { CopilotProvider } = await import('../providers/copilot.js');
492
+ const deviceData = await CopilotProvider.initiateDeviceAuth();
493
+
494
+ // Store the flow
495
+ const flowId = crypto.randomUUID();
496
+ pendingCopilotFlows.set(flowId, {
497
+ deviceCode: deviceData.device_code,
498
+ interval: deviceData.interval || 5,
499
+ timestamp: Date.now()
500
+ });
501
+
502
+ // Clean up old flows (> 10 mins)
503
+ const now = Date.now();
504
+ for (const [key, val] of pendingCopilotFlows.entries()) {
505
+ if (now - val.timestamp > 10 * 60 * 1000) {
506
+ pendingCopilotFlows.delete(key);
507
+ }
508
+ }
509
+
510
+ res.json({
511
+ status: 'ok',
512
+ flowId,
513
+ verificationUri: deviceData.verification_uri,
514
+ userCode: deviceData.user_code,
515
+ expiresIn: deviceData.expires_in,
516
+ interval: deviceData.interval || 5
517
+ });
518
+ } catch (error) {
519
+ logger.error('[WebUI] Copilot device auth error:', error);
520
+ res.status(500).json({ status: 'error', error: error.message });
521
+ }
522
+ });
523
+
524
+ /**
525
+ * POST /api/copilot/poll-token - Poll for Copilot token after user authorizes
526
+ */
527
+ app.post('/api/copilot/poll-token', async (req, res) => {
528
+ try {
529
+ const { flowId } = req.body;
530
+ if (!flowId) {
531
+ return res.status(400).json({ status: 'error', error: 'flowId is required' });
532
+ }
533
+
534
+ const flow = pendingCopilotFlows.get(flowId);
535
+ if (!flow) {
536
+ return res.status(400).json({ status: 'error', error: 'Flow not found or expired' });
537
+ }
538
+
539
+ const { CopilotProvider } = await import('../providers/copilot.js');
540
+
541
+ // Single poll attempt (client will retry)
542
+ const response = await fetch('https://github.com/login/oauth/access_token', {
543
+ method: 'POST',
544
+ headers: {
545
+ 'Accept': 'application/json',
546
+ 'Content-Type': 'application/json',
547
+ 'User-Agent': 'commons-proxy/2.0.0'
548
+ },
549
+ body: JSON.stringify({
550
+ client_id: 'Iv1.b507a08c87ecfe98',
551
+ device_code: flow.deviceCode,
552
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
553
+ })
554
+ });
555
+
556
+ const data = await response.json();
557
+
558
+ if (data.access_token) {
559
+ // Got the token! Get user info and add account
560
+ const userInfo = await CopilotProvider.getUserInfo(data.access_token);
561
+
562
+ await addAccount({
563
+ email: userInfo.email,
564
+ provider: 'copilot',
565
+ source: 'manual',
566
+ apiKey: data.access_token
567
+ });
568
+
569
+ await accountManager.reload();
570
+ pendingCopilotFlows.delete(flowId);
571
+
572
+ logger.success(`[WebUI] Copilot account ${userInfo.email} added via device auth`);
573
+
574
+ res.json({
575
+ status: 'ok',
576
+ completed: true,
577
+ email: userInfo.email,
578
+ message: `Account ${userInfo.email} added successfully`
579
+ });
580
+ } else if (data.error === 'authorization_pending') {
581
+ res.json({ status: 'ok', completed: false, pending: true });
582
+ } else if (data.error === 'slow_down') {
583
+ flow.interval = (flow.interval || 5) + 5;
584
+ res.json({ status: 'ok', completed: false, pending: true, interval: flow.interval });
585
+ } else if (data.error === 'expired_token') {
586
+ pendingCopilotFlows.delete(flowId);
587
+ res.json({ status: 'error', error: 'Device code expired. Please try again.' });
588
+ } else if (data.error) {
589
+ pendingCopilotFlows.delete(flowId);
590
+ res.json({ status: 'error', error: data.error_description || data.error });
591
+ } else {
592
+ res.json({ status: 'ok', completed: false, pending: true });
593
+ }
594
+ } catch (error) {
595
+ logger.error('[WebUI] Copilot poll token error:', error);
596
+ res.status(500).json({ status: 'error', error: error.message });
597
+ }
598
+ });
599
+
600
+ // ==========================================
601
+ // Configuration API
602
+ // ==========================================
603
+
604
+ /**
605
+ * GET /api/config - Get server configuration
606
+ */
607
+ app.get('/api/config', (req, res) => {
608
+ try {
609
+ const publicConfig = getPublicConfig();
610
+ res.json({
611
+ status: 'ok',
612
+ config: publicConfig,
613
+ version: packageVersion,
614
+ note: 'Edit ~/.config/commons-proxy/config.json or use env vars to change these values'
615
+ });
616
+ } catch (error) {
617
+ logger.error('[WebUI] Error getting config:', error);
618
+ res.status(500).json({ status: 'error', error: error.message });
619
+ }
620
+ });
621
+
622
+ /**
623
+ * POST /api/config - Update server configuration
624
+ */
625
+ app.post('/api/config', (req, res) => {
626
+ try {
627
+ const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
628
+
629
+ // Only allow updating specific fields (security)
630
+ const updates = {};
631
+ if (typeof debug === 'boolean') updates.debug = debug;
632
+ if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) {
633
+ updates.logLevel = logLevel;
634
+ }
635
+ if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) {
636
+ updates.maxRetries = maxRetries;
637
+ }
638
+ if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) {
639
+ updates.retryBaseMs = retryBaseMs;
640
+ }
641
+ if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) {
642
+ updates.retryMaxMs = retryMaxMs;
643
+ }
644
+ if (typeof persistTokenCache === 'boolean') {
645
+ updates.persistTokenCache = persistTokenCache;
646
+ }
647
+ if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) {
648
+ updates.defaultCooldownMs = defaultCooldownMs;
649
+ }
650
+ if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
651
+ updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
652
+ }
653
+ if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
654
+ updates.maxAccounts = maxAccounts;
655
+ }
656
+ if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
657
+ updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs;
658
+ }
659
+ if (typeof maxConsecutiveFailures === 'number' && maxConsecutiveFailures >= 1 && maxConsecutiveFailures <= 10) {
660
+ updates.maxConsecutiveFailures = maxConsecutiveFailures;
661
+ }
662
+ if (typeof extendedCooldownMs === 'number' && extendedCooldownMs >= 10000 && extendedCooldownMs <= 300000) {
663
+ updates.extendedCooldownMs = extendedCooldownMs;
664
+ }
665
+ if (typeof maxCapacityRetries === 'number' && maxCapacityRetries >= 1 && maxCapacityRetries <= 10) {
666
+ updates.maxCapacityRetries = maxCapacityRetries;
667
+ }
668
+ // Account selection strategy validation
669
+ if (accountSelection && typeof accountSelection === 'object') {
670
+ const validStrategies = ['sticky', 'round-robin', 'hybrid'];
671
+ if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) {
672
+ updates.accountSelection = {
673
+ ...(config.accountSelection || {}),
674
+ strategy: accountSelection.strategy
675
+ };
676
+ }
677
+ }
678
+
679
+ if (Object.keys(updates).length === 0) {
680
+ return res.status(400).json({
681
+ status: 'error',
682
+ error: 'No valid configuration updates provided'
683
+ });
684
+ }
685
+
686
+ const success = saveConfig(updates);
687
+
688
+ if (success) {
689
+ res.json({
690
+ status: 'ok',
691
+ message: 'Configuration saved. Restart server to apply some changes.',
692
+ updates: updates,
693
+ config: getPublicConfig()
694
+ });
695
+ } else {
696
+ res.status(500).json({
697
+ status: 'error',
698
+ error: 'Failed to save configuration file'
699
+ });
700
+ }
701
+ } catch (error) {
702
+ logger.error('[WebUI] Error updating config:', error);
703
+ res.status(500).json({ status: 'error', error: error.message });
704
+ }
705
+ });
706
+
707
+ /**
708
+ * POST /api/config/password - Change WebUI password
709
+ */
710
+ app.post('/api/config/password', (req, res) => {
711
+ try {
712
+ const { oldPassword, newPassword } = req.body;
713
+
714
+ // Validate input
715
+ if (!newPassword || typeof newPassword !== 'string') {
716
+ return res.status(400).json({
717
+ status: 'error',
718
+ error: 'New password is required'
719
+ });
720
+ }
721
+
722
+ // If current password exists, verify old password
723
+ if (config.webuiPassword && config.webuiPassword !== oldPassword) {
724
+ return res.status(403).json({
725
+ status: 'error',
726
+ error: 'Invalid current password'
727
+ });
728
+ }
729
+
730
+ // Save new password
731
+ const success = saveConfig({ webuiPassword: newPassword });
732
+
733
+ if (success) {
734
+ // Update in-memory config
735
+ config.webuiPassword = newPassword;
736
+ res.json({
737
+ status: 'ok',
738
+ message: 'Password changed successfully'
739
+ });
740
+ } else {
741
+ throw new Error('Failed to save password to config file');
742
+ }
743
+ } catch (error) {
744
+ logger.error('[WebUI] Error changing password:', error);
745
+ res.status(500).json({ status: 'error', error: error.message });
746
+ }
747
+ });
748
+
749
+ /**
750
+ * GET /api/settings - Get runtime settings
751
+ */
752
+ app.get('/api/settings', async (req, res) => {
753
+ try {
754
+ const settings = accountManager.getSettings ? accountManager.getSettings() : {};
755
+ res.json({
756
+ status: 'ok',
757
+ settings: {
758
+ ...settings,
759
+ port: process.env.PORT || DEFAULT_PORT
760
+ }
761
+ });
762
+ } catch (error) {
763
+ res.status(500).json({ status: 'error', error: error.message });
764
+ }
765
+ });
766
+
767
+ // ==========================================
768
+ // Claude CLI Configuration API
769
+ // ==========================================
770
+
771
+ /**
772
+ * GET /api/claude/config - Get Claude CLI configuration
773
+ */
774
+ app.get('/api/claude/config', async (req, res) => {
775
+ try {
776
+ const claudeConfig = await readClaudeConfig();
777
+ res.json({
778
+ status: 'ok',
779
+ config: claudeConfig,
780
+ path: getClaudeConfigPath()
781
+ });
782
+ } catch (error) {
783
+ res.status(500).json({ status: 'error', error: error.message });
784
+ }
785
+ });
786
+
787
+ /**
788
+ * POST /api/claude/config - Update Claude CLI configuration
789
+ */
790
+ app.post('/api/claude/config', async (req, res) => {
791
+ try {
792
+ const updates = req.body;
793
+ if (!updates || typeof updates !== 'object') {
794
+ return res.status(400).json({ status: 'error', error: 'Invalid config updates' });
795
+ }
796
+
797
+ const newConfig = await updateClaudeConfig(updates);
798
+ res.json({
799
+ status: 'ok',
800
+ config: newConfig,
801
+ message: 'Claude configuration updated'
802
+ });
803
+ } catch (error) {
804
+ res.status(500).json({ status: 'error', error: error.message });
805
+ }
806
+ });
807
+
808
+ /**
809
+ * POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings)
810
+ */
811
+ app.post('/api/claude/config/restore', async (req, res) => {
812
+ try {
813
+ const claudeConfig = await readClaudeConfig();
814
+
815
+ // Proxy-related environment variables to remove when restoring defaults
816
+ const PROXY_ENV_VARS = [
817
+ 'ANTHROPIC_BASE_URL',
818
+ 'ANTHROPIC_AUTH_TOKEN',
819
+ 'ANTHROPIC_MODEL',
820
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
821
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
822
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
823
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
824
+ 'ENABLE_EXPERIMENTAL_MCP_CLI'
825
+ ];
826
+
827
+ // Remove proxy-related environment variables to restore defaults
828
+ if (claudeConfig.env) {
829
+ for (const key of PROXY_ENV_VARS) {
830
+ delete claudeConfig.env[key];
831
+ }
832
+ // Remove env entirely if empty to truly restore defaults
833
+ if (Object.keys(claudeConfig.env).length === 0) {
834
+ delete claudeConfig.env;
835
+ }
836
+ }
837
+
838
+ // Use replaceClaudeConfig to completely overwrite the config (not merge)
839
+ const newConfig = await replaceClaudeConfig(claudeConfig);
840
+
841
+ logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`);
842
+
843
+ res.json({
844
+ status: 'ok',
845
+ config: newConfig,
846
+ message: 'Claude CLI configuration restored to defaults'
847
+ });
848
+ } catch (error) {
849
+ logger.error('[WebUI] Error restoring Claude config:', error);
850
+ res.status(500).json({ status: 'error', error: error.message });
851
+ }
852
+ });
853
+
854
+ // ==========================================
855
+ // Claude CLI Presets API
856
+ // ==========================================
857
+
858
+ /**
859
+ * GET /api/claude/presets - Get all saved presets
860
+ */
861
+ app.get('/api/claude/presets', async (req, res) => {
862
+ try {
863
+ const presets = await readPresets();
864
+ res.json({ status: 'ok', presets });
865
+ } catch (error) {
866
+ res.status(500).json({ status: 'error', error: error.message });
867
+ }
868
+ });
869
+
870
+ /**
871
+ * POST /api/claude/presets - Save a new preset
872
+ */
873
+ app.post('/api/claude/presets', async (req, res) => {
874
+ try {
875
+ const { name, config: presetConfig } = req.body;
876
+ if (!name || typeof name !== 'string' || !name.trim()) {
877
+ return res.status(400).json({ status: 'error', error: 'Preset name is required' });
878
+ }
879
+ if (!presetConfig || typeof presetConfig !== 'object') {
880
+ return res.status(400).json({ status: 'error', error: 'Config object is required' });
881
+ }
882
+
883
+ const presets = await savePreset(name.trim(), presetConfig);
884
+ res.json({ status: 'ok', presets, message: `Preset "${name}" saved` });
885
+ } catch (error) {
886
+ res.status(500).json({ status: 'error', error: error.message });
887
+ }
888
+ });
889
+
890
+ /**
891
+ * DELETE /api/claude/presets/:name - Delete a preset
892
+ */
893
+ app.delete('/api/claude/presets/:name', async (req, res) => {
894
+ try {
895
+ const { name } = req.params;
896
+ if (!name) {
897
+ return res.status(400).json({ status: 'error', error: 'Preset name is required' });
898
+ }
899
+
900
+ const presets = await deletePreset(name);
901
+ res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` });
902
+ } catch (error) {
903
+ res.status(500).json({ status: 'error', error: error.message });
904
+ }
905
+ });
906
+
907
+ /**
908
+ * POST /api/models/config - Update model configuration (hidden/pinned/alias)
909
+ */
910
+ app.post('/api/models/config', (req, res) => {
911
+ try {
912
+ const { modelId, config: newModelConfig } = req.body;
913
+
914
+ if (!modelId || typeof newModelConfig !== 'object') {
915
+ return res.status(400).json({ status: 'error', error: 'Invalid parameters' });
916
+ }
917
+
918
+ // Load current config
919
+ const currentMapping = config.modelMapping || {};
920
+
921
+ // Update specific model config
922
+ currentMapping[modelId] = {
923
+ ...currentMapping[modelId],
924
+ ...newModelConfig
925
+ };
926
+
927
+ // Save back to main config
928
+ const success = saveConfig({ modelMapping: currentMapping });
929
+
930
+ if (success) {
931
+ // Update in-memory config reference
932
+ config.modelMapping = currentMapping;
933
+ res.json({ status: 'ok', modelConfig: currentMapping[modelId] });
934
+ } else {
935
+ throw new Error('Failed to save configuration');
936
+ }
937
+ } catch (error) {
938
+ res.status(500).json({ status: 'error', error: error.message });
939
+ }
940
+ });
941
+
942
+ // ==========================================
943
+ // Logs API
944
+ // ==========================================
945
+
946
+ /**
947
+ * GET /api/logs - Get log history
948
+ */
949
+ app.get('/api/logs', (req, res) => {
950
+ res.json({
951
+ status: 'ok',
952
+ logs: logger.getHistory ? logger.getHistory() : []
953
+ });
954
+ });
955
+
956
+ /**
957
+ * GET /api/logs/stream - Stream logs via SSE
958
+ */
959
+ app.get('/api/logs/stream', (req, res) => {
960
+ res.setHeader('Content-Type', 'text/event-stream');
961
+ res.setHeader('Cache-Control', 'no-cache');
962
+ res.setHeader('Connection', 'keep-alive');
963
+
964
+ const sendLog = (log) => {
965
+ res.write(`data: ${JSON.stringify(log)}\n\n`);
966
+ };
967
+
968
+ // Send recent history if requested
969
+ if (req.query.history === 'true' && logger.getHistory) {
970
+ const history = logger.getHistory();
971
+ history.forEach(log => sendLog(log));
972
+ }
973
+
974
+ // Subscribe to new logs
975
+ if (logger.on) {
976
+ logger.on('log', sendLog);
977
+ }
978
+
979
+ // Cleanup on disconnect
980
+ req.on('close', () => {
981
+ if (logger.off) {
982
+ logger.off('log', sendLog);
983
+ }
984
+ });
985
+ });
986
+
987
+ // ==========================================
988
+ // OAuth API
989
+ // ==========================================
990
+
991
+ /**
992
+ * GET /api/auth/url - Get OAuth URL to start the flow
993
+ * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port
994
+ * to match Google OAuth Console's authorized redirect URIs
995
+ */
996
+ app.get('/api/auth/url', async (req, res) => {
997
+ try {
998
+ // Clean up old flows (> 10 mins)
999
+ const now = Date.now();
1000
+ for (const [key, val] of pendingOAuthFlows.entries()) {
1001
+ if (now - val.timestamp > 10 * 60 * 1000) {
1002
+ pendingOAuthFlows.delete(key);
1003
+ }
1004
+ }
1005
+
1006
+ // Generate OAuth URL using default redirect URI (localhost:51121)
1007
+ const { url, verifier, state } = getAuthorizationUrl();
1008
+
1009
+ // Start callback server on port 51121 (same as CLI)
1010
+ const { promise: serverPromise, abort: abortServer } = startCallbackServer(state, 120000); // 2 min timeout
1011
+
1012
+ // Store the flow data
1013
+ pendingOAuthFlows.set(state, {
1014
+ serverPromise,
1015
+ abortServer,
1016
+ verifier,
1017
+ state,
1018
+ timestamp: Date.now()
1019
+ });
1020
+
1021
+ // Start async handler for the OAuth callback
1022
+ serverPromise
1023
+ .then(async (code) => {
1024
+ try {
1025
+ logger.info('[WebUI] Received OAuth callback, completing flow...');
1026
+ const accountData = await completeOAuthFlow(code, verifier);
1027
+
1028
+ // Add or update the account
1029
+ // Note: Don't set projectId here - it will be discovered and stored
1030
+ // in the refresh token via getProjectForAccount() on first use
1031
+ await addAccount({
1032
+ email: accountData.email,
1033
+ refreshToken: accountData.refreshToken,
1034
+ source: 'oauth'
1035
+ });
1036
+
1037
+ // Reload AccountManager to pick up the new account
1038
+ await accountManager.reload();
1039
+
1040
+ logger.success(`[WebUI] Account ${accountData.email} added successfully`);
1041
+ } catch (err) {
1042
+ logger.error('[WebUI] OAuth flow completion error:', err);
1043
+ } finally {
1044
+ pendingOAuthFlows.delete(state);
1045
+ }
1046
+ })
1047
+ .catch((err) => {
1048
+ // Only log if not aborted (manual completion causes this)
1049
+ if (!err.message?.includes('aborted')) {
1050
+ logger.error('[WebUI] OAuth callback server error:', err);
1051
+ }
1052
+ pendingOAuthFlows.delete(state);
1053
+ });
1054
+
1055
+ res.json({ status: 'ok', url, state });
1056
+ } catch (error) {
1057
+ logger.error('[WebUI] Error generating auth URL:', error);
1058
+ res.status(500).json({ status: 'error', error: error.message });
1059
+ }
1060
+ });
1061
+
1062
+ /**
1063
+ * POST /api/auth/complete - Complete OAuth with manually submitted callback URL/code
1064
+ * Used when auto-callback cannot reach the local server
1065
+ */
1066
+ app.post('/api/auth/complete', async (req, res) => {
1067
+ try {
1068
+ const { callbackInput, state } = req.body;
1069
+
1070
+ if (!callbackInput || !state) {
1071
+ return res.status(400).json({
1072
+ status: 'error',
1073
+ error: 'Missing callbackInput or state'
1074
+ });
1075
+ }
1076
+
1077
+ // Find the pending flow
1078
+ const flowData = pendingOAuthFlows.get(state);
1079
+ if (!flowData) {
1080
+ return res.status(400).json({
1081
+ status: 'error',
1082
+ error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.'
1083
+ });
1084
+ }
1085
+
1086
+ const { verifier, abortServer } = flowData;
1087
+
1088
+ // Extract code from input (URL or raw code)
1089
+ const { extractCodeFromInput, completeOAuthFlow } = await import('../auth/oauth.js');
1090
+ const { code } = extractCodeFromInput(callbackInput);
1091
+
1092
+ // Complete the OAuth flow
1093
+ const accountData = await completeOAuthFlow(code, verifier);
1094
+
1095
+ // Add or update the account
1096
+ await addAccount({
1097
+ email: accountData.email,
1098
+ refreshToken: accountData.refreshToken,
1099
+ projectId: accountData.projectId,
1100
+ source: 'oauth'
1101
+ });
1102
+
1103
+ // Reload AccountManager to pick up the new account
1104
+ await accountManager.reload();
1105
+
1106
+ // Abort the callback server since manual completion succeeded
1107
+ if (abortServer) {
1108
+ abortServer();
1109
+ }
1110
+
1111
+ // Clean up
1112
+ pendingOAuthFlows.delete(state);
1113
+
1114
+ logger.success(`[WebUI] Account ${accountData.email} added via manual callback`);
1115
+
1116
+ res.json({
1117
+ status: 'ok',
1118
+ email: accountData.email,
1119
+ message: `Account ${accountData.email} added successfully`
1120
+ });
1121
+ } catch (error) {
1122
+ logger.error('[WebUI] Manual OAuth completion error:', error);
1123
+ res.status(500).json({ status: 'error', error: error.message });
1124
+ }
1125
+ });
1126
+
1127
+ /**
1128
+ * Note: /oauth/callback route removed
1129
+ * OAuth callbacks are now handled by the temporary server on port 51121
1130
+ * (same as CLI) to match Google OAuth Console's authorized redirect URIs
1131
+ */
1132
+
1133
+ logger.info('[WebUI] Mounted at /');
1134
+ }