ai-cc-router 0.1.7 → 0.1.9

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
  }
@@ -24,6 +24,34 @@ function applyOutputUsage(entry, usage) {
24
24
  entry.outputTokens = usage["output_tokens"] ?? 0;
25
25
  stats.totalOutputTokens += entry.outputTokens;
26
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
+ }
27
55
  export async function startServer(opts = {}) {
28
56
  const port = opts.port ?? PROXY_PORT;
29
57
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
@@ -182,6 +210,10 @@ export async function startServer(opts = {}) {
182
210
  account.busy = true;
183
211
  setTimeout(() => { account.busy = false; }, 30_000);
184
212
  }
213
+ // ── Capture rate limit utilization from response headers ────────────
214
+ const rl = extractRateLimits(proxyRes.headers);
215
+ if (rl)
216
+ account.rateLimits = rl;
185
217
  const entry = pendingLog;
186
218
  stats.addLog(entry);
187
219
  // ── Capture token usage from Anthropic response body ─────────────────
@@ -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
+ };
@@ -3,6 +3,11 @@ import { useState, useEffect } from "react";
3
3
  import { Box, Text, useInput, useApp } from "ink";
4
4
  const POLL_INTERVAL_MS = 2_000;
5
5
  const LOG_VISIBLE = 20;
6
+ const EMPTY_RL = {
7
+ status: "unknown", fiveHourUtil: 0, fiveHourReset: 0,
8
+ sevenDayUtil: 0, sevenDayReset: 0, claim: "", plan: "",
9
+ requestsLimit: 0, lastUpdated: 0,
10
+ };
6
11
  // ─── Dashboard component ──────────────────────────────────────────────────────
7
12
  export function Dashboard({ port }) {
8
13
  const { exit } = useApp();
@@ -79,21 +84,47 @@ function LiveDashboard({ data, port, lastUpdate }) {
79
84
  });
80
85
  const selectedLog = logs[selectedIndex] ?? null;
81
86
  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, { 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
87
+ 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 ?? 0 })] }), _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
88
  ? _jsx(Text, { color: "gray", children: " No activity yet" })
84
89
  : 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
90
  }
86
- // ─── Account row ──────────────────────────────────────────────────────────────
91
+ // ─── Account row (two-line: status + utilization bars) ───────────────────────
87
92
  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";
93
+ const rl = a.rateLimits ?? EMPTY_RL;
94
+ const isLimited = rl.status === "rate_limited";
95
+ const dot = isLimited ? "⊘" : a.busy ? "" : a.healthy ? "" : "";
96
+ const dotColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
97
+ const statusLabel = isLimited ? "LIMITED" : a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
98
+ const statusColor = isLimited ? "red" : a.busy ? "yellow" : a.healthy ? "green" : "red";
92
99
  const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
93
100
  const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
94
101
  : a.expiresInMs < 30 * 60 * 1000 ? "yellow"
95
102
  : "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) })] }));
103
+ const planTag = rl.plan ? ` [${rl.plan}]` : "";
104
+ 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" })] }))] }));
105
+ }
106
+ // ─── Utilization bar ─────────────────────────────────────────────────────────
107
+ function UtilBar({ label, util, resetTs, isActive }) {
108
+ const pct = Math.round(util * 100);
109
+ const BAR_W = 12;
110
+ const filled = Math.round(util * BAR_W);
111
+ const bar = "█".repeat(Math.min(filled, BAR_W)) + "░".repeat(Math.max(BAR_W - filled, 0));
112
+ const color = pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green";
113
+ const resetLabel = resetTs > 0 ? formatResetIn(resetTs) : "";
114
+ 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] })] }));
115
+ }
116
+ function formatResetIn(unixSeconds) {
117
+ const diff = unixSeconds - Date.now() / 1000;
118
+ if (diff <= 0)
119
+ return "now";
120
+ const d = Math.floor(diff / 86400);
121
+ const h = Math.floor((diff % 86400) / 3600);
122
+ const m = Math.floor((diff % 3600) / 60);
123
+ if (d > 0)
124
+ return `${d}d${h}h`;
125
+ if (h > 0)
126
+ return `${h}h${m}m`;
127
+ return `${m}m`;
97
128
  }
98
129
  // ─── Log row ──────────────────────────────────────────────────────────────────
99
130
  function LogRow({ log, selected }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {