ai-cc-router 0.1.6 → 0.1.8

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,7 @@ import { writeClaudeSettings, readClaudeProxySettings } from "../utils/claude-co
9
9
  import { saveAccounts } from "../proxy/token-refresher.js";
10
10
  import { loadAccounts, accountsFileExists, readConfig, writeConfig, generateProxySecret } from "../config/manager.js";
11
11
  import { PROXY_PORT } from "../config/paths.js";
12
+ import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
12
13
  const execFileAsync = promisify(execFile);
13
14
  // ─── Public registration ──────────────────────────────────────────────────────
14
15
  export function registerSetup(program) {
@@ -101,6 +102,7 @@ export async function setupSingleAccount(index) {
101
102
  lastUsed: 0,
102
103
  lastRefresh: 0,
103
104
  consecutiveErrors: 0,
105
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
104
106
  };
105
107
  }
106
108
  // ─── Full wizard ──────────────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
2
2
  import { randomBytes } from "crypto";
3
3
  import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
4
+ import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
4
5
  export function ensureConfigDir() {
5
6
  if (!existsSync(CONFIG_DIR)) {
6
7
  mkdirSync(CONFIG_DIR, { recursive: true });
@@ -73,5 +74,6 @@ function deserialize(records) {
73
74
  lastUsed: 0,
74
75
  lastRefresh: 0,
75
76
  consecutiveErrors: 0,
77
+ rateLimits: { ...DEFAULT_RATE_LIMITS },
76
78
  }));
77
79
  }
@@ -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,38 @@ 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
+ }
27
+ // ─── Rate limit header extraction ──────────────────────────────────────────
28
+ function inferPlan(requestsLimit) {
29
+ if (requestsLimit <= 0)
30
+ return "";
31
+ if (requestsLimit <= 100)
32
+ return "Pro";
33
+ if (requestsLimit <= 500)
34
+ return "Max 5x";
35
+ return "Max 20x";
36
+ }
37
+ function extractRateLimits(headers) {
38
+ const h = (name) => String(headers[name] ?? "");
39
+ const status = h("anthropic-ratelimit-unified-status");
40
+ if (!status)
41
+ return null; // No unified headers in this response
42
+ const requestsLimit = parseInt(h("anthropic-ratelimit-requests-limit"), 10) || 0;
43
+ return {
44
+ status: status === "rate_limited" ? "rate_limited" : "allowed",
45
+ fiveHourUtil: parseFloat(h("anthropic-ratelimit-unified-5h-utilization")) || 0,
46
+ fiveHourReset: parseInt(h("anthropic-ratelimit-unified-5h-reset"), 10) || 0,
47
+ sevenDayUtil: parseFloat(h("anthropic-ratelimit-unified-7d-utilization")) || 0,
48
+ sevenDayReset: parseInt(h("anthropic-ratelimit-unified-7d-reset"), 10) || 0,
49
+ claim: h("anthropic-ratelimit-unified-representative-claim"),
50
+ plan: inferPlan(requestsLimit),
51
+ requestsLimit,
52
+ lastUpdated: Date.now(),
53
+ };
54
+ }
23
55
  export async function startServer(opts = {}) {
24
56
  const port = opts.port ?? PROXY_PORT;
25
57
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
@@ -79,6 +111,7 @@ export async function startServer(opts = {}) {
79
111
  totalCacheReadTokens: stats.totalCacheReadTokens,
80
112
  totalCacheCreationTokens: stats.totalCacheCreationTokens,
81
113
  totalInputTokens: stats.totalInputTokens,
114
+ totalOutputTokens: stats.totalOutputTokens,
82
115
  accounts: pool.getStats(),
83
116
  recentLogs: stats.getRecentLogs(50),
84
117
  });
@@ -177,52 +210,63 @@ export async function startServer(opts = {}) {
177
210
  account.busy = true;
178
211
  setTimeout(() => { account.busy = false; }, 30_000);
179
212
  }
213
+ // ── Capture rate limit utilization from response headers ────────────
214
+ const rl = extractRateLimits(proxyRes.headers);
215
+ if (rl)
216
+ account.rateLimits = rl;
180
217
  const entry = pendingLog;
181
218
  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.
219
+ // ── Capture token usage from Anthropic response body ─────────────────
220
+ // SSE streams carry usage across two events:
221
+ // message_start → input_tokens, cache_read/creation_input_tokens
222
+ // message_delta → output_tokens
223
+ // Non-streaming JSON carries all fields in a single usage object.
224
+ // We use incremental line parsing (not buffering) so we can capture
225
+ // both events without holding the full stream in memory.
187
226
  const contentType = String(proxyRes.headers["content-type"] ?? "");
188
227
  const encoding = String(proxyRes.headers["content-encoding"] ?? "");
189
228
  const isCompressed = /gzip|br|deflate/.test(encoding);
190
229
  if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
191
230
  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");
231
+ if (isSSE) {
232
+ let lineBuf = "";
233
+ let gotInput = false;
234
+ let gotOutput = false;
235
+ proxyRes.on("data", (chunk) => {
236
+ if (gotInput && gotOutput)
237
+ return;
238
+ lineBuf += chunk.toString("utf8");
239
+ const lines = lineBuf.split("\n");
240
+ lineBuf = lines.pop() ?? ""; // keep incomplete last line
202
241
  for (const line of lines) {
203
242
  if (!line.startsWith("data: "))
204
243
  continue;
205
244
  try {
206
245
  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;
246
+ if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
247
+ applyInputUsage(entry, evt.message.usage);
248
+ gotInput = true;
249
+ }
250
+ if (!gotOutput && evt.type === "message_delta" && evt.usage) {
251
+ applyOutputUsage(entry, evt.usage);
252
+ gotOutput = true;
211
253
  }
212
254
  }
213
- catch { /* partial JSON, wait for next chunk */ }
255
+ catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
214
256
  }
215
- }
216
- });
217
- if (!isSSE) {
218
- // Non-streaming: parse full JSON body on 'end'
257
+ });
258
+ }
259
+ else {
260
+ // Non-streaming JSON: buffer full body then parse once
261
+ let buf = "";
262
+ proxyRes.on("data", (chunk) => { buf += chunk.toString("utf8"); });
219
263
  proxyRes.on("end", () => {
220
- if (captured)
221
- return;
222
264
  try {
223
265
  const body = JSON.parse(buf);
224
- if (body.usage)
225
- applyCacheUsage(entry, body.usage);
266
+ if (body.usage) {
267
+ applyInputUsage(entry, body.usage);
268
+ applyOutputUsage(entry, body.usage);
269
+ }
226
270
  }
227
271
  catch { /* ignore */ }
228
272
  });
@@ -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) {
@@ -1,3 +1,10 @@
1
+ /** Returns the earliest non-zero reset timestamp (seconds) for an account. */
2
+ function earliestReset(a) {
3
+ const r = a.rateLimits;
4
+ if (r.fiveHourReset && r.sevenDayReset)
5
+ return Math.min(r.fiveHourReset, r.sevenDayReset);
6
+ return r.fiveHourReset || r.sevenDayReset || Infinity;
7
+ }
1
8
  export class TokenPool {
2
9
  accounts;
3
10
  currentIndex = 0;
@@ -5,20 +12,24 @@ export class TokenPool {
5
12
  this.accounts = accounts;
6
13
  }
7
14
  /**
8
- * Round-robin selection among healthy, non-busy accounts.
9
- * Falls back to least-loaded if all are busy.
15
+ * Round-robin selection among healthy, non-busy, non-rate-limited accounts.
16
+ * Falls back to least-loaded if all are busy/limited.
17
+ * When all are rate-limited, picks the one with the earliest reset.
10
18
  * Falls back to accounts[0] if all are unhealthy.
11
19
  */
12
20
  getNext() {
13
- const available = this.accounts.filter(a => a.healthy && !a.busy);
21
+ const available = this.accounts.filter(a => a.healthy && !a.busy && a.rateLimits.status !== "rate_limited");
14
22
  if (available.length === 0) {
15
23
  const healthy = this.accounts.filter(a => a.healthy);
16
24
  if (healthy.length === 0) {
17
- // Complete fallback: nothing healthy — return first account and hope it recovers
18
25
  return this.accounts[0];
19
26
  }
20
- // All healthy but busy — return least loaded
21
- return healthy.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
27
+ // All healthy but busy/limitedpick earliest reset time
28
+ return healthy.reduce((best, a) => {
29
+ const resetA = earliestReset(a);
30
+ const resetBest = earliestReset(best);
31
+ return resetA < resetBest ? a : best;
32
+ });
22
33
  }
23
34
  const account = available[this.currentIndex % available.length];
24
35
  this.currentIndex = (this.currentIndex + 1) % available.length;
@@ -42,6 +53,7 @@ export class TokenPool {
42
53
  expiresInMs: a.tokens.expiresAt - Date.now(),
43
54
  lastUsedMs: a.lastUsed,
44
55
  lastRefreshMs: a.lastRefresh,
56
+ rateLimits: a.rateLimits,
45
57
  }));
46
58
  }
47
59
  }
@@ -1 +1,11 @@
1
- export {};
1
+ export const DEFAULT_RATE_LIMITS = {
2
+ status: "unknown",
3
+ fiveHourUtil: 0,
4
+ fiveHourReset: 0,
5
+ sevenDayUtil: 0,
6
+ sevenDayReset: 0,
7
+ claim: "",
8
+ plan: "",
9
+ requestsLimit: 0,
10
+ lastUpdated: 0,
11
+ };
@@ -79,21 +79,47 @@ 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
  }
86
- // ─── Account row ──────────────────────────────────────────────────────────────
86
+ // ─── Account row (two-line: status + utilization bars) ───────────────────────
87
87
  function AccountRow({ account: a }) {
88
- const dot = a.busy ? "◌" : a.healthy ? "●" : "●";
89
- const dotColor = a.busy ? "yellow" : a.healthy ? "green" : "red";
90
- const statusLabel = a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
91
- const statusColor = a.busy ? "yellow" : a.healthy ? "green" : "red";
88
+ const rl = a.rateLimits;
89
+ const isLimited = rl.status === "rate_limited";
90
+ const dot = isLimited ? "⊘" : a.busy ? "" : a.healthy ? "" : "";
91
+ const dotColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
92
+ const statusLabel = isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
93
+ const statusColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
92
94
  const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
93
95
  const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
94
96
  : a.expiresInMs < 30 * 60 * 1000 ? "yellow"
95
97
  : "white";
96
- return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
98
+ const planTag = rl.plan ? ` [${rl.plan}]` : "";
99
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 20).padEnd(20) }), _jsx(Text, { color: statusColor, children: statusLabel }), planTag && _jsx(Text, { color: "magenta", children: planTag.padEnd(10) }), !planTag && _jsx(Text, { children: "".padEnd(10) }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " tok " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(8) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }), rl.lastUpdated > 0 && (_jsxs(Box, { paddingLeft: 4, children: [_jsx(UtilBar, { label: "5h", util: rl.fiveHourUtil, resetTs: rl.fiveHourReset, isActive: rl.claim === "five_hour" }), _jsx(Text, { children: " " }), _jsx(UtilBar, { label: "7d", util: rl.sevenDayUtil, resetTs: rl.sevenDayReset, isActive: rl.claim === "seven_day" })] }))] }));
100
+ }
101
+ // ─── Utilization bar ─────────────────────────────────────────────────────────
102
+ function UtilBar({ label, util, resetTs, isActive }) {
103
+ const pct = Math.round(util * 100);
104
+ const BAR_W = 12;
105
+ const filled = Math.round(util * BAR_W);
106
+ const bar = "█".repeat(Math.min(filled, BAR_W)) + "░".repeat(Math.max(BAR_W - filled, 0));
107
+ const color = pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
108
+ const resetLabel = resetTs > 0 ? formatResetIn(resetTs) : "";
109
+ return (_jsxs(Box, { children: [_jsxs(Text, { color: isActive ? "white" : "gray", bold: isActive, children: [label, " "] }), _jsx(Text, { color: color, children: bar }), _jsxs(Text, { color: color, children: [String(pct).padStart(4), "%"] }), resetLabel && _jsxs(Text, { color: "gray", children: [" \u21BB", resetLabel] })] }));
110
+ }
111
+ function formatResetIn(unixSeconds) {
112
+ const diff = unixSeconds - Date.now() / 1000;
113
+ if (diff <= 0)
114
+ return "now";
115
+ const d = Math.floor(diff / 86400);
116
+ const h = Math.floor((diff % 86400) / 3600);
117
+ const m = Math.floor((diff % 3600) / 60);
118
+ if (d > 0)
119
+ return `${d}d${h}h`;
120
+ if (h > 0)
121
+ return `${h}h${m}m`;
122
+ return `${m}m`;
97
123
  }
98
124
  // ─── Log row ──────────────────────────────────────────────────────────────────
99
125
  function LogRow({ log, selected }) {
@@ -109,16 +135,17 @@ function LogRow({ log, selected }) {
109
135
  : "gray";
110
136
  const bg = selected ? "white" : undefined;
111
137
  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;
138
+ // Per-request token stats
139
+ const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
140
+ const outputTok = log.outputTokens ?? 0;
141
+ const cacheHitPct = inputTok > 0 ? Math.round(((log.cacheReadTokens ?? 0) / inputTok) * 100) : null;
115
142
  const cacheColor = cacheHitPct === null ? undefined
116
143
  : cacheHitPct >= 70 ? "green"
117
144
  : cacheHitPct >= 30 ? "yellow"
118
145
  : "red";
119
146
  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
147
  ? _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] }))] }));
148
+ : _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
149
  }
123
150
  // ─── Detail panel ─────────────────────────────────────────────────────────────
124
151
  function DetailPanel({ log }) {
@@ -136,7 +163,7 @@ function DetailPanel({ log }) {
136
163
  : log.statusCode >= 500 ? "red"
137
164
  : log.statusCode >= 400 ? "yellow"
138
165
  : "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 }) }))] })] }));
166
+ 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
167
  }
141
168
  function Field({ label, value }) {
142
169
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
@@ -155,11 +182,20 @@ function CacheHealthBadge({ read, created, input }) {
155
182
  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
183
  }
157
184
  // ─── 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" })] }));
185
+ function CacheBreakdown({ read, created, input, output }) {
186
+ const totalInput = read + created + input;
187
+ const hitPct = totalInput > 0 ? (read / totalInput) * 100 : 0;
188
+ const color = totalInput === 0 ? "gray" : hitPct >= 70 ? "green" : hitPct >= 30 ? "yellow" : "red";
189
+ 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" })] }));
190
+ }
191
+ // ─── Token summary (aggregated totals) ──────────────────────────────────────
192
+ function TokenSummary({ cacheRead, cacheCreated, uncached, output }) {
193
+ const totalInput = cacheRead + cacheCreated + uncached;
194
+ const totalAll = totalInput + output;
195
+ if (totalAll === 0)
196
+ return null;
197
+ const hitPct = totalInput > 0 ? (cacheRead / totalInput) * 100 : 0;
198
+ 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
199
  }
164
200
  function fmtTok(n) {
165
201
  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.8",
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": {