@kamel-ahmed/proxy-claude 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -0
  3. package/bin/cli.js +124 -0
  4. package/package.json +80 -0
  5. package/public/app.js +228 -0
  6. package/public/css/src/input.css +523 -0
  7. package/public/css/style.css +1 -0
  8. package/public/favicon.svg +10 -0
  9. package/public/index.html +381 -0
  10. package/public/js/components/account-manager.js +245 -0
  11. package/public/js/components/claude-config.js +420 -0
  12. package/public/js/components/dashboard/charts.js +589 -0
  13. package/public/js/components/dashboard/filters.js +362 -0
  14. package/public/js/components/dashboard/stats.js +110 -0
  15. package/public/js/components/dashboard.js +236 -0
  16. package/public/js/components/logs-viewer.js +100 -0
  17. package/public/js/components/models.js +36 -0
  18. package/public/js/components/server-config.js +349 -0
  19. package/public/js/config/constants.js +102 -0
  20. package/public/js/data-store.js +386 -0
  21. package/public/js/settings-store.js +58 -0
  22. package/public/js/store.js +78 -0
  23. package/public/js/translations/en.js +351 -0
  24. package/public/js/translations/id.js +396 -0
  25. package/public/js/translations/pt.js +287 -0
  26. package/public/js/translations/tr.js +342 -0
  27. package/public/js/translations/zh.js +357 -0
  28. package/public/js/utils/account-actions.js +189 -0
  29. package/public/js/utils/error-handler.js +96 -0
  30. package/public/js/utils/model-config.js +42 -0
  31. package/public/js/utils/validators.js +77 -0
  32. package/public/js/utils.js +69 -0
  33. package/public/views/accounts.html +329 -0
  34. package/public/views/dashboard.html +484 -0
  35. package/public/views/logs.html +97 -0
  36. package/public/views/models.html +331 -0
  37. package/public/views/settings.html +1329 -0
  38. package/src/account-manager/credentials.js +243 -0
  39. package/src/account-manager/index.js +380 -0
  40. package/src/account-manager/onboarding.js +117 -0
  41. package/src/account-manager/rate-limits.js +237 -0
  42. package/src/account-manager/storage.js +136 -0
  43. package/src/account-manager/strategies/base-strategy.js +104 -0
  44. package/src/account-manager/strategies/hybrid-strategy.js +195 -0
  45. package/src/account-manager/strategies/index.js +79 -0
  46. package/src/account-manager/strategies/round-robin-strategy.js +76 -0
  47. package/src/account-manager/strategies/sticky-strategy.js +138 -0
  48. package/src/account-manager/strategies/trackers/health-tracker.js +162 -0
  49. package/src/account-manager/strategies/trackers/index.js +8 -0
  50. package/src/account-manager/strategies/trackers/token-bucket-tracker.js +121 -0
  51. package/src/auth/database.js +169 -0
  52. package/src/auth/oauth.js +419 -0
  53. package/src/auth/token-extractor.js +117 -0
  54. package/src/cli/accounts.js +512 -0
  55. package/src/cli/refresh.js +201 -0
  56. package/src/cli/setup.js +338 -0
  57. package/src/cloudcode/index.js +29 -0
  58. package/src/cloudcode/message-handler.js +386 -0
  59. package/src/cloudcode/model-api.js +248 -0
  60. package/src/cloudcode/rate-limit-parser.js +181 -0
  61. package/src/cloudcode/request-builder.js +93 -0
  62. package/src/cloudcode/session-manager.js +47 -0
  63. package/src/cloudcode/sse-parser.js +121 -0
  64. package/src/cloudcode/sse-streamer.js +293 -0
  65. package/src/cloudcode/streaming-handler.js +492 -0
  66. package/src/config.js +107 -0
  67. package/src/constants.js +278 -0
  68. package/src/errors.js +238 -0
  69. package/src/fallback-config.js +29 -0
  70. package/src/format/content-converter.js +193 -0
  71. package/src/format/index.js +20 -0
  72. package/src/format/request-converter.js +248 -0
  73. package/src/format/response-converter.js +120 -0
  74. package/src/format/schema-sanitizer.js +673 -0
  75. package/src/format/signature-cache.js +88 -0
  76. package/src/format/thinking-utils.js +558 -0
  77. package/src/index.js +146 -0
  78. package/src/modules/usage-stats.js +205 -0
  79. package/src/server.js +861 -0
  80. package/src/utils/claude-config.js +245 -0
  81. package/src/utils/helpers.js +51 -0
  82. package/src/utils/logger.js +142 -0
  83. package/src/utils/native-module-helper.js +162 -0
  84. package/src/webui/index.js +707 -0
@@ -0,0 +1,707 @@
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 { readFileSync } from 'fs';
17
+ import { fileURLToPath } from 'url';
18
+ import express from 'express';
19
+ import { getPublicConfig, saveConfig, config } from '../config.js';
20
+ import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
21
+ import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
22
+ import { logger } from '../utils/logger.js';
23
+ import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
24
+ import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
25
+
26
+ // Get package version
27
+ const __filename = fileURLToPath(import.meta.url);
28
+ const __dirname = path.dirname(__filename);
29
+ let packageVersion = '1.0.0';
30
+ try {
31
+ const packageJsonPath = path.join(__dirname, '../../package.json');
32
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
33
+ packageVersion = packageJson.version;
34
+ } catch (error) {
35
+ logger.warn('[WebUI] Could not read package.json version, using default');
36
+ }
37
+
38
+ // OAuth state storage (state -> { server, verifier, state, timestamp })
39
+ // Maps state ID to active OAuth flow data
40
+ const pendingOAuthFlows = new Map();
41
+
42
+ /**
43
+ * WebUI Helper Functions - Direct account manipulation
44
+ * These functions work around AccountManager's limited API by directly
45
+ * manipulating the accounts.json config file (non-invasive approach for PR)
46
+ */
47
+
48
+ /**
49
+ * Set account enabled/disabled state
50
+ */
51
+ async function setAccountEnabled(email, enabled) {
52
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
53
+ const account = accounts.find(a => a.email === email);
54
+ if (!account) {
55
+ throw new Error(`Account ${email} not found`);
56
+ }
57
+ account.enabled = enabled;
58
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
59
+ logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`);
60
+ }
61
+
62
+ /**
63
+ * Remove account from config
64
+ */
65
+ async function removeAccount(email) {
66
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
67
+ const index = accounts.findIndex(a => a.email === email);
68
+ if (index === -1) {
69
+ throw new Error(`Account ${email} not found`);
70
+ }
71
+ accounts.splice(index, 1);
72
+ // Adjust activeIndex if needed
73
+ const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex;
74
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex);
75
+ logger.info(`[WebUI] Account ${email} removed`);
76
+ }
77
+
78
+ /**
79
+ * Add new account to config
80
+ */
81
+ async function addAccount(accountData) {
82
+ const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
83
+
84
+ // Check if account already exists
85
+ const existingIndex = accounts.findIndex(a => a.email === accountData.email);
86
+ if (existingIndex !== -1) {
87
+ // Update existing account
88
+ accounts[existingIndex] = {
89
+ ...accounts[existingIndex],
90
+ ...accountData,
91
+ enabled: true,
92
+ isInvalid: false,
93
+ invalidReason: null,
94
+ addedAt: accounts[existingIndex].addedAt || new Date().toISOString()
95
+ };
96
+ logger.info(`[WebUI] Account ${accountData.email} updated`);
97
+ } else {
98
+ // Add new account
99
+ accounts.push({
100
+ ...accountData,
101
+ enabled: true,
102
+ isInvalid: false,
103
+ invalidReason: null,
104
+ modelRateLimits: {},
105
+ lastUsed: null,
106
+ addedAt: new Date().toISOString()
107
+ });
108
+ logger.info(`[WebUI] Account ${accountData.email} added`);
109
+ }
110
+
111
+ await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
112
+ }
113
+
114
+ /**
115
+ * Auth Middleware - Optional password protection for WebUI
116
+ * Password can be set via WEBUI_PASSWORD env var or config.json
117
+ */
118
+ function createAuthMiddleware() {
119
+ return (req, res, next) => {
120
+ const password = config.webuiPassword;
121
+ if (!password) return next();
122
+
123
+ // Determine if this path should be protected
124
+ const isApiRoute = req.path.startsWith('/api/');
125
+ const isException = req.path === '/api/auth/url' || req.path === '/api/config';
126
+ const isProtected = (isApiRoute && !isException) || req.path === '/account-limits' || req.path === '/health';
127
+
128
+ if (isProtected) {
129
+ const providedPassword = req.headers['x-webui-password'] || req.query.password;
130
+ if (providedPassword !== password) {
131
+ return res.status(401).json({ status: 'error', error: 'Unauthorized: Password required' });
132
+ }
133
+ }
134
+ next();
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Mount WebUI routes and middleware on Express app
140
+ * @param {Express} app - Express application instance
141
+ * @param {string} dirname - __dirname of the calling module (for static file path)
142
+ * @param {AccountManager} accountManager - Account manager instance
143
+ */
144
+ export function mountWebUI(app, dirname, accountManager) {
145
+ // Apply auth middleware
146
+ app.use(createAuthMiddleware());
147
+
148
+ // Serve static files from public directory
149
+ app.use(express.static(path.join(dirname, '../public')));
150
+
151
+ // ==========================================
152
+ // Account Management API
153
+ // ==========================================
154
+
155
+ /**
156
+ * GET /api/accounts - List all accounts with status
157
+ */
158
+ app.get('/api/accounts', async (req, res) => {
159
+ try {
160
+ const status = accountManager.getStatus();
161
+ res.json({
162
+ status: 'ok',
163
+ accounts: status.accounts,
164
+ summary: {
165
+ total: status.total,
166
+ available: status.available,
167
+ rateLimited: status.rateLimited,
168
+ invalid: status.invalid
169
+ }
170
+ });
171
+ } catch (error) {
172
+ res.status(500).json({ status: 'error', error: error.message });
173
+ }
174
+ });
175
+
176
+ /**
177
+ * POST /api/accounts/:email/refresh - Refresh specific account token
178
+ */
179
+ app.post('/api/accounts/:email/refresh', async (req, res) => {
180
+ try {
181
+ const { email } = req.params;
182
+ accountManager.clearTokenCache(email);
183
+ accountManager.clearProjectCache(email);
184
+ res.json({
185
+ status: 'ok',
186
+ message: `Token cache cleared for ${email}`
187
+ });
188
+ } catch (error) {
189
+ res.status(500).json({ status: 'error', error: error.message });
190
+ }
191
+ });
192
+
193
+ /**
194
+ * POST /api/accounts/:email/toggle - Enable/disable account
195
+ */
196
+ app.post('/api/accounts/:email/toggle', async (req, res) => {
197
+ try {
198
+ const { email } = req.params;
199
+ const { enabled } = req.body;
200
+
201
+ if (typeof enabled !== 'boolean') {
202
+ return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
203
+ }
204
+
205
+ await setAccountEnabled(email, enabled);
206
+
207
+ // Reload AccountManager to pick up changes
208
+ await accountManager.reload();
209
+
210
+ res.json({
211
+ status: 'ok',
212
+ message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
213
+ });
214
+ } catch (error) {
215
+ res.status(500).json({ status: 'error', error: error.message });
216
+ }
217
+ });
218
+
219
+ /**
220
+ * DELETE /api/accounts/:email - Remove account
221
+ */
222
+ app.delete('/api/accounts/:email', async (req, res) => {
223
+ try {
224
+ const { email } = req.params;
225
+ await removeAccount(email);
226
+
227
+ // Reload AccountManager to pick up changes
228
+ await accountManager.reload();
229
+
230
+ res.json({
231
+ status: 'ok',
232
+ message: `Account ${email} removed`
233
+ });
234
+ } catch (error) {
235
+ res.status(500).json({ status: 'error', error: error.message });
236
+ }
237
+ });
238
+
239
+ /**
240
+ * POST /api/accounts/reload - Reload accounts from disk
241
+ */
242
+ app.post('/api/accounts/reload', async (req, res) => {
243
+ try {
244
+ // Reload AccountManager from disk
245
+ await accountManager.reload();
246
+
247
+ const status = accountManager.getStatus();
248
+ res.json({
249
+ status: 'ok',
250
+ message: 'Accounts reloaded from disk',
251
+ summary: status.summary
252
+ });
253
+ } catch (error) {
254
+ res.status(500).json({ status: 'error', error: error.message });
255
+ }
256
+ });
257
+
258
+ // ==========================================
259
+ // Configuration API
260
+ // ==========================================
261
+
262
+ /**
263
+ * GET /api/config - Get server configuration
264
+ */
265
+ app.get('/api/config', (req, res) => {
266
+ try {
267
+ const publicConfig = getPublicConfig();
268
+ res.json({
269
+ status: 'ok',
270
+ config: publicConfig,
271
+ version: packageVersion,
272
+ note: 'Edit ~/.config/antigravity-proxy/config.json or use env vars to change these values'
273
+ });
274
+ } catch (error) {
275
+ logger.error('[WebUI] Error getting config:', error);
276
+ res.status(500).json({ status: 'error', error: error.message });
277
+ }
278
+ });
279
+
280
+ /**
281
+ * POST /api/config - Update server configuration
282
+ */
283
+ app.post('/api/config', (req, res) => {
284
+ try {
285
+ const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, accountSelection } = req.body;
286
+
287
+ // Only allow updating specific fields (security)
288
+ const updates = {};
289
+ if (typeof debug === 'boolean') updates.debug = debug;
290
+ if (logLevel && ['info', 'warn', 'error', 'debug'].includes(logLevel)) {
291
+ updates.logLevel = logLevel;
292
+ }
293
+ if (typeof maxRetries === 'number' && maxRetries >= 1 && maxRetries <= 20) {
294
+ updates.maxRetries = maxRetries;
295
+ }
296
+ if (typeof retryBaseMs === 'number' && retryBaseMs >= 100 && retryBaseMs <= 10000) {
297
+ updates.retryBaseMs = retryBaseMs;
298
+ }
299
+ if (typeof retryMaxMs === 'number' && retryMaxMs >= 1000 && retryMaxMs <= 120000) {
300
+ updates.retryMaxMs = retryMaxMs;
301
+ }
302
+ if (typeof persistTokenCache === 'boolean') {
303
+ updates.persistTokenCache = persistTokenCache;
304
+ }
305
+ if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) {
306
+ updates.defaultCooldownMs = defaultCooldownMs;
307
+ }
308
+ if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
309
+ updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
310
+ }
311
+ // Account selection strategy validation
312
+ if (accountSelection && typeof accountSelection === 'object') {
313
+ const validStrategies = ['sticky', 'round-robin', 'hybrid'];
314
+ if (accountSelection.strategy && validStrategies.includes(accountSelection.strategy)) {
315
+ updates.accountSelection = {
316
+ ...(config.accountSelection || {}),
317
+ strategy: accountSelection.strategy
318
+ };
319
+ }
320
+ }
321
+
322
+ if (Object.keys(updates).length === 0) {
323
+ return res.status(400).json({
324
+ status: 'error',
325
+ error: 'No valid configuration updates provided'
326
+ });
327
+ }
328
+
329
+ const success = saveConfig(updates);
330
+
331
+ if (success) {
332
+ res.json({
333
+ status: 'ok',
334
+ message: 'Configuration saved. Restart server to apply some changes.',
335
+ updates: updates,
336
+ config: getPublicConfig()
337
+ });
338
+ } else {
339
+ res.status(500).json({
340
+ status: 'error',
341
+ error: 'Failed to save configuration file'
342
+ });
343
+ }
344
+ } catch (error) {
345
+ logger.error('[WebUI] Error updating config:', error);
346
+ res.status(500).json({ status: 'error', error: error.message });
347
+ }
348
+ });
349
+
350
+ /**
351
+ * POST /api/config/password - Change WebUI password
352
+ */
353
+ app.post('/api/config/password', (req, res) => {
354
+ try {
355
+ const { oldPassword, newPassword } = req.body;
356
+
357
+ // Validate input
358
+ if (!newPassword || typeof newPassword !== 'string') {
359
+ return res.status(400).json({
360
+ status: 'error',
361
+ error: 'New password is required'
362
+ });
363
+ }
364
+
365
+ // If current password exists, verify old password
366
+ if (config.webuiPassword && config.webuiPassword !== oldPassword) {
367
+ return res.status(403).json({
368
+ status: 'error',
369
+ error: 'Invalid current password'
370
+ });
371
+ }
372
+
373
+ // Save new password
374
+ const success = saveConfig({ webuiPassword: newPassword });
375
+
376
+ if (success) {
377
+ // Update in-memory config
378
+ config.webuiPassword = newPassword;
379
+ res.json({
380
+ status: 'ok',
381
+ message: 'Password changed successfully'
382
+ });
383
+ } else {
384
+ throw new Error('Failed to save password to config file');
385
+ }
386
+ } catch (error) {
387
+ logger.error('[WebUI] Error changing password:', error);
388
+ res.status(500).json({ status: 'error', error: error.message });
389
+ }
390
+ });
391
+
392
+ /**
393
+ * GET /api/settings - Get runtime settings
394
+ */
395
+ app.get('/api/settings', async (req, res) => {
396
+ try {
397
+ const settings = accountManager.getSettings ? accountManager.getSettings() : {};
398
+ res.json({
399
+ status: 'ok',
400
+ settings: {
401
+ ...settings,
402
+ port: process.env.PORT || DEFAULT_PORT
403
+ }
404
+ });
405
+ } catch (error) {
406
+ res.status(500).json({ status: 'error', error: error.message });
407
+ }
408
+ });
409
+
410
+ // ==========================================
411
+ // Claude CLI Configuration API
412
+ // ==========================================
413
+
414
+ /**
415
+ * GET /api/claude/config - Get Claude CLI configuration
416
+ */
417
+ app.get('/api/claude/config', async (req, res) => {
418
+ try {
419
+ const claudeConfig = await readClaudeConfig();
420
+ res.json({
421
+ status: 'ok',
422
+ config: claudeConfig,
423
+ path: getClaudeConfigPath()
424
+ });
425
+ } catch (error) {
426
+ res.status(500).json({ status: 'error', error: error.message });
427
+ }
428
+ });
429
+
430
+ /**
431
+ * POST /api/claude/config - Update Claude CLI configuration
432
+ */
433
+ app.post('/api/claude/config', async (req, res) => {
434
+ try {
435
+ const updates = req.body;
436
+ if (!updates || typeof updates !== 'object') {
437
+ return res.status(400).json({ status: 'error', error: 'Invalid config updates' });
438
+ }
439
+
440
+ const newConfig = await updateClaudeConfig(updates);
441
+ res.json({
442
+ status: 'ok',
443
+ config: newConfig,
444
+ message: 'Claude configuration updated'
445
+ });
446
+ } catch (error) {
447
+ res.status(500).json({ status: 'error', error: error.message });
448
+ }
449
+ });
450
+
451
+ /**
452
+ * POST /api/claude/config/restore - Restore Claude CLI to default (remove proxy settings)
453
+ */
454
+ app.post('/api/claude/config/restore', async (req, res) => {
455
+ try {
456
+ const claudeConfig = await readClaudeConfig();
457
+
458
+ // Proxy-related environment variables to remove when restoring defaults
459
+ const PROXY_ENV_VARS = [
460
+ 'ANTHROPIC_BASE_URL',
461
+ 'ANTHROPIC_AUTH_TOKEN',
462
+ 'ANTHROPIC_MODEL',
463
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
464
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
465
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
466
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
467
+ 'ENABLE_EXPERIMENTAL_MCP_CLI'
468
+ ];
469
+
470
+ // Remove proxy-related environment variables to restore defaults
471
+ if (claudeConfig.env) {
472
+ for (const key of PROXY_ENV_VARS) {
473
+ delete claudeConfig.env[key];
474
+ }
475
+ // Remove env entirely if empty to truly restore defaults
476
+ if (Object.keys(claudeConfig.env).length === 0) {
477
+ delete claudeConfig.env;
478
+ }
479
+ }
480
+
481
+ // Use replaceClaudeConfig to completely overwrite the config (not merge)
482
+ const newConfig = await replaceClaudeConfig(claudeConfig);
483
+
484
+ logger.info(`[WebUI] Restored Claude CLI config to defaults at ${getClaudeConfigPath()}`);
485
+
486
+ res.json({
487
+ status: 'ok',
488
+ config: newConfig,
489
+ message: 'Claude CLI configuration restored to defaults'
490
+ });
491
+ } catch (error) {
492
+ logger.error('[WebUI] Error restoring Claude config:', error);
493
+ res.status(500).json({ status: 'error', error: error.message });
494
+ }
495
+ });
496
+
497
+ // ==========================================
498
+ // Claude CLI Presets API
499
+ // ==========================================
500
+
501
+ /**
502
+ * GET /api/claude/presets - Get all saved presets
503
+ */
504
+ app.get('/api/claude/presets', async (req, res) => {
505
+ try {
506
+ const presets = await readPresets();
507
+ res.json({ status: 'ok', presets });
508
+ } catch (error) {
509
+ res.status(500).json({ status: 'error', error: error.message });
510
+ }
511
+ });
512
+
513
+ /**
514
+ * POST /api/claude/presets - Save a new preset
515
+ */
516
+ app.post('/api/claude/presets', async (req, res) => {
517
+ try {
518
+ const { name, config: presetConfig } = req.body;
519
+ if (!name || typeof name !== 'string' || !name.trim()) {
520
+ return res.status(400).json({ status: 'error', error: 'Preset name is required' });
521
+ }
522
+ if (!presetConfig || typeof presetConfig !== 'object') {
523
+ return res.status(400).json({ status: 'error', error: 'Config object is required' });
524
+ }
525
+
526
+ const presets = await savePreset(name.trim(), presetConfig);
527
+ res.json({ status: 'ok', presets, message: `Preset "${name}" saved` });
528
+ } catch (error) {
529
+ res.status(500).json({ status: 'error', error: error.message });
530
+ }
531
+ });
532
+
533
+ /**
534
+ * DELETE /api/claude/presets/:name - Delete a preset
535
+ */
536
+ app.delete('/api/claude/presets/:name', async (req, res) => {
537
+ try {
538
+ const { name } = req.params;
539
+ if (!name) {
540
+ return res.status(400).json({ status: 'error', error: 'Preset name is required' });
541
+ }
542
+
543
+ const presets = await deletePreset(name);
544
+ res.json({ status: 'ok', presets, message: `Preset "${name}" deleted` });
545
+ } catch (error) {
546
+ res.status(500).json({ status: 'error', error: error.message });
547
+ }
548
+ });
549
+
550
+ /**
551
+ * POST /api/models/config - Update model configuration (hidden/pinned/alias)
552
+ */
553
+ app.post('/api/models/config', (req, res) => {
554
+ try {
555
+ const { modelId, config: newModelConfig } = req.body;
556
+
557
+ if (!modelId || typeof newModelConfig !== 'object') {
558
+ return res.status(400).json({ status: 'error', error: 'Invalid parameters' });
559
+ }
560
+
561
+ // Load current config
562
+ const currentMapping = config.modelMapping || {};
563
+
564
+ // Update specific model config
565
+ currentMapping[modelId] = {
566
+ ...currentMapping[modelId],
567
+ ...newModelConfig
568
+ };
569
+
570
+ // Save back to main config
571
+ const success = saveConfig({ modelMapping: currentMapping });
572
+
573
+ if (success) {
574
+ // Update in-memory config reference
575
+ config.modelMapping = currentMapping;
576
+ res.json({ status: 'ok', modelConfig: currentMapping[modelId] });
577
+ } else {
578
+ throw new Error('Failed to save configuration');
579
+ }
580
+ } catch (error) {
581
+ res.status(500).json({ status: 'error', error: error.message });
582
+ }
583
+ });
584
+
585
+ // ==========================================
586
+ // Logs API
587
+ // ==========================================
588
+
589
+ /**
590
+ * GET /api/logs - Get log history
591
+ */
592
+ app.get('/api/logs', (req, res) => {
593
+ res.json({
594
+ status: 'ok',
595
+ logs: logger.getHistory ? logger.getHistory() : []
596
+ });
597
+ });
598
+
599
+ /**
600
+ * GET /api/logs/stream - Stream logs via SSE
601
+ */
602
+ app.get('/api/logs/stream', (req, res) => {
603
+ res.setHeader('Content-Type', 'text/event-stream');
604
+ res.setHeader('Cache-Control', 'no-cache');
605
+ res.setHeader('Connection', 'keep-alive');
606
+
607
+ const sendLog = (log) => {
608
+ res.write(`data: ${JSON.stringify(log)}\n\n`);
609
+ };
610
+
611
+ // Send recent history if requested
612
+ if (req.query.history === 'true' && logger.getHistory) {
613
+ const history = logger.getHistory();
614
+ history.forEach(log => sendLog(log));
615
+ }
616
+
617
+ // Subscribe to new logs
618
+ if (logger.on) {
619
+ logger.on('log', sendLog);
620
+ }
621
+
622
+ // Cleanup on disconnect
623
+ req.on('close', () => {
624
+ if (logger.off) {
625
+ logger.off('log', sendLog);
626
+ }
627
+ });
628
+ });
629
+
630
+ // ==========================================
631
+ // OAuth API
632
+ // ==========================================
633
+
634
+ /**
635
+ * GET /api/auth/url - Get OAuth URL to start the flow
636
+ * Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port
637
+ * to match Google OAuth Console's authorized redirect URIs
638
+ */
639
+ app.get('/api/auth/url', async (req, res) => {
640
+ try {
641
+ // Clean up old flows (> 10 mins)
642
+ const now = Date.now();
643
+ for (const [key, val] of pendingOAuthFlows.entries()) {
644
+ if (now - val.timestamp > 10 * 60 * 1000) {
645
+ pendingOAuthFlows.delete(key);
646
+ }
647
+ }
648
+
649
+ // Generate OAuth URL using default redirect URI (localhost:51121)
650
+ const { url, verifier, state } = getAuthorizationUrl();
651
+
652
+ // Start callback server on port 51121 (same as CLI)
653
+ const serverPromise = startCallbackServer(state, 120000); // 2 min timeout
654
+
655
+ // Store the flow data
656
+ pendingOAuthFlows.set(state, {
657
+ serverPromise,
658
+ verifier,
659
+ state,
660
+ timestamp: Date.now()
661
+ });
662
+
663
+ // Start async handler for the OAuth callback
664
+ serverPromise
665
+ .then(async (code) => {
666
+ try {
667
+ logger.info('[WebUI] Received OAuth callback, completing flow...');
668
+ const accountData = await completeOAuthFlow(code, verifier);
669
+
670
+ // Add or update the account
671
+ await addAccount({
672
+ email: accountData.email,
673
+ refreshToken: accountData.refreshToken,
674
+ projectId: accountData.projectId,
675
+ source: 'oauth'
676
+ });
677
+
678
+ // Reload AccountManager to pick up the new account
679
+ await accountManager.reload();
680
+
681
+ logger.success(`[WebUI] Account ${accountData.email} added successfully`);
682
+ } catch (err) {
683
+ logger.error('[WebUI] OAuth flow completion error:', err);
684
+ } finally {
685
+ pendingOAuthFlows.delete(state);
686
+ }
687
+ })
688
+ .catch((err) => {
689
+ logger.error('[WebUI] OAuth callback server error:', err);
690
+ pendingOAuthFlows.delete(state);
691
+ });
692
+
693
+ res.json({ status: 'ok', url });
694
+ } catch (error) {
695
+ logger.error('[WebUI] Error generating auth URL:', error);
696
+ res.status(500).json({ status: 'error', error: error.message });
697
+ }
698
+ });
699
+
700
+ /**
701
+ * Note: /oauth/callback route removed
702
+ * OAuth callbacks are now handled by the temporary server on port 51121
703
+ * (same as CLI) to match Google OAuth Console's authorized redirect URIs
704
+ */
705
+
706
+ logger.info('[WebUI] Mounted at /');
707
+ }