ai-cc-router 0.1.6 → 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,10 +9,10 @@ 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 cache usage from Anthropic's
13
- // message_start event. Called asynchronously after the log entry is already stored,
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
14
  // so the dashboard picks up the values on the next poll.
15
- function applyCacheUsage(entry, usage) {
15
+ function applyInputUsage(entry, usage) {
16
16
  entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
17
17
  entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
18
18
  entry.inputTokens = usage["input_tokens"] ?? 0;
@@ -20,6 +20,10 @@ function applyCacheUsage(entry, usage) {
20
20
  stats.totalCacheCreationTokens += entry.cacheCreationTokens;
21
21
  stats.totalInputTokens += entry.inputTokens;
22
22
  }
23
+ function applyOutputUsage(entry, usage) {
24
+ entry.outputTokens = usage["output_tokens"] ?? 0;
25
+ stats.totalOutputTokens += entry.outputTokens;
26
+ }
23
27
  export async function startServer(opts = {}) {
24
28
  const port = opts.port ?? PROXY_PORT;
25
29
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
@@ -79,6 +83,7 @@ export async function startServer(opts = {}) {
79
83
  totalCacheReadTokens: stats.totalCacheReadTokens,
80
84
  totalCacheCreationTokens: stats.totalCacheCreationTokens,
81
85
  totalInputTokens: stats.totalInputTokens,
86
+ totalOutputTokens: stats.totalOutputTokens,
82
87
  accounts: pool.getStats(),
83
88
  recentLogs: stats.getRecentLogs(50),
84
89
  });
@@ -179,50 +184,57 @@ export async function startServer(opts = {}) {
179
184
  }
180
185
  const entry = pendingLog;
181
186
  stats.addLog(entry);
182
- // ── Capture cache usage from Anthropic response body ──────────────────
183
- // The message_start SSE event (or JSON body) carries usage fields:
184
- // cache_read_input_tokens, cache_creation_input_tokens, input_tokens
185
- // We add a passive data listener to capture these without breaking the
186
- // streaming passthrough all data listeners share the same chunks.
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.
187
194
  const contentType = String(proxyRes.headers["content-type"] ?? "");
188
195
  const encoding = String(proxyRes.headers["content-encoding"] ?? "");
189
196
  const isCompressed = /gzip|br|deflate/.test(encoding);
190
197
  if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
191
198
  const isSSE = contentType.includes("text/event-stream");
192
- const MAX_BUF = 8 * 1024;
193
- let buf = "";
194
- let captured = false;
195
- proxyRes.on("data", (chunk) => {
196
- if (captured || buf.length >= MAX_BUF)
197
- return;
198
- buf += chunk.toString("utf8");
199
- if (isSSE) {
200
- // message_start is always the first SSE event — parse as soon as we have it
201
- const lines = buf.split("\n");
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
202
209
  for (const line of lines) {
203
210
  if (!line.startsWith("data: "))
204
211
  continue;
205
212
  try {
206
213
  const evt = JSON.parse(line.slice(6));
207
- if (evt.type === "message_start" && evt.message?.usage) {
208
- applyCacheUsage(entry, evt.message.usage);
209
- captured = true;
210
- break;
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;
211
221
  }
212
222
  }
213
- catch { /* partial JSON, wait for next chunk */ }
223
+ catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
214
224
  }
215
- }
216
- });
217
- if (!isSSE) {
218
- // Non-streaming: parse full JSON body on 'end'
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"); });
219
231
  proxyRes.on("end", () => {
220
- if (captured)
221
- return;
222
232
  try {
223
233
  const body = JSON.parse(buf);
224
- if (body.usage)
225
- applyCacheUsage(entry, body.usage);
234
+ if (body.usage) {
235
+ applyInputUsage(entry, body.usage);
236
+ applyOutputUsage(entry, body.usage);
237
+ }
226
238
  }
227
239
  catch { /* ignore */ }
228
240
  });
@@ -6,6 +6,7 @@ class ProxyStats {
6
6
  totalCacheReadTokens = 0;
7
7
  totalCacheCreationTokens = 0;
8
8
  totalInputTokens = 0;
9
+ totalOutputTokens = 0;
9
10
  startTime = Date.now();
10
11
  logs = [];
11
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(CacheHealthBadge, { read: data.totalCacheReadTokens, created: data.totalCacheCreationTokens, input: data.totalInputTokens })] }), _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,16 +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 cache hit rate
113
- const totalTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
114
- const cacheHitPct = totalTok > 0 ? Math.round(((log.cacheReadTokens ?? 0) / totalTok) * 100) : null;
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;
115
116
  const cacheColor = cacheHitPct === null ? undefined
116
117
  : cacheHitPct >= 70 ? "green"
117
118
  : cacheHitPct >= 30 ? "yellow"
118
119
  : "red";
119
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
120
121
  ? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
121
- : _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, "%"] })), 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] }))] }));
122
123
  }
123
124
  // ─── Detail panel ─────────────────────────────────────────────────────────────
124
125
  function DetailPanel({ log }) {
@@ -136,7 +137,7 @@ function DetailPanel({ log }) {
136
137
  : log.statusCode >= 500 ? "red"
137
138
  : log.statusCode >= 400 ? "yellow"
138
139
  : "green";
139
- 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 }) }))] })] }));
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 }) }))] })] }));
140
141
  }
141
142
  function Field({ label, value }) {
142
143
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
@@ -155,11 +156,20 @@ function CacheHealthBadge({ read, created, input }) {
155
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, ")"] })] }));
156
157
  }
157
158
  // ─── Cache breakdown (per-request detail) ────────────────────────────────────
158
- function CacheBreakdown({ read, created, input }) {
159
- const total = read + created + input;
160
- const hitPct = total > 0 ? (read / total) * 100 : 0;
161
- const color = total === 0 ? "gray" : hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
162
- return (_jsxs(_Fragment, { children: [_jsx(FieldColored, { label: "Cache hit", value: total > 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(total) + " tok" })] }));
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) })] }));
163
173
  }
164
174
  function fmtTok(n) {
165
175
  if (n >= 1_000_000)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.6",
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": {