@kylindc/ccxray 1.2.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,551 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const config = require('./config');
9
+ const store = require('./store');
10
+ const helpers = require('./helpers');
11
+ const { fetchPricing } = require('./pricing');
12
+ const { restoreFromLogs } = require('./restore');
13
+ const { warmUp: warmUpCosts } = require('./cost-budget');
14
+ const { forwardRequest } = require('./forward');
15
+ const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast');
16
+ const { authMiddleware } = require('./auth');
17
+ const { extractAgentType, splitB2IntoBlocks } = require('./system-prompt');
18
+
19
+ // ── CLI: parse flags and detect "claude" subcommand ──
20
+ const portIdx = process.argv.indexOf('--port');
21
+ let explicitPort = false;
22
+ if (portIdx !== -1) {
23
+ const portVal = process.argv[portIdx + 1];
24
+ const parsed = parseInt(portVal, 10);
25
+ if (!portVal || isNaN(parsed) || parsed < 1 || parsed > 65535) {
26
+ console.error('\x1b[31mError: --port requires a valid port number (1-65535)\x1b[0m');
27
+ process.exit(1);
28
+ }
29
+ config.PORT = parsed;
30
+ explicitPort = true;
31
+ process.argv.splice(portIdx, 2);
32
+ }
33
+ const hubMode = process.argv.includes('--hub-mode');
34
+ if (hubMode) process.argv.splice(process.argv.indexOf('--hub-mode'), 1);
35
+
36
+ // --bedrock flag: activate Bedrock mode regardless of env vars
37
+ const bedrockFlagIdx = process.argv.indexOf('--bedrock');
38
+ if (bedrockFlagIdx !== -1) {
39
+ config.IS_BEDROCK_MODE = true;
40
+ process.argv.splice(bedrockFlagIdx, 1);
41
+ }
42
+
43
+ const claudeMode = process.argv[2] === 'claude';
44
+ const claudeArgs = claudeMode ? process.argv.slice(3) : [];
45
+
46
+ // In claude/hub mode, mute startup logs so they don't pollute output.
47
+ const _origLog = console.log;
48
+ if (claudeMode || hubMode) console.log = () => {};
49
+
50
+ // Route handlers
51
+ const { handleSSERoute } = require('./routes/sse');
52
+ const { handleApiRoutes } = require('./routes/api');
53
+ const { handleInterceptRoutes } = require('./routes/intercept');
54
+ const { handleCostRoutes } = require('./routes/costs');
55
+ const hub = require('./hub');
56
+
57
+ // ── Web UI: Static files from public/ ────────────────────────────────
58
+ const PUBLIC_DIR = path.join(__dirname, '..', 'public');
59
+ const MIME_TYPES = { '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript' };
60
+
61
+ // index.html with config injection (port may shift, so rebuilt after listen)
62
+ let rawIndexHTML = '';
63
+ try { rawIndexHTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); } catch {}
64
+ let indexHTML = rawIndexHTML || '<html><body>Error loading dashboard</body></html>';
65
+
66
+ function rebuildIndexHTML(port) {
67
+ if (!rawIndexHTML) return;
68
+ const script = `<script>window.__PROXY_CONFIG__=${JSON.stringify({ DEFAULT_CONTEXT: config.DEFAULT_CONTEXT, PORT: port })}</script>`;
69
+ indexHTML = rawIndexHTML.replace('<!--__PROXY_CONFIG__-->', script);
70
+ }
71
+
72
+ function serveStatic(url, clientRes) {
73
+ const pathname = url.split('?')[0];
74
+ if (pathname === '/' || pathname === '/index.html') {
75
+ clientRes.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
76
+ clientRes.end(indexHTML);
77
+ return true;
78
+ }
79
+ const ext = path.extname(pathname);
80
+ const mime = MIME_TYPES[ext];
81
+ if (!mime) return false;
82
+ const filePath = path.join(PUBLIC_DIR, pathname);
83
+ // Prevent directory traversal
84
+ if (!filePath.startsWith(PUBLIC_DIR)) return false;
85
+ try {
86
+ const content = fs.readFileSync(filePath);
87
+ clientRes.writeHead(200, { 'Content-Type': mime + '; charset=utf-8' });
88
+ clientRes.end(content);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ // ── Server ──────────────────────────────────────────────────────────
96
+ const server = http.createServer((clientReq, clientRes) => {
97
+
98
+ // ── Hub API (health, register, unregister, status) ──
99
+ // Placed before auth: these are local IPC endpoints, not user-facing
100
+ if (hub.handleHubRoutes(clientReq, clientRes)) return;
101
+
102
+ // ── Auth check (enabled via AUTH_TOKEN env var) ──
103
+ if (!authMiddleware(clientReq, clientRes)) return;
104
+
105
+ // ── Static files (HTML, CSS, JS) ──
106
+ if (serveStatic(clientReq.url, clientRes)) return;
107
+
108
+ // ── SSE ──
109
+ if (handleSSERoute(clientReq, clientRes)) return;
110
+
111
+ // ── API routes ──
112
+ if (handleApiRoutes(clientReq, clientRes)) return;
113
+
114
+ // ── Intercept API ──
115
+ if (handleInterceptRoutes(clientReq, clientRes)) return;
116
+
117
+ // ── Cost Budget API ──
118
+ if (handleCostRoutes(clientReq, clientRes)) return;
119
+
120
+ // ── Proxy logic ──
121
+ const ts = helpers.taipeiTime();
122
+ const id = helpers.timestamp();
123
+ const startTime = Date.now();
124
+
125
+ const reqChunks = [];
126
+ clientReq.on('data', chunk => reqChunks.push(chunk));
127
+ clientReq.on('end', () => {
128
+ const rawBody = Buffer.concat(reqChunks);
129
+ let parsedBody = null;
130
+ try { parsedBody = JSON.parse(rawBody.toString()); } catch {}
131
+
132
+ // Quota-check probes: forward to Anthropic (rate limit headers still captured)
133
+ // but skip all logging, session tracking, and entry creation
134
+ if (parsedBody && store.isQuotaCheck(parsedBody)) {
135
+ const fwdHeaders = { ...clientReq.headers };
136
+ delete fwdHeaders['host'];
137
+ delete fwdHeaders['connection'];
138
+ delete fwdHeaders['accept-encoding'];
139
+ fwdHeaders['host'] = config.ANTHROPIC_HOST;
140
+ forwardRequest({ id, ts, startTime, parsedBody, rawBody, clientReq, clientRes, fwdHeaders, reqSessionId: null, reqWritePromise: null, skipEntry: true });
141
+ return;
142
+ }
143
+
144
+ let reqWritePromise = null;
145
+ let sysHash = null;
146
+ let toolsHash = null;
147
+ if (parsedBody) {
148
+ sysHash = parsedBody.system
149
+ ? crypto.createHash('sha256').update(JSON.stringify(parsedBody.system)).digest('hex').slice(0, 12)
150
+ : null;
151
+ toolsHash = parsedBody.tools
152
+ ? crypto.createHash('sha256').update(JSON.stringify(parsedBody.tools)).digest('hex').slice(0, 12)
153
+ : null;
154
+
155
+ if (sysHash) config.storage.writeSharedIfAbsent(`sys_${sysHash}.json`, JSON.stringify(parsedBody.system))
156
+ .catch(e => console.error('Write sys failed:', e.message));
157
+ if (toolsHash) config.storage.writeSharedIfAbsent(`tools_${toolsHash}.json`, JSON.stringify(parsedBody.tools))
158
+ .catch(e => console.error('Write tools failed:', e.message));
159
+
160
+ const stripped = {
161
+ model: parsedBody.model,
162
+ max_tokens: parsedBody.max_tokens,
163
+ messages: parsedBody.messages,
164
+ sysHash,
165
+ toolsHash,
166
+ };
167
+ reqWritePromise = config.storage.write(id, '_req.json', JSON.stringify(stripped))
168
+ .catch(e => console.error('Write req.json failed:', e.message));
169
+ }
170
+
171
+ const { sessionId: reqSessionId, isNewSession } = parsedBody
172
+ ? store.detectSession(parsedBody)
173
+ : { sessionId: store.getCurrentSessionId(), isNewSession: false };
174
+
175
+ // Extract and store cwd
176
+ if (parsedBody && reqSessionId) {
177
+ const cwd = store.extractCwd(parsedBody);
178
+ if (cwd) {
179
+ if (!store.sessionMeta[reqSessionId]) store.sessionMeta[reqSessionId] = {};
180
+ store.sessionMeta[reqSessionId].cwd = cwd;
181
+ }
182
+ }
183
+
184
+ // Detect new cc_version for live requests
185
+ if (parsedBody && Array.isArray(parsedBody.system) && parsedBody.system.length >= 3) {
186
+ const b0 = (parsedBody.system[0].text || '');
187
+ const b2 = (parsedBody.system[2].text || '');
188
+ const liveM = b0.match(/cc_version=(\S+?)[; ]/);
189
+ const liveVer = liveM ? liveM[1] : null;
190
+ const { key: agentKey, label: agentLabel } = extractAgentType(parsedBody.system);
191
+ if (liveVer && b2.length >= 500) {
192
+ const idxKey = `${agentKey}::${liveVer}`;
193
+ if (!store.versionIndex.has(idxKey)) {
194
+ const now = new Date().toISOString().slice(0, 10);
195
+ const coreText = splitB2IntoBlocks(b2).coreInstructions || '';
196
+ const coreLen = coreText.length;
197
+ const coreHash = crypto.createHash('md5').update(coreText).digest('hex').slice(0, 12);
198
+ const sharedFile = sysHash ? `sys_${sysHash}.json` : null;
199
+ store.versionIndex.set(idxKey, { reqId: null, sharedFile, b2Len: b2.length, coreLen, coreHash, firstSeen: now, agentKey, agentLabel, version: liveVer });
200
+ // Only notify if coreInstructions actually changed vs previous version
201
+ const versions = [...store.versionIndex.values()].filter(v => v.agentKey === agentKey && v.version !== liveVer);
202
+ const prev = versions.length ? versions.sort((a, b) => b.firstSeen.localeCompare(a.firstSeen))[0] : null;
203
+ const coreChanged = !prev || prev.coreHash !== coreHash;
204
+ if (coreChanged) {
205
+ const vData = JSON.stringify({ _type: 'version_detected', version: liveVer, b2Len: b2.length, agentKey, agentLabel });
206
+ for (const res of store.sseClients) res.write(`data: ${vData}\n\n`);
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Track active requests
213
+ if (reqSessionId) {
214
+ store.activeRequests[reqSessionId] = (store.activeRequests[reqSessionId] || 0) + 1;
215
+ if (!store.sessionMeta[reqSessionId]) store.sessionMeta[reqSessionId] = {};
216
+ store.sessionMeta[reqSessionId].lastSeenAt = Date.now();
217
+ broadcastSessionStatus(reqSessionId);
218
+ }
219
+
220
+ // Terminal summary
221
+ if (isNewSession) store.printSessionBanner(reqSessionId);
222
+ helpers.printSeparator();
223
+ console.log(`\x1b[36m📤 REQUEST [${ts}] ${clientReq.method} ${clientReq.url}\x1b[0m`);
224
+ if (parsedBody) console.log(helpers.summarizeRequest(parsedBody));
225
+
226
+ // Build context for forwarding
227
+ const fwdHeaders = { ...clientReq.headers };
228
+ delete fwdHeaders['host'];
229
+ delete fwdHeaders['connection'];
230
+ delete fwdHeaders['accept-encoding'];
231
+ fwdHeaders['host'] = config.ANTHROPIC_HOST;
232
+
233
+ const ctx = { id, ts, startTime, parsedBody, rawBody, clientReq, clientRes, fwdHeaders, reqSessionId, reqWritePromise, sysHash, toolsHash };
234
+
235
+ // ── Intercept check ──
236
+ const lastStop = store.sessionMeta[reqSessionId]?.lastStopReason;
237
+ if (reqSessionId && store.interceptSessions.has(reqSessionId) && lastStop !== 'tool_use') {
238
+ ctx.timer = setTimeout(() => {
239
+ const p = store.pendingRequests.get(id);
240
+ if (p) {
241
+ store.pendingRequests.delete(id);
242
+ const { broadcastInterceptRemoved } = require('./sse-broadcast');
243
+ broadcastInterceptRemoved(id);
244
+ console.log(`\x1b[33m⏰ INTERCEPT TIMEOUT [${helpers.taipeiTime()}] auto-forwarding ${id}\x1b[0m`);
245
+ forwardRequest(p);
246
+ }
247
+ }, store.getInterceptTimeout() * 1000);
248
+ ctx.originalBody = JSON.parse(JSON.stringify(parsedBody));
249
+ store.pendingRequests.set(id, ctx);
250
+ console.log(`\x1b[33m⏸ INTERCEPTED [${helpers.taipeiTime()}] ${id} — waiting for dashboard approval\x1b[0m`);
251
+ broadcastPendingRequest(id, parsedBody, reqSessionId);
252
+ return;
253
+ }
254
+
255
+ forwardRequest(ctx);
256
+ });
257
+ });
258
+
259
+ // ── Port scanner ──
260
+ function tryListen(srv, port, maxAttempts) {
261
+ return new Promise((resolve, reject) => {
262
+ let attempt = 0;
263
+ function onError(err) {
264
+ if (err.code === 'EADDRINUSE' && attempt < maxAttempts) {
265
+ attempt++;
266
+ srv.listen(port + attempt);
267
+ } else {
268
+ srv.removeListener('listening', onListening);
269
+ reject(err);
270
+ }
271
+ }
272
+ function onListening() {
273
+ srv.removeListener('error', onError);
274
+ resolve(srv.address().port);
275
+ }
276
+ srv.on('error', onError);
277
+ srv.once('listening', onListening);
278
+ srv.listen(port);
279
+ });
280
+ }
281
+
282
+ // ── Spawn Claude Code with proxy env ──
283
+ function spawnClaude(port, args) {
284
+ const { spawn } = require('child_process');
285
+ // In Bedrock mode, strip AWS_* env vars from Claude's environment so it
286
+ // doesn't independently attempt Bedrock SDK usage — it should use the proxy.
287
+ let childEnv = { ...process.env, ANTHROPIC_BASE_URL: `http://localhost:${port}` };
288
+ if (config.IS_BEDROCK_MODE) {
289
+ for (const key of Object.keys(childEnv)) {
290
+ if (key.startsWith('AWS_') || key === 'CLAUDE_CODE_USE_BEDROCK') {
291
+ delete childEnv[key];
292
+ }
293
+ }
294
+ }
295
+ const child = spawn('claude', args, {
296
+ stdio: 'inherit',
297
+ env: childEnv,
298
+ });
299
+ child.on('error', (err) => {
300
+ if (err.code === 'ENOENT') {
301
+ console.error('\x1b[31mError: "claude" command not found. Install Claude Code first:\x1b[0m');
302
+ console.error('\x1b[31m npm install -g @anthropic-ai/claude-code\x1b[0m');
303
+ } else {
304
+ console.error(`\x1b[31mFailed to start claude: ${err.message}\x1b[0m`);
305
+ }
306
+ process.exit(1);
307
+ });
308
+ child.on('exit', (code, signal) => {
309
+ server.close();
310
+ process.exit(code ?? (signal === 'SIGINT' ? 130 : 1));
311
+ });
312
+ // SIGINT is already sent to claude by the terminal (same process group).
313
+ // Just prevent Node's default exit so we wait for claude's exit event.
314
+ process.on('SIGINT', () => {});
315
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
316
+ }
317
+
318
+ // ── "status" subcommand ──
319
+ if (process.argv[2] === 'status') {
320
+ const lock = hub.readHubLock();
321
+ if (!lock) {
322
+ console.log('No hub running.');
323
+ process.exit(0);
324
+ }
325
+ if (!hub.isPidAlive(lock.pid)) {
326
+ console.log('Hub lockfile exists but process is dead. Cleaning up.');
327
+ hub.deleteHubLock();
328
+ process.exit(1);
329
+ }
330
+ hub.checkHubHealth(lock.port).then(ok => {
331
+ if (!ok) {
332
+ console.log(`Hub pid ${lock.pid} alive but not responding on port ${lock.port}.`);
333
+ console.log(`Check ${hub.HUB_LOG_PATH}`);
334
+ process.exit(1);
335
+ }
336
+ const http = require('http');
337
+ http.get(`http://localhost:${lock.port}/_api/hub/status`, res => {
338
+ let data = '';
339
+ res.on('data', c => { data += c; });
340
+ res.on('end', () => {
341
+ try {
342
+ const s = JSON.parse(data);
343
+ console.log(`Hub: http://localhost:${s.port} (pid ${s.pid}, uptime ${s.uptime}s, v${s.version})`);
344
+ if (s.clients.length === 0) {
345
+ console.log('No connected clients.');
346
+ } else {
347
+ console.log(`Connected clients (${s.clients.length}):`);
348
+ s.clients.forEach((c, i) => {
349
+ console.log(` [${i + 1}] pid ${c.pid} — ${c.cwd} (since ${c.connectedAt})`);
350
+ });
351
+ }
352
+ } catch { console.log(data); }
353
+ process.exit(0);
354
+ });
355
+ }).on('error', err => {
356
+ console.error(`Failed to query hub: ${err.message}`);
357
+ process.exit(1);
358
+ });
359
+ });
360
+ return; // prevent falling through to startup
361
+ }
362
+
363
+ // ── Client mode: connect to existing hub ──
364
+ async function startClientMode(lock) {
365
+ const compat = hub.checkVersionCompat(lock.version);
366
+ if (compat.fatal) {
367
+ console.error(`\x1b[31m${compat.message}\x1b[0m`);
368
+ process.exit(1);
369
+ }
370
+ if (compat.warning) {
371
+ _origLog(`\x1b[33m${compat.warning}\x1b[0m`);
372
+ }
373
+
374
+ _origLog(`\x1b[90mccxray → http://localhost:${lock.port} (hub)\x1b[0m`);
375
+
376
+ try {
377
+ const reg = await hub.registerClient(lock.port, process.pid, process.cwd());
378
+ if (!reg) {
379
+ console.error('\x1b[31mHub rejected client registration.\x1b[0m');
380
+ process.exit(1);
381
+ }
382
+
383
+ // Auto-open browser for the first client connecting to this hub
384
+ if (reg.firstClient) {
385
+ const noOpen = process.argv.includes('--no-browser')
386
+ || process.env.BROWSER === 'none'
387
+ || process.env.CI
388
+ || process.env.SSH_TTY;
389
+ if (!noOpen) {
390
+ const { exec } = require('child_process');
391
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
392
+ exec(`${cmd} http://localhost:${lock.port}`);
393
+ }
394
+ }
395
+ } catch (err) {
396
+ console.error(`\x1b[31mFailed to register with hub: ${err.message}\x1b[0m`);
397
+ process.exit(1);
398
+ }
399
+
400
+ // Monitor hub health and auto-recover
401
+ hub.startHubMonitor(lock.pid, lock.port, (newLock) => {
402
+ // Re-register with new hub
403
+ hub.registerClient(newLock.port, process.pid, process.cwd()).catch(() => {});
404
+ });
405
+
406
+ // Spawn claude pointing to hub
407
+ const { spawn } = require('child_process');
408
+ const child = spawn('claude', claudeArgs, {
409
+ stdio: 'inherit',
410
+ env: { ...process.env, ANTHROPIC_BASE_URL: `http://localhost:${lock.port}` },
411
+ });
412
+ child.on('error', (err) => {
413
+ if (err.code === 'ENOENT') {
414
+ console.error('\x1b[31mError: "claude" command not found. Install Claude Code first:\x1b[0m');
415
+ console.error('\x1b[31m npm install -g @anthropic-ai/claude-code\x1b[0m');
416
+ } else {
417
+ console.error(`\x1b[31mFailed to start claude: ${err.message}\x1b[0m`);
418
+ }
419
+ hub.unregisterClient(lock.port, process.pid).finally(() => process.exit(1));
420
+ });
421
+ child.on('exit', (code, signal) => {
422
+ hub.unregisterClient(lock.port, process.pid).finally(() => {
423
+ process.exit(code ?? (signal === 'SIGINT' ? 130 : 1));
424
+ });
425
+ });
426
+ process.on('SIGINT', () => {});
427
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
428
+ }
429
+
430
+ // ── Hub/Server startup ──
431
+ async function startServer() {
432
+ await config.storage.init();
433
+
434
+ // Bedrock mode: resolve credentials (or validate bearer token)
435
+ if (config.IS_BEDROCK_MODE) {
436
+ const activationSource = config.BEDROCK_ACTIVATION_SOURCE
437
+ || (process.argv.includes('--bedrock') ? '--bedrock flag' : 'unknown');
438
+ if (config.BEDROCK_BEARER_TOKEN) {
439
+ _origLog(`\x1b[36mBedrock mode: ${config.BEDROCK_RESOLVED_REGION} (bearer token auth, via ${activationSource})\x1b[0m`);
440
+ } else {
441
+ const { resolveCredentials } = require('./bedrock-credentials');
442
+ const creds = await resolveCredentials();
443
+ if (!creds) {
444
+ console.error('\x1b[31mBedrock mode requires auth. Set BEDROCK_BEARER_TOKEN, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or configure ~/.aws/credentials\x1b[0m');
445
+ process.exit(1);
446
+ }
447
+ config.BEDROCK_CREDENTIALS = creds;
448
+ _origLog(`\x1b[36mBedrock mode: ${config.BEDROCK_RESOLVED_REGION} (SigV4 auth, via ${activationSource})\x1b[0m`);
449
+ }
450
+ }
451
+
452
+ await fetchPricing();
453
+ await restoreFromLogs();
454
+ warmUpCosts();
455
+
456
+ // Hub mode: no port retry — EADDRINUSE means another hub won the race.
457
+ // Claude mode (with --port, standalone): retry up to 10 ports.
458
+ const maxAttempts = (claudeMode && !hubMode) ? 10 : 0;
459
+ const actualPort = await tryListen(server, config.PORT, maxAttempts);
460
+ rebuildIndexHTML(actualPort);
461
+
462
+ // Hub mode only: write lockfile as readiness signal, start client lifecycle
463
+ // Do NOT write lockfile in claudeMode with --port (that's independent mode)
464
+ if (hubMode) {
465
+ hub.setHubPort(actualPort);
466
+ hub.writeHubLock(actualPort, process.pid);
467
+ hub.startDeadClientCheck();
468
+ const cleanup = () => { hub.deleteHubLock(); process.exit(0); };
469
+ process.on('SIGTERM', cleanup);
470
+ process.on('SIGINT', cleanup);
471
+ }
472
+
473
+ // Banner
474
+ if (hubMode) {
475
+ // Hub runs silently (logs go to hub.log)
476
+ } else if (claudeMode) {
477
+ _origLog(`\x1b[90mccxray → http://localhost:${actualPort}\x1b[0m`);
478
+ } else {
479
+ console.log();
480
+ console.log(`\x1b[35m🔌 Claude API Proxy listening on http://localhost:${actualPort}\x1b[0m`);
481
+ console.log(`\x1b[90m Dashboard → http://localhost:${actualPort}/`);
482
+ console.log(` Forwarding to ${config.ANTHROPIC_HOST}`);
483
+ console.log(` Logs → ${config.LOGS_DIR}`);
484
+ console.log();
485
+ console.log(` Usage: ANTHROPIC_BASE_URL=http://localhost:${actualPort} claude\x1b[0m`);
486
+ console.log('\x1b[0m');
487
+ }
488
+
489
+ // Auto-open dashboard in browser (not in hub mode)
490
+ const noOpen = hubMode
491
+ || process.argv.includes('--no-browser')
492
+ || process.env.BROWSER === 'none'
493
+ || process.env.CI
494
+ || process.env.SSH_TTY;
495
+ if (!noOpen) {
496
+ const { exec } = require('child_process');
497
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
498
+ exec(`${cmd} http://localhost:${actualPort}`);
499
+ }
500
+
501
+ if (claudeMode) spawnClaude(actualPort, claudeArgs);
502
+ }
503
+
504
+ // ── Main entry ──
505
+ (async () => {
506
+ // Hub mode or explicit port or standalone: start server directly
507
+ if (hubMode || explicitPort || !claudeMode) {
508
+ try {
509
+ await startServer();
510
+ } catch (err) {
511
+ if (err.code === 'EADDRINUSE') {
512
+ console.error(`\x1b[31mError: port ${config.PORT} is already in use\x1b[0m`);
513
+ } else {
514
+ console.error(`\x1b[31mStartup failed: ${err.message}\x1b[0m`);
515
+ }
516
+ process.exit(1);
517
+ }
518
+ return;
519
+ }
520
+
521
+ // Claude mode without explicit port: try hub discovery
522
+ const existingHub = await hub.discoverHub(config.PORT);
523
+ if (existingHub) {
524
+ await startClientMode(existingHub);
525
+ return;
526
+ }
527
+
528
+ // No hub found: fork a hub, then connect as client
529
+ hub.forkHub(config.PORT);
530
+ try {
531
+ const lock = await hub.waitForHubReady();
532
+ await startClientMode(lock);
533
+ } catch (err) {
534
+ console.error(`\x1b[31m${err.message}\x1b[0m`);
535
+ // Show last hub log lines so user doesn't have to open the file
536
+ const fs = require('fs');
537
+ try {
538
+ const log = fs.readFileSync(hub.HUB_LOG_PATH, 'utf8');
539
+ const lines = log.trim().split('\n');
540
+ const lastErrors = lines.filter(l => /error|EADDRINUSE/i.test(l)).slice(-3);
541
+ if (lastErrors.length) {
542
+ console.error('\x1b[33mHub log:\x1b[0m');
543
+ lastErrors.forEach(l => console.error(` ${l.replace(/\x1b\[[0-9;]*m/g, '')}`));
544
+ }
545
+ if (lines.some(l => /EADDRINUSE|already in use/i.test(l))) {
546
+ console.error(`\x1b[33mSuggestion: another process is using port ${config.PORT}. Use --port <other> or kill the process.\x1b[0m`);
547
+ }
548
+ } catch {}
549
+ process.exit(1);
550
+ }
551
+ })();
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+ const fs = require('fs');
5
+ const fsp = fs.promises;
6
+ const path = require('path');
7
+
8
+ // ── Pricing ─────────────────────────────────────────────────────────
9
+ const PRICING_CACHE_PATH = path.join(__dirname, '..', 'pricing-cache.json');
10
+ const PRICING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+ const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
12
+
13
+ // Hardcoded fallback (per 1M tokens, USD)
14
+ const DEFAULT_PRICING = {
15
+ 'claude-opus-4-6': { input: 5, output: 25, cache_create: 6.25, cache_read: 0.50 },
16
+ 'claude-sonnet-4-6': { input: 3, output: 15, cache_create: 3.75, cache_read: 0.30 },
17
+ 'claude-opus-4-5': { input: 5, output: 25, cache_create: 6.25, cache_read: 0.50 },
18
+ 'claude-opus-4-1': { input: 5, output: 25, cache_create: 6.25, cache_read: 0.50 },
19
+ 'claude-opus-4': { input: 15, output: 75, cache_create: 18.75, cache_read: 1.50 },
20
+ 'claude-sonnet-4': { input: 3, output: 15, cache_create: 3.75, cache_read: 0.30 },
21
+ 'claude-haiku-4': { input: 0.80, output: 4, cache_create: 1, cache_read: 0.08 },
22
+ 'claude-3-5-sonnet': { input: 3, output: 15, cache_create: 3.75, cache_read: 0.30 },
23
+ 'claude-3-5-haiku': { input: 0.80, output: 4, cache_create: 1, cache_read: 0.08 },
24
+ 'claude-3-opus': { input: 15, output: 75, cache_create: 18.75, cache_read: 1.50 },
25
+ };
26
+
27
+ let pricingTable = { ...DEFAULT_PRICING };
28
+ // Model → max_input_tokens from LiteLLM (populated by fetchPricing)
29
+ let contextTable = {};
30
+
31
+ async function fetchPricing() {
32
+ // Check cache first
33
+ try {
34
+ const cached = JSON.parse(await fsp.readFile(PRICING_CACHE_PATH, 'utf8'));
35
+ if (Date.now() - cached.fetchedAt < PRICING_TTL_MS) {
36
+ pricingTable = { ...DEFAULT_PRICING, ...cached.pricing };
37
+ if (cached.context) contextTable = cached.context;
38
+ console.log(`\x1b[90m Pricing loaded from cache (${new Date(cached.fetchedAt).toLocaleString('zh-TW', { timeZone: 'Asia/Taipei' })})\x1b[0m`);
39
+ return;
40
+ }
41
+ } catch {}
42
+
43
+ // Fetch from LiteLLM
44
+ return new Promise((resolve) => {
45
+ let resolved = false;
46
+ const done = () => { if (!resolved) { resolved = true; resolve(); } };
47
+ const req = https.get(LITELLM_URL, (res) => {
48
+ if (res.statusCode !== 200) {
49
+ console.log(`\x1b[33m ⚠ Pricing fetch failed (${res.statusCode}), using defaults\x1b[0m`);
50
+ return done();
51
+ }
52
+ const chunks = [];
53
+ res.on('data', c => chunks.push(c));
54
+ res.on('end', () => {
55
+ try {
56
+ const data = JSON.parse(Buffer.concat(chunks).toString());
57
+ const fetched = {};
58
+ const fetchedCtx = {};
59
+ for (const [key, val] of Object.entries(data)) {
60
+ if (!key.startsWith('claude-') && !key.startsWith('bedrock/anthropic.claude-')) continue;
61
+ if (val.input_cost_per_token) {
62
+ fetched[key] = {
63
+ input: (val.input_cost_per_token || 0) * 1_000_000,
64
+ output: (val.output_cost_per_token || 0) * 1_000_000,
65
+ cache_create: (val.cache_creation_input_token_cost || val.input_cost_per_token || 0) * 1_000_000,
66
+ cache_read: (val.cache_read_input_token_cost || val.input_cost_per_token || 0) * 1_000_000,
67
+ };
68
+ }
69
+ if (val.max_input_tokens) {
70
+ fetchedCtx[key] = val.max_input_tokens;
71
+ }
72
+ }
73
+ fsp.writeFile(PRICING_CACHE_PATH, JSON.stringify({ fetchedAt: Date.now(), pricing: fetched, context: fetchedCtx }, null, 2))
74
+ .catch(e => console.error('Write pricing cache failed:', e.message));
75
+ pricingTable = { ...DEFAULT_PRICING, ...fetched };
76
+ contextTable = fetchedCtx;
77
+ console.log(`\x1b[90m Pricing fetched: ${Object.keys(fetched).length} Claude models, ${Object.keys(fetchedCtx).length} context windows\x1b[0m`);
78
+ } catch (e) {
79
+ console.log(`\x1b[33m ⚠ Pricing parse error, using defaults\x1b[0m`);
80
+ }
81
+ done();
82
+ });
83
+ }).on('error', () => {
84
+ console.log(`\x1b[33m ⚠ Pricing fetch error, using defaults\x1b[0m`);
85
+ resolve();
86
+ });
87
+ req.setTimeout(5000, () => {
88
+ req.destroy();
89
+ console.log(`\x1b[33m ⚠ Pricing fetch timeout, using defaults\x1b[0m`);
90
+ resolve();
91
+ });
92
+ });
93
+ }
94
+
95
+ function getModelPricing(model) {
96
+ if (!model) return null;
97
+ if (pricingTable[model]) return pricingTable[model];
98
+ const keys = Object.keys(pricingTable).sort((a, b) => b.length - a.length);
99
+ for (const key of keys) {
100
+ if (model.startsWith(key)) return pricingTable[key];
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function calculateCost(usage, model) {
106
+ if (!usage) return null;
107
+ const rates = getModelPricing(model);
108
+ if (!rates) return { cost: null, rates: null, warning: `Unknown model: ${model}` };
109
+ const cost =
110
+ ((usage.input_tokens || 0) / 1_000_000) * rates.input +
111
+ ((usage.output_tokens || 0) / 1_000_000) * rates.output +
112
+ ((usage.cache_creation_input_tokens || 0) / 1_000_000) * rates.cache_create +
113
+ ((usage.cache_read_input_tokens || 0) / 1_000_000) * rates.cache_read;
114
+ return { cost, rates };
115
+ }
116
+
117
+ function getModelContext(model) {
118
+ if (!model) return null;
119
+ if (contextTable[model]) return contextTable[model];
120
+ const keys = Object.keys(contextTable).sort((a, b) => b.length - a.length);
121
+ for (const key of keys) {
122
+ if (model.startsWith(key)) return contextTable[key];
123
+ }
124
+ return null;
125
+ }
126
+
127
+ module.exports = {
128
+ fetchPricing,
129
+ getModelPricing,
130
+ getModelContext,
131
+ calculateCost,
132
+ get pricingTable() { return pricingTable; },
133
+ };