ai-cc-router 0.1.5 → 0.1.7

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.
@@ -9,6 +9,21 @@ import { logRoute, logError, logStartup } from "./logger.js";
9
9
  import { stats } from "./stats.js";
10
10
  import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
11
11
  import chalk from "chalk";
12
+ // Mutates entry and updates aggregate counters with token usage from Anthropic's
13
+ // response. Called asynchronously after the log entry is already stored,
14
+ // so the dashboard picks up the values on the next poll.
15
+ function applyInputUsage(entry, usage) {
16
+ entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
17
+ entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
18
+ entry.inputTokens = usage["input_tokens"] ?? 0;
19
+ stats.totalCacheReadTokens += entry.cacheReadTokens;
20
+ stats.totalCacheCreationTokens += entry.cacheCreationTokens;
21
+ stats.totalInputTokens += entry.inputTokens;
22
+ }
23
+ function applyOutputUsage(entry, usage) {
24
+ entry.outputTokens = usage["output_tokens"] ?? 0;
25
+ stats.totalOutputTokens += entry.outputTokens;
26
+ }
12
27
  export async function startServer(opts = {}) {
13
28
  const port = opts.port ?? PROXY_PORT;
14
29
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
@@ -65,6 +80,10 @@ export async function startServer(opts = {}) {
65
80
  totalRequests: stats.totalRequests,
66
81
  totalErrors: stats.totalErrors,
67
82
  totalRefreshes: stats.totalRefreshes,
83
+ totalCacheReadTokens: stats.totalCacheReadTokens,
84
+ totalCacheCreationTokens: stats.totalCacheCreationTokens,
85
+ totalInputTokens: stats.totalInputTokens,
86
+ totalOutputTokens: stats.totalOutputTokens,
68
87
  accounts: pool.getStats(),
69
88
  recentLogs: stats.getRecentLogs(50),
70
89
  });
@@ -163,7 +182,64 @@ export async function startServer(opts = {}) {
163
182
  account.busy = true;
164
183
  setTimeout(() => { account.busy = false; }, 30_000);
165
184
  }
166
- stats.addLog(pendingLog);
185
+ const entry = pendingLog;
186
+ stats.addLog(entry);
187
+ // ── Capture token usage from Anthropic response body ─────────────────
188
+ // SSE streams carry usage across two events:
189
+ // message_start → input_tokens, cache_read/creation_input_tokens
190
+ // message_delta → output_tokens
191
+ // Non-streaming JSON carries all fields in a single usage object.
192
+ // We use incremental line parsing (not buffering) so we can capture
193
+ // both events without holding the full stream in memory.
194
+ const contentType = String(proxyRes.headers["content-type"] ?? "");
195
+ const encoding = String(proxyRes.headers["content-encoding"] ?? "");
196
+ const isCompressed = /gzip|br|deflate/.test(encoding);
197
+ if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
198
+ const isSSE = contentType.includes("text/event-stream");
199
+ if (isSSE) {
200
+ let lineBuf = "";
201
+ let gotInput = false;
202
+ let gotOutput = false;
203
+ proxyRes.on("data", (chunk) => {
204
+ if (gotInput && gotOutput)
205
+ return;
206
+ lineBuf += chunk.toString("utf8");
207
+ const lines = lineBuf.split("\n");
208
+ lineBuf = lines.pop() ?? ""; // keep incomplete last line
209
+ for (const line of lines) {
210
+ if (!line.startsWith("data: "))
211
+ continue;
212
+ try {
213
+ const evt = JSON.parse(line.slice(6));
214
+ if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
215
+ applyInputUsage(entry, evt.message.usage);
216
+ gotInput = true;
217
+ }
218
+ if (!gotOutput && evt.type === "message_delta" && evt.usage) {
219
+ applyOutputUsage(entry, evt.usage);
220
+ gotOutput = true;
221
+ }
222
+ }
223
+ catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
224
+ }
225
+ });
226
+ }
227
+ else {
228
+ // Non-streaming JSON: buffer full body then parse once
229
+ let buf = "";
230
+ proxyRes.on("data", (chunk) => { buf += chunk.toString("utf8"); });
231
+ proxyRes.on("end", () => {
232
+ try {
233
+ const body = JSON.parse(buf);
234
+ if (body.usage) {
235
+ applyInputUsage(entry, body.usage);
236
+ applyOutputUsage(entry, body.usage);
237
+ }
238
+ }
239
+ catch { /* ignore */ }
240
+ });
241
+ }
242
+ }
167
243
  },
168
244
  error: (err, _req, res) => {
169
245
  stats.totalErrors++;
@@ -3,6 +3,10 @@ class ProxyStats {
3
3
  totalRequests = 0;
4
4
  totalErrors = 0;
5
5
  totalRefreshes = 0;
6
+ totalCacheReadTokens = 0;
7
+ totalCacheCreationTokens = 0;
8
+ totalInputTokens = 0;
9
+ totalOutputTokens = 0;
6
10
  startTime = Date.now();
7
11
  logs = [];
8
12
  addLog(entry) {
@@ -79,7 +79,7 @@ function LiveDashboard({ data, port, lastUpdate }) {
79
79
  });
80
80
  const selectedLog = logs[selectedIndex] ?? null;
81
81
  const visibleLogs = logs.slice(0, LOG_VISIBLE);
82
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
82
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [\u2191\u2193] navigate \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes }), _jsx(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _jsx(TokenSummary, { cacheRead: data.totalCacheReadTokens, cacheCreated: data.totalCacheCreationTokens, uncached: data.totalInputTokens, output: data.totalOutputTokens })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: visibleLogs.length === 0
83
83
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
84
84
  : visibleLogs.map((log, i) => (_jsx(LogRow, { log: log, selected: i === selectedIndex }, log.ts))) })] }), selectedLog && (_jsxs(_Fragment, { children: [_jsx(Box, { marginTop: 1 }), _jsx(DetailPanel, { log: selectedLog })] }))] }));
85
85
  }
@@ -109,9 +109,17 @@ function LogRow({ log, selected }) {
109
109
  : "gray";
110
110
  const bg = selected ? "white" : undefined;
111
111
  const fg = (c) => selected ? "black" : c;
112
+ // Per-request token stats
113
+ const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
114
+ const outputTok = log.outputTokens ?? 0;
115
+ const cacheHitPct = inputTok > 0 ? Math.round(((log.cacheReadTokens ?? 0) / inputTok) * 100) : null;
116
+ const cacheColor = cacheHitPct === null ? undefined
117
+ : cacheHitPct >= 70 ? "green"
118
+ : cacheHitPct >= 30 ? "yellow"
119
+ : "red";
112
120
  return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
113
121
  ? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
114
- : _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
122
+ : _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), (inputTok > 0 || outputTok > 0) && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", fmtTok(inputTok), "\u2191 ", fmtTok(outputTok), "\u2193"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
115
123
  }
116
124
  // ─── Detail panel ─────────────────────────────────────────────────────────────
117
125
  function DetailPanel({ log }) {
@@ -129,7 +137,7 @@ function DetailPanel({ log }) {
129
137
  : log.statusCode >= 500 ? "red"
130
138
  : log.statusCode >= 400 ? "yellow"
131
139
  : "green";
132
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) }))] })] }));
140
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
133
141
  }
134
142
  function Field({ label, value }) {
135
143
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
@@ -137,6 +145,39 @@ function Field({ label, value }) {
137
145
  function FieldColored({ label, value, color }) {
138
146
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: color, children: value })] }));
139
147
  }
148
+ // ─── Cache health badge (aggregated) ─────────────────────────────────────────
149
+ function CacheHealthBadge({ read, created, input }) {
150
+ const total = read + created + input;
151
+ if (total === 0)
152
+ return null;
153
+ const hitPct = Math.round((read / total) * 100);
154
+ const color = hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
155
+ const label = hitPct >= 70 ? "healthy" : hitPct >= 30 ? "fair" : "poor";
156
+ return (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "cache " }), _jsxs(Text, { color: color, children: [hitPct, "% hit "] }), _jsxs(Text, { color: "gray", children: ["(", label, ")"] })] }));
157
+ }
158
+ // ─── Cache breakdown (per-request detail) ────────────────────────────────────
159
+ function CacheBreakdown({ read, created, input, output }) {
160
+ const totalInput = read + created + input;
161
+ const hitPct = totalInput > 0 ? (read / totalInput) * 100 : 0;
162
+ const color = totalInput === 0 ? "gray" : hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
163
+ return (_jsxs(_Fragment, { children: [_jsx(FieldColored, { label: "Cache hit", value: totalInput > 0 ? `${fmtTok(read)} tok (${hitPct.toFixed(1)}%)` : "—", color: color }), _jsx(Field, { label: "Cache created", value: fmtTok(created) + " tok" }), _jsx(Field, { label: "Uncached", value: fmtTok(input) + " tok" }), _jsx(Field, { label: "Total input", value: fmtTok(totalInput) + " tok" }), _jsx(Field, { label: "Output", value: fmtTok(output) + " tok" }), _jsx(Field, { label: "Total", value: fmtTok(totalInput + output) + " tok" })] }));
164
+ }
165
+ // ─── Token summary (aggregated totals) ──────────────────────────────────────
166
+ function TokenSummary({ cacheRead, cacheCreated, uncached, output }) {
167
+ const totalInput = cacheRead + cacheCreated + uncached;
168
+ const totalAll = totalInput + output;
169
+ if (totalAll === 0)
170
+ return null;
171
+ const hitPct = totalInput > 0 ? (cacheRead / totalInput) * 100 : 0;
172
+ return (_jsxs(Box, { paddingLeft: 2, children: [_jsx(Text, { color: "gray", children: "input " }), _jsx(Text, { color: "white", children: fmtTok(totalInput) }), _jsx(Text, { color: "gray", children: " (cached " }), _jsx(Text, { color: "green", children: fmtTok(cacheRead) }), _jsx(Text, { color: "gray", children: " + new " }), _jsx(Text, { color: "yellow", children: fmtTok(cacheCreated) }), _jsx(Text, { color: "gray", children: " + uncached " }), _jsx(Text, { color: "white", children: fmtTok(uncached) }), _jsx(Text, { color: "gray", children: ") \u00B7 output " }), _jsx(Text, { color: "white", children: fmtTok(output) }), _jsx(Text, { color: "gray", children: " \u00B7 total " }), _jsx(Text, { color: "cyan", bold: true, children: fmtTok(totalAll) })] }));
173
+ }
174
+ function fmtTok(n) {
175
+ if (n >= 1_000_000)
176
+ return `${(n / 1_000_000).toFixed(1)}M`;
177
+ if (n >= 1_000)
178
+ return `${(n / 1_000).toFixed(1)}k`;
179
+ return String(n);
180
+ }
140
181
  // ─── HTTP status text ─────────────────────────────────────────────────────────
141
182
  function httpStatusText(code) {
142
183
  const map = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {