ai-cc-router 0.5.0 → 0.5.2

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.
@@ -70,9 +70,17 @@ async function jsonOutput(port) {
70
70
  * Launches the Ink dashboard and handles "re-launch" intents.
71
71
  *
72
72
  * The dashboard cannot run inquirer prompts while Ink owns stdin, so when
73
- * the user presses `n` to add an account, Ink unmounts and this loop runs
74
- * the OAuth flow synchronously. Once tokens are obtained and POSTed to the
73
+ * the user presses `n` to add an account, Ink unmounts **completely** first.
74
+ * Only after `waitUntilExit()` resolves and stdin is restored from raw mode
75
+ * — does the OAuth flow run. Once tokens are obtained and POSTed to the
75
76
  * server, the dashboard is re-rendered and polling resumes.
77
+ *
78
+ * IMPORTANT: The previous design resolved the outer promise from inside
79
+ * `onIntent` (before Ink unmounted), then raced with `waitUntilExit`. That
80
+ * caused inquirer to see a half-released stdin and force-close itself.
81
+ * The fix: `onIntent` writes to a mutable variable; `waitUntilExit()`
82
+ * is the ONLY thing that resolves the await; stdin is explicitly restored
83
+ * before inquirer runs.
76
84
  */
77
85
  async function dashboardLoop(port) {
78
86
  // Dynamic imports keep these heavy deps out of the cold-start path
@@ -83,23 +91,36 @@ async function dashboardLoop(port) {
83
91
  ]);
84
92
  while (true) {
85
93
  const target = resolveStatusTarget(port);
86
- const intent = await new Promise((resolve) => {
87
- const onIntent = (i) => resolve(i);
88
- const instance = render(createElement(Dashboard, {
89
- port,
90
- baseUrl: target.baseUrl,
91
- authToken: target.authToken,
92
- onIntent,
93
- }),
94
- // Let Ink handle Ctrl+C it calls exit() which cleanly unmounts
95
- { exitOnCtrlC: true });
96
- // If Ink exits on its own (Ctrl+C or `q`) without firing onIntent, treat as quit.
97
- instance.waitUntilExit().then(() => resolve("quit"));
98
- });
99
- if (intent === "quit")
94
+ // `pendingIntent` is set by the Dashboard component via `onIntent`;
95
+ // it defaults to "quit" so Ctrl+C (exitOnCtrlC) does the right thing
96
+ // without the Dashboard ever firing onIntent.
97
+ let pendingIntent = "quit";
98
+ const instance = render(createElement(Dashboard, {
99
+ port,
100
+ baseUrl: target.baseUrl,
101
+ authToken: target.authToken,
102
+ onIntent: (i) => { pendingIntent = i; },
103
+ }), { exitOnCtrlC: true });
104
+ // Block until Ink has FULLY unmounted and released stdin.
105
+ // The Dashboard's keyboard handler calls exit() for both `q` and `n`;
106
+ // Ctrl+C also triggers exit via exitOnCtrlC.
107
+ await instance.waitUntilExit();
108
+ // Yield the event loop so any of Ink's pending stdin cleanup tasks
109
+ // (listeners detach, raw-mode restore) run before inquirer grabs stdin.
110
+ // Without this, inquirer can see a half-released stdin and throw
111
+ // "User force closed the prompt".
112
+ await new Promise(resolve => setImmediate(resolve));
113
+ // Ink leaves stdin in raw mode. Restore it before running inquirer or
114
+ // exiting, otherwise the terminal may remain in a broken state.
115
+ if (process.stdin.isTTY) {
116
+ process.stdin.setRawMode(false);
117
+ }
118
+ // Ink may have paused stdin — resume it so inquirer can read input.
119
+ process.stdin.resume();
120
+ if (pendingIntent === "quit")
100
121
  return;
101
- // Intent: addAccount — unmount and run the OAuth flow, then POST the
102
- // resulting tokens to the server we're connected to (local or remote).
122
+ // Intent: addAccount — run the OAuth flow, then POST the resulting
123
+ // tokens to the server we're connected to (local or remote).
103
124
  console.log();
104
125
  console.log(chalk.cyan("→ Adding a new account..."));
105
126
  console.log();
@@ -83,6 +83,12 @@ export async function startServer(opts = {}) {
83
83
  logError(a.id, 0, msg);
84
84
  stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "error", details: msg });
85
85
  };
86
+ // Surface rate-limit recovery in the dashboard so users see the account
87
+ // rejoin the rotation instead of wondering why it stayed red.
88
+ pool.onCooldownExpired = (a) => {
89
+ const msg = `${a.id} cooldown expired — rate limit cleared`;
90
+ stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "route", details: msg });
91
+ };
86
92
  startRefreshLoop(accounts);
87
93
  const app = express();
88
94
  // ─── Proxy auth middleware ─────────────────────────────────────────────────
@@ -115,6 +121,9 @@ export async function startServer(opts = {}) {
115
121
  }
116
122
  // ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
117
123
  app.get("/cc-router/health", (_req, res) => {
124
+ // Sweep expired cooldowns on each poll so the dashboard reflects recovery
125
+ // even during idle periods when no /v1 request would trigger getNext().
126
+ pool.sweepExpiredCooldowns();
118
127
  res.json({
119
128
  status: pool.getHealthy().length > 0 ? "ok" : "degraded",
120
129
  mode,
@@ -12,6 +12,72 @@ function earliestReset(a) {
12
12
  return Math.min(r.fiveHourReset, r.sevenDayReset);
13
13
  return r.fiveHourReset || r.sevenDayReset || Infinity;
14
14
  }
15
+ /**
16
+ * Returns the reset timestamp (seconds) that must pass before the account
17
+ * stops being rate_limited. Prefers the `claim` window (the one Anthropic
18
+ * said was actually limiting); falls back to the earliest non-zero reset.
19
+ * Returns 0 when no reset is known.
20
+ */
21
+ function limitingReset(a) {
22
+ const r = a.rateLimits;
23
+ if (r.claim === "five_hour" && r.fiveHourReset)
24
+ return r.fiveHourReset;
25
+ if (r.claim === "seven_day" && r.sevenDayReset)
26
+ return r.sevenDayReset;
27
+ const earliest = earliestReset(a);
28
+ return Number.isFinite(earliest) ? earliest : 0;
29
+ }
30
+ /**
31
+ * Roll over any rate-limit window whose reset timestamp has passed.
32
+ *
33
+ * `rateLimits` is a snapshot of Anthropic's response headers — utilization
34
+ * values only refresh when a new response arrives. That creates a stuck
35
+ * state in two scenarios:
36
+ *
37
+ * 1. `status: "rate_limited"` — the pool refuses to route to the account,
38
+ * so no new response ever updates the status.
39
+ * 2. `fiveHourUtil` / `sevenDayUtil` at or above the user cap — `overUserCap`
40
+ * evicts the account from rotation, so the util stays stale at its last
41
+ * recorded value instead of dropping to ~0 when Anthropic's window resets.
42
+ *
43
+ * This sweep resolves both: when `now >= reset` for a window, the util is
44
+ * zeroed and the window's reset timestamp is cleared. When the limiting
45
+ * window expires, `status` flips back to `"allowed"`. The callback fires
46
+ * once per recovery so the dashboard can surface it.
47
+ */
48
+ function clearExpiredCooldown(a, onExpired) {
49
+ const nowSec = Math.floor(Date.now() / 1000);
50
+ const r = a.rateLimits;
51
+ let changed = false;
52
+ let recovered = false;
53
+ if (r.fiveHourReset > 0 && nowSec >= r.fiveHourReset) {
54
+ r.fiveHourUtil = 0;
55
+ r.fiveHourReset = 0;
56
+ changed = true;
57
+ }
58
+ if (r.sevenDayReset > 0 && nowSec >= r.sevenDayReset) {
59
+ r.sevenDayUtil = 0;
60
+ r.sevenDayReset = 0;
61
+ changed = true;
62
+ }
63
+ // If the account was rate_limited and its claimed window just reset,
64
+ // return it to rotation. If we can't tell which window was limiting
65
+ // (empty claim) but all known windows have rolled over, clear the flag.
66
+ if (r.status === "rate_limited") {
67
+ const stillBlocked = (r.claim === "five_hour" && r.fiveHourReset > 0) ||
68
+ (r.claim === "seven_day" && r.sevenDayReset > 0) ||
69
+ (r.claim === "" && (r.fiveHourReset > 0 || r.sevenDayReset > 0));
70
+ if (!stillBlocked) {
71
+ r.status = "allowed";
72
+ recovered = true;
73
+ changed = true;
74
+ }
75
+ }
76
+ if (changed)
77
+ r.lastUpdated = Date.now();
78
+ if (recovered && onExpired)
79
+ onExpired(a);
80
+ }
15
81
  /** True when the account's user-defined caps have been reached. */
16
82
  function overUserCap(a) {
17
83
  return (a.rateLimits.fiveHourUtil * 100 >= a.sessionLimitPercent ||
@@ -52,6 +118,10 @@ export class TokenPool {
52
118
  if (this.accounts.length === 0) {
53
119
  throw new EmptyPoolError("token pool is empty — add an account first");
54
120
  }
121
+ // Sweep expired cooldowns before filtering — otherwise rate_limited accounts
122
+ // whose reset window has passed would never re-enter rotation.
123
+ for (const a of this.accounts)
124
+ clearExpiredCooldown(a, this.onCooldownExpired);
55
125
  const available = this.accounts.filter(a => a.healthy &&
56
126
  !a.busy &&
57
127
  a.rateLimits.status !== "rate_limited" &&
@@ -84,6 +154,18 @@ export class TokenPool {
84
154
  /** Optional listener fired when a request is routed to a capped account
85
155
  * because every account in the pool was over its user-configured cap. */
86
156
  onCapBypass;
157
+ /** Optional listener fired when a rate-limited account's cooldown expires
158
+ * and it is automatically returned to the rotation. */
159
+ onCooldownExpired;
160
+ /**
161
+ * Sweep the pool for accounts whose rate_limited cooldown has passed and
162
+ * clear the flag in place. Intended for periodic calls from the dashboard
163
+ * poll loop so the UI reflects recovery without waiting for a new request.
164
+ */
165
+ sweepExpiredCooldowns() {
166
+ for (const a of this.accounts)
167
+ clearExpiredCooldown(a, this.onCooldownExpired);
168
+ }
87
169
  getAll() {
88
170
  return this.accounts;
89
171
  }
@@ -202,8 +202,11 @@ function LiveDashboard({ data, port, lastUpdate, api, onIntent, }) {
202
202
  return;
203
203
  }
204
204
  // ── Normal view mode ────────────────────────────────────────────────
205
+ // Always call exit() so Ink fully unmounts and releases stdin.
206
+ // The outer dashboardLoop reads `pendingIntent` after waitUntilExit().
205
207
  if (input === "q") {
206
- onIntent ? onIntent("quit") : exit();
208
+ onIntent?.("quit");
209
+ exit();
207
210
  return;
208
211
  }
209
212
  if (key.escape) {
@@ -211,7 +214,8 @@ function LiveDashboard({ data, port, lastUpdate, api, onIntent, }) {
211
214
  setFocus("logs");
212
215
  return;
213
216
  }
214
- onIntent ? onIntent("quit") : exit();
217
+ onIntent?.("quit");
218
+ exit();
215
219
  return;
216
220
  }
217
221
  if (key.tab) {
@@ -258,7 +262,9 @@ function LiveDashboard({ data, port, lastUpdate, api, onIntent, }) {
258
262
  return;
259
263
  }
260
264
  }
261
- // n = add account — works regardless of focus
265
+ // n = add account — works regardless of focus.
266
+ // Requires an onIntent handler because the outer loop runs the OAuth
267
+ // flow after Ink unmounts; if none is wired, this key is a no-op.
262
268
  if (input === "n") {
263
269
  if (onIntent) {
264
270
  onIntent("addAccount");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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": {