@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.
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.ja.md +144 -0
- package/README.md +145 -0
- package/README.zh-TW.md +144 -0
- package/package.json +46 -0
- package/public/app.js +99 -0
- package/public/cost-budget-ui.js +305 -0
- package/public/entry-rendering.js +535 -0
- package/public/index.html +119 -0
- package/public/intercept-ui.js +335 -0
- package/public/keyboard-nav.js +208 -0
- package/public/messages.js +750 -0
- package/public/miller-columns.js +1686 -0
- package/public/quota-ticker.js +67 -0
- package/public/style.css +431 -0
- package/public/system-prompt-ui.js +327 -0
- package/server/auth.js +34 -0
- package/server/bedrock-credentials.js +141 -0
- package/server/config.js +190 -0
- package/server/cost-budget.js +220 -0
- package/server/cost-worker.js +110 -0
- package/server/eventstream.js +148 -0
- package/server/forward.js +683 -0
- package/server/helpers.js +393 -0
- package/server/hub.js +418 -0
- package/server/index.js +551 -0
- package/server/pricing.js +133 -0
- package/server/restore.js +141 -0
- package/server/routes/api.js +123 -0
- package/server/routes/costs.js +124 -0
- package/server/routes/intercept.js +89 -0
- package/server/routes/sse.js +44 -0
- package/server/sigv4.js +104 -0
- package/server/sse-broadcast.js +71 -0
- package/server/storage/index.js +36 -0
- package/server/storage/interface.js +26 -0
- package/server/storage/local.js +79 -0
- package/server/storage/s3.js +91 -0
- package/server/store.js +108 -0
- package/server/system-prompt.js +150 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const config = require('./config');
|
|
5
|
+
const store = require('./store');
|
|
6
|
+
const { calculateCost } = require('./pricing');
|
|
7
|
+
const { extractAgentType, splitB2IntoBlocks } = require('./system-prompt');
|
|
8
|
+
|
|
9
|
+
// ── Lazy-load req/res from disk on demand ────────────────────────────
|
|
10
|
+
|
|
11
|
+
function loadEntryReqRes(entry) {
|
|
12
|
+
if (entry._loaded) return Promise.resolve();
|
|
13
|
+
if (entry._loadingPromise) return entry._loadingPromise;
|
|
14
|
+
entry._loadingPromise = (async () => {
|
|
15
|
+
if (entry._writePromise) await entry._writePromise.catch(() => {});
|
|
16
|
+
try {
|
|
17
|
+
const stripped = JSON.parse(await config.storage.read(entry.id, '_req.json'));
|
|
18
|
+
const sys = stripped.sysHash
|
|
19
|
+
? await config.storage.readShared(`sys_${stripped.sysHash}.json`).then(JSON.parse).catch(() => null)
|
|
20
|
+
: null;
|
|
21
|
+
const tools = stripped.toolsHash
|
|
22
|
+
? await config.storage.readShared(`tools_${stripped.toolsHash}.json`).then(JSON.parse).catch(() => null)
|
|
23
|
+
: null;
|
|
24
|
+
entry.req = { ...stripped, system: sys, tools };
|
|
25
|
+
delete entry.req.sysHash;
|
|
26
|
+
delete entry.req.toolsHash;
|
|
27
|
+
} catch { entry.req = null; }
|
|
28
|
+
try {
|
|
29
|
+
const raw = await config.storage.read(entry.id, '_res.json');
|
|
30
|
+
try { entry.res = JSON.parse(raw); } catch { entry.res = raw; }
|
|
31
|
+
} catch { entry.res = null; }
|
|
32
|
+
entry._loaded = true;
|
|
33
|
+
entry._loadingPromise = null;
|
|
34
|
+
})();
|
|
35
|
+
return entry._loadingPromise;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Restore entries from index.ndjson on startup ─────────────────────
|
|
39
|
+
|
|
40
|
+
async function restoreFromLogs() {
|
|
41
|
+
console.time('restore:total');
|
|
42
|
+
|
|
43
|
+
// 1. Read the lightweight index (one file read for all metadata)
|
|
44
|
+
console.time('restore:index');
|
|
45
|
+
const indexContent = await config.storage.readIndex();
|
|
46
|
+
console.timeEnd('restore:index');
|
|
47
|
+
|
|
48
|
+
if (!indexContent) {
|
|
49
|
+
console.timeEnd('restore:total');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Parse index lines and filter by RESTORE_DAYS
|
|
54
|
+
console.time('restore:parse');
|
|
55
|
+
let cutoffStr = null;
|
|
56
|
+
if (config.RESTORE_DAYS > 0) {
|
|
57
|
+
const cutoff = new Date();
|
|
58
|
+
cutoff.setDate(cutoff.getDate() - config.RESTORE_DAYS);
|
|
59
|
+
cutoffStr = cutoff.toLocaleString('sv-SE', { timeZone: 'Asia/Taipei' }).slice(0, 10);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines = indexContent.split('\n').filter(Boolean);
|
|
63
|
+
let restored = 0;
|
|
64
|
+
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
let meta;
|
|
67
|
+
try { meta = JSON.parse(line); } catch { continue; }
|
|
68
|
+
|
|
69
|
+
if (cutoffStr && meta.id.slice(0, 10) < cutoffStr) continue;
|
|
70
|
+
|
|
71
|
+
store.entries.push({ ...meta, req: null, res: null, _loaded: false });
|
|
72
|
+
|
|
73
|
+
// Track earliest timestamp per sysHash for version dating
|
|
74
|
+
if (meta.sysHash && meta.id) {
|
|
75
|
+
const existing = store._sysHashDates && store._sysHashDates.get(meta.sysHash);
|
|
76
|
+
if (!existing || meta.id < existing) {
|
|
77
|
+
if (!store._sysHashDates) store._sysHashDates = new Map();
|
|
78
|
+
store._sysHashDates.set(meta.sysHash, meta.id.slice(0, 10));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (meta.sessionId) {
|
|
83
|
+
if (!store.sessionMeta[meta.sessionId]) store.sessionMeta[meta.sessionId] = {};
|
|
84
|
+
if (meta.cwd) store.sessionMeta[meta.sessionId].cwd = meta.cwd;
|
|
85
|
+
if (meta.receivedAt) store.sessionMeta[meta.sessionId].lastSeenAt = meta.receivedAt;
|
|
86
|
+
}
|
|
87
|
+
if (meta.cost?.cost != null && meta.sessionId) {
|
|
88
|
+
store.sessionCosts.set(meta.sessionId, (store.sessionCosts.get(meta.sessionId) || 0) + meta.cost.cost);
|
|
89
|
+
}
|
|
90
|
+
restored++;
|
|
91
|
+
}
|
|
92
|
+
store.trimEntries();
|
|
93
|
+
console.timeEnd('restore:parse');
|
|
94
|
+
|
|
95
|
+
// 3. Build version index from shared/ system prompts (scans a handful of small files)
|
|
96
|
+
console.time('restore:versions');
|
|
97
|
+
await buildVersionIndex();
|
|
98
|
+
console.timeEnd('restore:versions');
|
|
99
|
+
|
|
100
|
+
console.timeEnd('restore:total');
|
|
101
|
+
if (restored) {
|
|
102
|
+
const msg = config.RESTORE_DAYS > 0
|
|
103
|
+
? `Restored ${restored} entries from last ${config.RESTORE_DAYS} days`
|
|
104
|
+
: `Restored ${restored} entries from index`;
|
|
105
|
+
console.log(`\x1b[90m ${msg}\x1b[0m`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function buildVersionIndex() {
|
|
110
|
+
let sharedFiles;
|
|
111
|
+
try { sharedFiles = await config.storage.listShared(); } catch { return; }
|
|
112
|
+
|
|
113
|
+
for (const filename of sharedFiles) {
|
|
114
|
+
if (!filename.startsWith('sys_')) continue;
|
|
115
|
+
try {
|
|
116
|
+
const sys = JSON.parse(await config.storage.readShared(filename));
|
|
117
|
+
if (!Array.isArray(sys) || sys.length < 3) continue;
|
|
118
|
+
const b0 = sys[0]?.text || '';
|
|
119
|
+
const b2 = sys[2]?.text || '';
|
|
120
|
+
const m = b0.match(/cc_version=(\S+?)[; ]/);
|
|
121
|
+
const ver = m ? m[1] : null;
|
|
122
|
+
const { key: agentKey, label: agentLabel } = extractAgentType(sys);
|
|
123
|
+
if (ver && b2.length >= 500) {
|
|
124
|
+
const idxKey = `${agentKey}::${ver}`;
|
|
125
|
+
const existing = store.versionIndex.get(idxKey);
|
|
126
|
+
if (!existing || b2.length > existing.b2Len) {
|
|
127
|
+
const coreText = splitB2IntoBlocks(b2).coreInstructions || '';
|
|
128
|
+
const coreLen = coreText.length;
|
|
129
|
+
const coreHash = crypto.createHash('md5').update(coreText).digest('hex').slice(0, 12);
|
|
130
|
+
store.versionIndex.set(idxKey, {
|
|
131
|
+
reqId: null, sharedFile: filename, b2Len: b2.length, coreLen, coreHash,
|
|
132
|
+
firstSeen: filename.slice(4, 14) || '?',
|
|
133
|
+
agentKey, agentLabel, version: ver,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = { loadEntryReqRes, restoreFromLogs };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const store = require('../store');
|
|
5
|
+
const { summarizeEntry } = require('../sse-broadcast');
|
|
6
|
+
const { loadEntryReqRes } = require('../restore');
|
|
7
|
+
const { tokenizeRequest } = require('../helpers');
|
|
8
|
+
const { computeBlockDiff } = require('../system-prompt');
|
|
9
|
+
|
|
10
|
+
function handleApiRoutes(clientReq, clientRes) {
|
|
11
|
+
if (clientReq.url === '/_api/config') {
|
|
12
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
13
|
+
clientRes.end(JSON.stringify({ bedrockMode: !!config.IS_BEDROCK_MODE }));
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (clientReq.url === '/_api/entries') {
|
|
18
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
19
|
+
clientRes.end(JSON.stringify(store.entries.map(summarizeEntry)));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (clientReq.url.startsWith('/_api/sysprompt/versions')) {
|
|
24
|
+
const urlParams = new URLSearchParams(clientReq.url.split('?')[1] || '');
|
|
25
|
+
const filterAgent = urlParams.get('agent') || null;
|
|
26
|
+
const allAgents = [...new Set([...store.versionIndex.values()].map(v => v.agentKey))].sort();
|
|
27
|
+
const vEntries = [...store.versionIndex.values()]
|
|
28
|
+
.filter(v => !filterAgent || v.agentKey === filterAgent)
|
|
29
|
+
.sort((a, b) => b.version.localeCompare(a.version));
|
|
30
|
+
const versions = vEntries.map(({ version, reqId, b2Len, coreLen, firstSeen, agentKey, agentLabel }) => ({ version, reqId, b2Len, coreLen, firstSeen, agentKey, agentLabel }));
|
|
31
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
32
|
+
clientRes.end(JSON.stringify({ versions, agents: allAgents.map(k => ({ key: k, label: store.versionIndex.get([...store.versionIndex.keys()].find(ik => ik.startsWith(k + '::')))?.agentLabel || k })) }));
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const diffMatch = clientReq.url.match(/^\/_api\/sysprompt\/diff\?(.+)$/);
|
|
37
|
+
if (diffMatch) {
|
|
38
|
+
const params = new URLSearchParams(diffMatch[1]);
|
|
39
|
+
const verA = params.get('a'), verB = params.get('b');
|
|
40
|
+
const agentKey = params.get('agent') || 'claude-code';
|
|
41
|
+
const entA = store.versionIndex.get(`${agentKey}::${verA}`), entB = store.versionIndex.get(`${agentKey}::${verB}`);
|
|
42
|
+
if (!entA || !entB) {
|
|
43
|
+
clientRes.writeHead(404, { 'Content-Type': 'application/json' });
|
|
44
|
+
clientRes.end(JSON.stringify({ error: 'version not found' }));
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
const loadB2 = async (ent) => {
|
|
48
|
+
// Try reqId first, fallback to shared file
|
|
49
|
+
if (ent.reqId) {
|
|
50
|
+
try {
|
|
51
|
+
const raw = await config.storage.read(ent.reqId, '_req.json');
|
|
52
|
+
const body = JSON.parse(raw);
|
|
53
|
+
return Array.isArray(body.system) && body.system[2] ? (body.system[2].text || '') : '';
|
|
54
|
+
} catch {}
|
|
55
|
+
}
|
|
56
|
+
if (ent.sharedFile) {
|
|
57
|
+
try {
|
|
58
|
+
const sys = JSON.parse(await config.storage.readShared(ent.sharedFile));
|
|
59
|
+
return Array.isArray(sys) && sys[2] ? (sys[2].text || '') : '';
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
return '';
|
|
63
|
+
};
|
|
64
|
+
(async () => {
|
|
65
|
+
const [b2A, b2B] = await Promise.all([loadB2(entA), loadB2(entB)]);
|
|
66
|
+
const blockDiff = computeBlockDiff(b2A, b2B, verA, verB);
|
|
67
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
68
|
+
clientRes.end(JSON.stringify({
|
|
69
|
+
a: { version: verA, b2Len: entA.b2Len },
|
|
70
|
+
b: { version: verB, b2Len: entB.b2Len },
|
|
71
|
+
blockDiff
|
|
72
|
+
}));
|
|
73
|
+
})().catch(e => {
|
|
74
|
+
if (!clientRes.headersSent) clientRes.writeHead(500);
|
|
75
|
+
clientRes.end(JSON.stringify({ error: e.message }));
|
|
76
|
+
});
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Full entry data (req + res) — lazy loaded
|
|
81
|
+
const entryMatch = clientReq.url.match(/^\/_api\/entry\/(.+)$/);
|
|
82
|
+
if (entryMatch) {
|
|
83
|
+
const id = decodeURIComponent(entryMatch[1]);
|
|
84
|
+
const entry = store.entries.find(e => e.id === id);
|
|
85
|
+
if (!entry) { clientRes.writeHead(404); clientRes.end('Not found'); return true; }
|
|
86
|
+
(async () => {
|
|
87
|
+
await loadEntryReqRes(entry);
|
|
88
|
+
const snapshot = { req: entry.req, res: entry.res, receivedAt: entry.receivedAt || null };
|
|
89
|
+
if (entry.elapsed === '?') { entry.req = null; entry.res = null; entry._loaded = false; }
|
|
90
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
91
|
+
clientRes.end(JSON.stringify(snapshot));
|
|
92
|
+
})().catch(e => {
|
|
93
|
+
if (!clientRes.headersSent) clientRes.writeHead(500);
|
|
94
|
+
clientRes.end(JSON.stringify({ error: e.message }));
|
|
95
|
+
});
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Lazy tokenization endpoint
|
|
100
|
+
const tokMatch = clientReq.url.match(/^\/_api\/tokens\/(.+)$/);
|
|
101
|
+
if (tokMatch) {
|
|
102
|
+
const id = decodeURIComponent(tokMatch[1]);
|
|
103
|
+
const entry = store.entries.find(e => e.id === id);
|
|
104
|
+
if (!entry) { clientRes.writeHead(404); clientRes.end('Not found'); return true; }
|
|
105
|
+
(async () => {
|
|
106
|
+
if (!entry.tokens) {
|
|
107
|
+
await loadEntryReqRes(entry);
|
|
108
|
+
if (entry.req) entry.tokens = tokenizeRequest(entry.req);
|
|
109
|
+
if (entry.elapsed === '?') { entry.req = null; entry.res = null; entry._loaded = false; }
|
|
110
|
+
}
|
|
111
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
112
|
+
clientRes.end(JSON.stringify(entry.tokens));
|
|
113
|
+
})().catch(e => {
|
|
114
|
+
if (!clientRes.headersSent) clientRes.writeHead(500);
|
|
115
|
+
clientRes.end(JSON.stringify({ error: e.message }));
|
|
116
|
+
});
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { handleApiRoutes };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const store = require('../store');
|
|
4
|
+
const { getCostsCacheOrNull, calculateBurnRate, TOKEN_LIMIT } = require('../cost-budget');
|
|
5
|
+
const { pricingTable } = require('../pricing');
|
|
6
|
+
|
|
7
|
+
// Helper: return loading response if cache not ready (triggers background computation)
|
|
8
|
+
function sendLoadingOrData(clientRes, dataFn) {
|
|
9
|
+
const data = getCostsCacheOrNull();
|
|
10
|
+
if (!data) {
|
|
11
|
+
clientRes.writeHead(202, { 'Content-Type': 'application/json' });
|
|
12
|
+
clientRes.end(JSON.stringify({ loading: true }));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
dataFn(data);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function handleCostRoutes(clientReq, clientRes) {
|
|
19
|
+
if (clientReq.url === '/_api/costs/current-block') {
|
|
20
|
+
sendLoadingOrData(clientRes, data => {
|
|
21
|
+
const now = Date.now();
|
|
22
|
+
const activeBlock = data.blocks.find(b => b.isActive);
|
|
23
|
+
if (!activeBlock) {
|
|
24
|
+
const lastBlock = data.blocks[data.blocks.length - 1];
|
|
25
|
+
if (!lastBlock) {
|
|
26
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
27
|
+
clientRes.end(JSON.stringify({ active: false }));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
31
|
+
clientRes.end(JSON.stringify({
|
|
32
|
+
active: false,
|
|
33
|
+
lastBlock: {
|
|
34
|
+
startTime: lastBlock.startTime,
|
|
35
|
+
endTime: lastBlock.endTime,
|
|
36
|
+
totalTokens: lastBlock.totalTokens,
|
|
37
|
+
costUSD: Math.round(lastBlock.costUSD * 100) / 100,
|
|
38
|
+
models: lastBlock.models,
|
|
39
|
+
minutesAgo: Math.round((now - lastBlock._lastTs) / 60000),
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const br = calculateBurnRate(activeBlock);
|
|
45
|
+
const minutesRemaining = Math.round((activeBlock._endMs - now) / 60_000);
|
|
46
|
+
const rawRateLimitState = store.getRateLimitState();
|
|
47
|
+
// Ignore stale rate limit data: must be within current block and <10min old
|
|
48
|
+
const rateLimitState = rawRateLimitState
|
|
49
|
+
&& rawRateLimitState.updatedAt > activeBlock._startMs
|
|
50
|
+
&& (now - rawRateLimitState.updatedAt) < 600_000
|
|
51
|
+
? rawRateLimitState : null;
|
|
52
|
+
const liveLimit = rateLimitState && rateLimitState.tokensLimit;
|
|
53
|
+
const liveRemaining = rateLimitState && rateLimitState.tokensRemaining;
|
|
54
|
+
const tokenLimit = liveLimit || TOKEN_LIMIT;
|
|
55
|
+
const tokensUsed = liveLimit ? (liveLimit - liveRemaining) : activeBlock.totalTokens;
|
|
56
|
+
const percentUsed = Math.round((tokensUsed / tokenLimit) * 1000) / 10;
|
|
57
|
+
const resetTime = rateLimitState && rateLimitState.inputReset || activeBlock.endTime;
|
|
58
|
+
const windowStartMs = activeBlock._startMs;
|
|
59
|
+
const windowEndMs = activeBlock._endMs;
|
|
60
|
+
const windowDurationMs = windowEndMs - windowStartMs;
|
|
61
|
+
const minutesElapsed = Math.round((now - windowStartMs) / 60_000);
|
|
62
|
+
const timePct = Math.round(Math.min(100, Math.max(0, (now - windowStartMs) / windowDurationMs * 100) * 10)) / 10;
|
|
63
|
+
const resp = {
|
|
64
|
+
active: true,
|
|
65
|
+
startTime: activeBlock.startTime,
|
|
66
|
+
endTime: resetTime,
|
|
67
|
+
totalTokens: tokensUsed,
|
|
68
|
+
tokenLimit,
|
|
69
|
+
percentUsed,
|
|
70
|
+
costUSD: Math.round(activeBlock.costUSD * 100) / 100,
|
|
71
|
+
models: activeBlock.models,
|
|
72
|
+
burnRate: br ? br.burnRate : null,
|
|
73
|
+
projection: br ? br.projection : null,
|
|
74
|
+
minutesRemaining: rateLimitState && rateLimitState.inputReset
|
|
75
|
+
? Math.round((new Date(rateLimitState.inputReset).getTime() - now) / 60_000)
|
|
76
|
+
: minutesRemaining,
|
|
77
|
+
minutesElapsed,
|
|
78
|
+
timePct,
|
|
79
|
+
source: rateLimitState ? 'live' : 'jsonl',
|
|
80
|
+
};
|
|
81
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
82
|
+
clientRes.end(JSON.stringify(resp));
|
|
83
|
+
});
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (clientReq.url === '/_api/costs/daily') {
|
|
88
|
+
sendLoadingOrData(clientRes, data => {
|
|
89
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
90
|
+
clientRes.end(JSON.stringify(data.daily));
|
|
91
|
+
});
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (clientReq.url === '/_api/costs/monthly') {
|
|
96
|
+
sendLoadingOrData(clientRes, data => {
|
|
97
|
+
const now = new Date();
|
|
98
|
+
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
|
99
|
+
const currentMonthData = data.monthly.find(m => m.month === currentMonth) || { month: currentMonth, totalTokens: 0, costUSD: 0, models: [] };
|
|
100
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
101
|
+
clientRes.end(JSON.stringify({ monthly: data.monthly, currentMonth: currentMonthData }));
|
|
102
|
+
});
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (clientReq.url === '/_api/pricing') {
|
|
107
|
+
const result = {};
|
|
108
|
+
for (const [model, rates] of Object.entries(pricingTable)) {
|
|
109
|
+
result[model] = {
|
|
110
|
+
input_cost_per_mtok: rates.input,
|
|
111
|
+
output_cost_per_mtok: rates.output,
|
|
112
|
+
cache_read_cost_per_mtok: rates.cache_read,
|
|
113
|
+
cache_creation_cost_per_mtok: rates.cache_create,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
117
|
+
clientRes.end(JSON.stringify(result));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { handleCostRoutes };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const store = require('../store');
|
|
4
|
+
const { taipeiTime } = require('../helpers');
|
|
5
|
+
const { broadcastInterceptToggle, broadcastInterceptRemoved, broadcastSessionStatus } = require('../sse-broadcast');
|
|
6
|
+
const { forwardRequest } = require('../forward');
|
|
7
|
+
|
|
8
|
+
function handleInterceptRoutes(clientReq, clientRes) {
|
|
9
|
+
if (clientReq.url === '/_api/intercept/toggle' && clientReq.method === 'POST') {
|
|
10
|
+
const chunks = []; clientReq.on('data', c => chunks.push(c));
|
|
11
|
+
clientReq.on('end', () => {
|
|
12
|
+
try {
|
|
13
|
+
const { sessionId } = JSON.parse(Buffer.concat(chunks).toString());
|
|
14
|
+
if (!sessionId) { clientRes.writeHead(400); clientRes.end('missing sessionId'); return; }
|
|
15
|
+
const enabled = !store.interceptSessions.has(sessionId);
|
|
16
|
+
if (enabled) store.interceptSessions.add(sessionId); else store.interceptSessions.delete(sessionId);
|
|
17
|
+
broadcastInterceptToggle(sessionId, enabled);
|
|
18
|
+
console.log(`\x1b[33m${enabled ? '⏸' : '▶'} INTERCEPT ${enabled ? 'ON' : 'OFF'} for session ${sessionId.slice(0, 8)}\x1b[0m`);
|
|
19
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
20
|
+
clientRes.end(JSON.stringify({ sessionId, enabled }));
|
|
21
|
+
} catch { clientRes.writeHead(400); clientRes.end('bad json'); }
|
|
22
|
+
});
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const approveMatch = clientReq.url.match(/^\/_api\/intercept\/(.+)\/approve$/);
|
|
27
|
+
if (approveMatch && clientReq.method === 'POST') {
|
|
28
|
+
const reqId = decodeURIComponent(approveMatch[1]);
|
|
29
|
+
const pending = store.pendingRequests.get(reqId);
|
|
30
|
+
if (!pending) { clientRes.writeHead(404); clientRes.end('not found'); return true; }
|
|
31
|
+
const chunks = []; clientReq.on('data', c => chunks.push(c));
|
|
32
|
+
clientReq.on('end', () => {
|
|
33
|
+
clearTimeout(pending.timer);
|
|
34
|
+
store.pendingRequests.delete(reqId);
|
|
35
|
+
broadcastInterceptRemoved(reqId);
|
|
36
|
+
try {
|
|
37
|
+
const payload = Buffer.concat(chunks).toString();
|
|
38
|
+
if (payload) {
|
|
39
|
+
const { body } = JSON.parse(payload);
|
|
40
|
+
if (body) { pending.parsedBody = body; pending.bodyModified = true; }
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
console.log(`\x1b[32m✓ INTERCEPT APPROVED [${taipeiTime()}] ${reqId}\x1b[0m`);
|
|
44
|
+
forwardRequest(pending);
|
|
45
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
46
|
+
clientRes.end(JSON.stringify({ ok: true }));
|
|
47
|
+
});
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rejectMatch = clientReq.url.match(/^\/_api\/intercept\/(.+)\/reject$/);
|
|
52
|
+
if (rejectMatch && clientReq.method === 'POST') {
|
|
53
|
+
const reqId = decodeURIComponent(rejectMatch[1]);
|
|
54
|
+
const pending = store.pendingRequests.get(reqId);
|
|
55
|
+
if (!pending) { clientRes.writeHead(404); clientRes.end('not found'); return true; }
|
|
56
|
+
clearTimeout(pending.timer);
|
|
57
|
+
store.pendingRequests.delete(reqId);
|
|
58
|
+
broadcastInterceptRemoved(reqId);
|
|
59
|
+
if (pending.reqSessionId) {
|
|
60
|
+
store.activeRequests[pending.reqSessionId] = Math.max(0, (store.activeRequests[pending.reqSessionId] || 1) - 1);
|
|
61
|
+
broadcastSessionStatus(pending.reqSessionId);
|
|
62
|
+
}
|
|
63
|
+
console.log(`\x1b[31m✕ INTERCEPT REJECTED [${taipeiTime()}] ${reqId}\x1b[0m`);
|
|
64
|
+
if (!pending.clientRes.headersSent) {
|
|
65
|
+
pending.clientRes.writeHead(499, { 'Content-Type': 'application/json' });
|
|
66
|
+
}
|
|
67
|
+
pending.clientRes.end(JSON.stringify({ error: 'request_rejected', message: 'Request rejected by dashboard' }));
|
|
68
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
69
|
+
clientRes.end(JSON.stringify({ ok: true }));
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (clientReq.url === '/_api/intercept/timeout' && clientReq.method === 'POST') {
|
|
74
|
+
const chunks = []; clientReq.on('data', c => chunks.push(c));
|
|
75
|
+
clientReq.on('end', () => {
|
|
76
|
+
try {
|
|
77
|
+
const { timeout } = JSON.parse(Buffer.concat(chunks).toString());
|
|
78
|
+
store.setInterceptTimeout(Math.max(30, Math.min(180, Number(timeout) || 120)));
|
|
79
|
+
clientRes.writeHead(200, { 'Content-Type': 'application/json' });
|
|
80
|
+
clientRes.end(JSON.stringify({ timeout: store.getInterceptTimeout() }));
|
|
81
|
+
} catch { clientRes.writeHead(400); clientRes.end('bad json'); }
|
|
82
|
+
});
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { handleInterceptRoutes };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const store = require('../store');
|
|
4
|
+
|
|
5
|
+
function handleSSERoute(clientReq, clientRes) {
|
|
6
|
+
if (clientReq.url !== '/_events') return false;
|
|
7
|
+
|
|
8
|
+
clientRes.writeHead(200, {
|
|
9
|
+
'Content-Type': 'text/event-stream',
|
|
10
|
+
'Cache-Control': 'no-cache',
|
|
11
|
+
'Connection': 'keep-alive',
|
|
12
|
+
});
|
|
13
|
+
clientRes.write(':\n\n');
|
|
14
|
+
|
|
15
|
+
// Send current session statuses
|
|
16
|
+
for (const [sid, meta] of Object.entries(store.sessionMeta)) {
|
|
17
|
+
if (meta.lastSeenAt || (store.activeRequests[sid] || 0) > 0) {
|
|
18
|
+
const active = (store.activeRequests[sid] || 0) > 0;
|
|
19
|
+
const data = JSON.stringify({ _type: 'session_status', sessionId: sid, active, lastSeenAt: meta.lastSeenAt || null });
|
|
20
|
+
clientRes.write(`data: ${data}\n\n`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Send intercept state
|
|
25
|
+
for (const sid of store.interceptSessions) {
|
|
26
|
+
clientRes.write(`data: ${JSON.stringify({ _type: 'intercept_toggled', sessionId: sid, enabled: true })}\n\n`);
|
|
27
|
+
}
|
|
28
|
+
for (const [reqId, pending] of store.pendingRequests) {
|
|
29
|
+
clientRes.write(`data: ${JSON.stringify({ _type: 'pending_request', requestId: reqId, sessionId: pending.reqSessionId, body: pending.parsedBody })}\n\n`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Send current interceptTimeout
|
|
33
|
+
clientRes.write(`data: ${JSON.stringify({ _type: 'intercept_timeout', timeout: store.getInterceptTimeout() })}\n\n`);
|
|
34
|
+
|
|
35
|
+
store.sseClients.push(clientRes);
|
|
36
|
+
clientReq.on('close', () => {
|
|
37
|
+
const idx = store.sseClients.indexOf(clientRes);
|
|
38
|
+
if (idx >= 0) store.sseClients.splice(idx, 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { handleSSERoute };
|
package/server/sigv4.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
function hmac(key, data, enc) {
|
|
6
|
+
return crypto.createHmac('sha256', key).update(data).digest(enc || undefined);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sha256hex(data) {
|
|
10
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Sign a request with AWS Signature Version 4.
|
|
14
|
+
// Returns extra headers to add: { authorization, 'x-amz-date', ['x-amz-security-token'] }
|
|
15
|
+
//
|
|
16
|
+
// Parameters:
|
|
17
|
+
// method - HTTP method (e.g. 'POST')
|
|
18
|
+
// urlStr - Full URL string (e.g. 'https://bedrock-runtime.us-east-1.amazonaws.com/model/...')
|
|
19
|
+
// headers - Headers object to be forwarded (host/x-amz-date will be added from here for signing)
|
|
20
|
+
// body - Request body (Buffer or string)
|
|
21
|
+
// credentials - { accessKeyId, secretAccessKey, sessionToken }
|
|
22
|
+
// region - AWS region (e.g. 'us-east-1')
|
|
23
|
+
// service - AWS service name (e.g. 'bedrock')
|
|
24
|
+
function sign(method, urlStr, headers, body, credentials, region, service) {
|
|
25
|
+
const url = new URL(urlStr);
|
|
26
|
+
|
|
27
|
+
const now = new Date();
|
|
28
|
+
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
29
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
30
|
+
|
|
31
|
+
// Build the set of headers to sign (lowercase names)
|
|
32
|
+
const signingHeaders = {};
|
|
33
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
34
|
+
const lk = k.toLowerCase();
|
|
35
|
+
// Exclude pseudo-headers that we're replacing
|
|
36
|
+
if (lk === 'authorization' || lk === 'x-amz-date' || lk === 'x-amz-security-token') continue;
|
|
37
|
+
signingHeaders[lk] = String(v).trim();
|
|
38
|
+
}
|
|
39
|
+
signingHeaders['host'] = url.hostname;
|
|
40
|
+
signingHeaders['x-amz-date'] = amzDate;
|
|
41
|
+
if (credentials.sessionToken) {
|
|
42
|
+
signingHeaders['x-amz-security-token'] = credentials.sessionToken;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Sorted unique lowercase header keys
|
|
46
|
+
const sortedKeys = [...new Set(Object.keys(signingHeaders).sort())];
|
|
47
|
+
const canonicalHeaders = sortedKeys.map(k => `${k}:${signingHeaders[k]}\n`).join('');
|
|
48
|
+
const signedHeaders = sortedKeys.join(';');
|
|
49
|
+
|
|
50
|
+
// Canonical URI: percent-encode each path segment (unreserved chars stay as-is)
|
|
51
|
+
const canonicalUri = url.pathname
|
|
52
|
+
.split('/')
|
|
53
|
+
.map(seg => encodeURIComponent(decodeURIComponent(seg)))
|
|
54
|
+
.join('/') || '/';
|
|
55
|
+
|
|
56
|
+
// Canonical query string: sorted key=value pairs
|
|
57
|
+
const queryEntries = [...url.searchParams.entries()]
|
|
58
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
59
|
+
const canonicalQueryString = queryEntries
|
|
60
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
61
|
+
.join('&');
|
|
62
|
+
|
|
63
|
+
const payloadHash = sha256hex(body || '');
|
|
64
|
+
|
|
65
|
+
const canonicalRequest = [
|
|
66
|
+
method.toUpperCase(),
|
|
67
|
+
canonicalUri,
|
|
68
|
+
canonicalQueryString,
|
|
69
|
+
canonicalHeaders,
|
|
70
|
+
signedHeaders,
|
|
71
|
+
payloadHash,
|
|
72
|
+
].join('\n');
|
|
73
|
+
|
|
74
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
|
75
|
+
const stringToSign = [
|
|
76
|
+
'AWS4-HMAC-SHA256',
|
|
77
|
+
amzDate,
|
|
78
|
+
credentialScope,
|
|
79
|
+
sha256hex(canonicalRequest),
|
|
80
|
+
].join('\n');
|
|
81
|
+
|
|
82
|
+
// Derive signing key: HMAC chain AWS4+secret → date → region → service → 'aws4_request'
|
|
83
|
+
const signingKey = hmac(
|
|
84
|
+
hmac(
|
|
85
|
+
hmac(
|
|
86
|
+
hmac(`AWS4${credentials.secretAccessKey}`, dateStamp),
|
|
87
|
+
region
|
|
88
|
+
),
|
|
89
|
+
service
|
|
90
|
+
),
|
|
91
|
+
'aws4_request'
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const signature = hmac(signingKey, stringToSign, 'hex');
|
|
95
|
+
const authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
authorization,
|
|
99
|
+
'x-amz-date': amzDate,
|
|
100
|
+
...(credentials.sessionToken ? { 'x-amz-security-token': credentials.sessionToken } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { sign };
|