@timo972/cc-router 0.10.0-rc.3 → 0.10.0-rc.5

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.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,19 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
10
 
11
11
  ### Added
12
12
 
13
+ - OpenAI/Codex account usage is fetched proactively, so `cc-router status`
14
+ shows the 5h/weekly bars immediately after a restart — matching how
15
+ Anthropic accounts already behaved. Codex usage previously arrived only on
16
+ `x-codex-*` response headers, so a freshly restarted daemon rendered empty
17
+ OpenAI bars until the first request happened to route there. The daemon now
18
+ polls the usage endpoint the Codex CLI itself reads
19
+ (`GET chatgpt.com/backend-api/wham/usage`) on the same bounded scheduler the
20
+ Anthropic usage refresher uses (staggered startup, 5-minute cadence, failure
21
+ backoff, identity-owned application), feeding the JSON — the payload twin of
22
+ the response headers, parsed under the same trust rules — through the exact
23
+ merge the headers go through. A failed poll keeps whatever the account
24
+ already knew; an expired access token is refreshed before the first fetch
25
+ rather than 401-ing until traffic happens to fix it.
13
26
  - `cc-router accounts rename <id> <new-id>`. An account's id is the key its
14
27
  routing state hangs off — in-flight counters and sticky session bindings are
15
28
  both id-keyed — so on a running proxy the rename runs as a transaction
@@ -42,6 +55,34 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
42
55
 
43
56
  ### Fixed
44
57
 
58
+ - Anthropic activity rows show their cache rate and token counts. The proxy
59
+ captures usage by passively parsing the response body, but skipped any
60
+ compressed response — and since the proxy is byte-transparent, the client's
61
+ own `accept-encoding` makes upstream compress essentially every response, so
62
+ no `/messages` row ever carried token fields while Codex rows (whose relay
63
+ decompresses anyway) did. The capture now decompresses its own copy of the
64
+ stream (gzip/brotli/deflate) purely for parsing, stops paying for the
65
+ stream once both usage events have been seen, and remains strictly
66
+ best-effort: a corrupt or unsupported coding ends the capture, never the
67
+ response.
68
+ - `cc-router stop` no longer terminates itself — or your editor sessions —
69
+ when it falls back to killing by port. The fallback listed every process
70
+ with a socket on the proxy port (`lsof -ti :port` reports both ends of
71
+ every connection), which included the stop CLI itself (its health-check
72
+ fetch leaves a keep-alive socket open) and any live Claude Code or Codex
73
+ session talking to the proxy. All of them received SIGTERM alongside the
74
+ daemon; the shell reported the stop command as `zsh: terminated`. The
75
+ listing now selects only the process LISTENING on the port, and killing by
76
+ port additionally never targets the process doing the killing.
77
+ - Service-mode `cc-router start` no longer reports "nothing is answering" for
78
+ a service that comes up moments later. The post-bootstrap health wait was
79
+ 10 seconds, but a stop→start restart re-bootstraps a label whose process
80
+ exited moments earlier, and launchd throttles that spawn by up to ~10s
81
+ (default ThrottleInterval) before the daemon even begins booting — so the
82
+ window regularly closed right as throttled spawns landed, and the very
83
+ next `cc-router status` connected fine. The wait now outlasts the throttle
84
+ (30s), says what it is waiting for, and a genuine timeout points to
85
+ `cc-router status` before suggesting the logs.
45
86
  - OpenAI activity rows carry the same columns as Claude ones. The OpenAI ingress
46
87
  recorded a path but no method and no client, and the dashboard needs both
47
88
  `method` and `path` to render the request — so those rows fell back to the
@@ -7,6 +7,16 @@ import { checkForUpdate, performUpdate, PKG_NAME } from "../utils/self-update.js
7
7
  import { launchDaemon, waitForHealth } from "../daemon/launcher.js";
8
8
  import { installService } from "../daemon/service.js";
9
9
  import { getLocalIPs } from "../utils/network.js";
10
+ /**
11
+ * How long service-mode start waits for the proxy to answer after the
12
+ * LaunchAgent/systemd unit is loaded. This must outlast more than daemon
13
+ * startup: a stop→start restart re-bootstraps a label whose process exited
14
+ * moments earlier, and launchd throttles that spawn by up to ~10s (default
15
+ * ThrottleInterval) before the daemon even begins booting. The previous 10s
16
+ * budget ended right as throttled spawns typically landed, reporting
17
+ * "nothing answering" for a service that came up seconds later.
18
+ */
19
+ export const SERVICE_HEALTH_TIMEOUT_MS = 30_000;
10
20
  export function registerStart(program) {
11
21
  program
12
22
  .command("start")
@@ -82,13 +92,14 @@ export function registerStart(program) {
82
92
  // Installing the service is not the same as the proxy being up: if
83
93
  // launchd rejects the load, `installService` only warns. Verify, so a
84
94
  // failed start is not reported as a success.
85
- if (await waitForHealth(port, 10_000)) {
95
+ console.log(chalk.gray(` Waiting for the proxy on port ${port} (a quick restart can be throttled by the service manager)...`));
96
+ if (await waitForHealth(port, SERVICE_HEALTH_TIMEOUT_MS)) {
86
97
  console.log(chalk.green(`✓ CC-Router running on port ${port}`));
87
98
  }
88
99
  else {
89
- console.log(chalk.yellow(`\n⚠ Service configured, but nothing is answering on port ${port}.`));
90
- console.log(chalk.gray(` Check the logs: cc-router logs`));
91
- console.log(chalk.gray(` Then try again: cc-router start`));
100
+ console.log(chalk.yellow(`\n⚠ Service configured, but nothing answered on port ${port} within ${Math.round(SERVICE_HEALTH_TIMEOUT_MS / 1000)}s.`));
101
+ console.log(chalk.gray(` It may still come up — check: cc-router status`));
102
+ console.log(chalk.gray(` If it does not: cc-router logs, then try again: cc-router start`));
92
103
  process.exitCode = 1;
93
104
  return;
94
105
  }
@@ -158,32 +158,47 @@ export async function killPortAndWait(port, deps, timeoutMs = 5_000) {
158
158
  }
159
159
  const DEATH_POLL_MS = 200;
160
160
  const POST_KILL_GRACE_MS = 500;
161
- async function killByPort(port) {
161
+ /**
162
+ * PIDs that should die when the proxy on `port` is killed by port: the
163
+ * process LISTENING on it — never connected clients. A bare `lsof -ti :port`
164
+ * lists BOTH ends of every connection, which included the stop CLI itself
165
+ * (its health-check fetch leaves a keep-alive socket open) and any live
166
+ * Claude Code / Codex session talking to the proxy; `cc-router stop` then
167
+ * SIGTERMed all of them, itself included (`zsh: terminated`).
168
+ */
169
+ export async function listListeningPids(port) {
162
170
  const { execFile } = await import("child_process");
163
171
  const { promisify } = await import("util");
164
172
  const execFileAsync = promisify(execFile);
165
- const listPids = async (p) => {
166
- try {
167
- if (isWindows()) {
168
- const { stdout } = await execFileAsync("netstat", ["-ano"]);
169
- const match = stdout
170
- .split("\n")
171
- .find(line => line.includes(`:${p}`) && line.includes("LISTENING"));
172
- if (!match)
173
- return [];
174
- const pid = Number(match.trim().split(/\s+/).at(-1));
175
- return Number.isNaN(pid) ? [] : [pid];
176
- }
177
- const { stdout } = await execFileAsync("lsof", ["-ti", `:${p}`]);
178
- return stdout.trim().split("\n").filter(Boolean).map(Number).filter(n => !Number.isNaN(n));
179
- }
180
- catch {
181
- return [];
173
+ try {
174
+ if (isWindows()) {
175
+ const { stdout } = await execFileAsync("netstat", ["-ano"]);
176
+ const match = stdout
177
+ .split("\n")
178
+ .find(line => line.includes(`:${port}`) && line.includes("LISTENING"));
179
+ if (!match)
180
+ return [];
181
+ const pid = Number(match.trim().split(/\s+/).at(-1));
182
+ return Number.isNaN(pid) ? [] : [pid];
182
183
  }
183
- };
184
+ const { stdout } = await execFileAsync("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"]);
185
+ return stdout.trim().split("\n").filter(Boolean).map(Number)
186
+ .filter(n => !Number.isNaN(n))
187
+ // Belt and braces: whatever the listing says, killing by port must
188
+ // never target the process doing the killing.
189
+ .filter(pid => pid !== process.pid);
190
+ }
191
+ catch {
192
+ return [];
193
+ }
194
+ }
195
+ async function killByPort(port) {
196
+ const { execFile } = await import("child_process");
197
+ const { promisify } = await import("util");
198
+ const execFileAsync = promisify(execFile);
184
199
  try {
185
200
  return await killPortAndWait(port, {
186
- listPids,
201
+ listPids: listListeningPids,
187
202
  // Windows has no signals: taskkill /F is the only lever, so both the
188
203
  // graceful and forced step map onto it.
189
204
  kill: (pid, signal) => {
@@ -1,195 +1,31 @@
1
+ import { UsageRefresher } from "../../proxy/usage-refresher.js";
1
2
  import { fetchAnthropicUsage } from "./usage.js";
2
- const SUCCESS_REFRESH_MS = 5 * 60_000;
3
- const FAILURE_BACKOFF_MS = [60_000, 2 * 60_000, 5 * 60_000, 15 * 60_000];
4
- const DEFAULT_STARTUP_STAGGER_MS = 250;
5
- const MAX_CONCURRENT_REFRESHES = 2;
6
- const RECONCILE_INTERVAL_MS = 60_000;
7
3
  /**
8
- * Schedules bounded usage refreshes without becoming a source of routing or
9
- * health traffic. Account identity, rather than ID alone, owns both work and
10
- * result application so an account replacement cannot receive stale data.
4
+ * The Anthropic instantiation of the shared usage scheduler (see
5
+ * proxy/usage-refresher.ts for the timing/identity guarantees): fetches the
6
+ * OAuth usage endpoint and lands snapshots on `account.rateLimits.usage`,
7
+ * downgrading prior data to "stale" (or marking "unavailable") on failure.
11
8
  */
12
- export class AnthropicUsageRefresher {
13
- pool;
14
- fetchUsage;
15
- now;
16
- startupStaggerMs;
17
- maxConcurrent;
18
- timers = new Map();
19
- inFlight = new Map();
20
- resolvers = new Map();
21
- queued = new Set();
22
- failures = new Map();
23
- reconcileTimer;
24
- active = 0;
25
- started = false;
26
- stopped = false;
9
+ export class AnthropicUsageRefresher extends UsageRefresher {
27
10
  constructor(pool, options = {}) {
28
- this.pool = pool;
29
- this.fetchUsage = options.fetchUsage ?? fetchAnthropicUsage;
30
- this.now = options.now ?? Date.now;
31
- this.startupStaggerMs = Math.max(0, options.startupStaggerMs ?? DEFAULT_STARTUP_STAGGER_MS);
32
- this.maxConcurrent = Math.max(1, Math.floor(options.maxConcurrent ?? MAX_CONCURRENT_REFRESHES));
33
- }
34
- /** Begin staggered startup work for the accounts currently in the pool. */
35
- start() {
36
- if (this.started)
37
- return;
38
- this.started = true;
39
- this.stopped = false;
40
- this.reconcile(true);
41
- this.reconcileTimer = setInterval(() => this.reconcile(), RECONCILE_INTERVAL_MS);
42
- }
43
- /** Cancel scheduled work. In-flight calls may settle, but cannot reschedule. */
44
- stop() {
45
- if (!this.started)
46
- return;
47
- this.started = false;
48
- this.stopped = true;
49
- if (this.reconcileTimer)
50
- clearInterval(this.reconcileTimer);
51
- this.reconcileTimer = undefined;
52
- for (const timer of this.timers.values())
53
- clearTimeout(timer);
54
- this.timers.clear();
55
- for (const account of this.queued) {
56
- this.resolvers.get(account)?.({ ok: false, reason: "network" });
57
- this.resolvers.delete(account);
58
- this.inFlight.delete(account);
59
- }
60
- this.queued.clear();
61
- }
62
- /** Join or initiate the one usage refresh owned by this exact account object. */
63
- refreshNow(account) {
64
- const existing = this.inFlight.get(account);
65
- if (existing)
66
- return existing;
67
- if (this.stopped)
68
- return Promise.resolve({ ok: false, reason: "network" });
69
- const scheduled = this.timers.get(account);
70
- if (scheduled) {
71
- clearTimeout(scheduled);
72
- this.timers.delete(account);
73
- }
74
- let resolve;
75
- const pending = new Promise(done => { resolve = done; });
76
- this.inFlight.set(account, pending);
77
- this.resolvers.set(account, resolve);
78
- this.queued.add(account);
79
- this.runQueued();
80
- return pending;
81
- }
82
- /** Start a refresh after any request that was already in flight at call time. */
83
- refreshAfterCurrent(account) {
84
- const existing = this.inFlight.get(account);
85
- return existing
86
- ? existing.then(() => this.refreshNow(account))
87
- : this.refreshNow(account);
88
- }
89
- reconcile(startup = false) {
90
- if (!this.started)
91
- return;
92
- const accounts = this.pool.getAll();
93
- const current = new Set(accounts);
94
- for (const [account, timer] of this.timers) {
95
- if (!current.has(account)) {
96
- clearTimeout(timer);
97
- this.timers.delete(account);
98
- }
99
- }
100
- for (const account of [...this.queued]) {
101
- if (!current.has(account)) {
102
- this.queued.delete(account);
103
- this.resolvers.get(account)?.({ ok: false, reason: "network" });
104
- this.resolvers.delete(account);
105
- this.inFlight.delete(account);
106
- }
107
- }
108
- for (const account of this.failures.keys()) {
109
- if (!current.has(account))
110
- this.failures.delete(account);
111
- }
112
- accounts.forEach((account, index) => {
113
- if (!this.timers.has(account) && !this.inFlight.has(account) && !this.queued.has(account)) {
114
- if (startup)
115
- this.schedule(account, index * this.startupStaggerMs);
116
- else
117
- void this.refreshNow(account);
118
- }
11
+ const now = options.now ?? Date.now;
12
+ super(pool, {
13
+ fetchUsage: options.fetchUsage ?? fetchAnthropicUsage,
14
+ cancelledResult: () => ({ ok: false, reason: "network" }),
15
+ applyResult: (account, result) => {
16
+ if (result.ok) {
17
+ account.rateLimits = { ...account.rateLimits, usage: result.snapshot };
18
+ return;
19
+ }
20
+ const prior = account.rateLimits.usage;
21
+ const usage = prior
22
+ ? { ...prior, fetchStatus: "stale" }
23
+ : { modelLimits: [], fetchedAt: now(), fetchStatus: "unavailable" };
24
+ account.rateLimits = { ...account.rateLimits, usage };
25
+ },
26
+ ...(options.now !== undefined ? { now: options.now } : {}),
27
+ ...(options.startupStaggerMs !== undefined ? { startupStaggerMs: options.startupStaggerMs } : {}),
28
+ ...(options.maxConcurrent !== undefined ? { maxConcurrent: options.maxConcurrent } : {}),
119
29
  });
120
30
  }
121
- schedule(account, delayMs) {
122
- if (!this.started || this.pool.findById(account.id) !== account)
123
- return;
124
- const timer = setTimeout(() => {
125
- this.timers.delete(account);
126
- this.reconcile();
127
- void this.refreshNow(account);
128
- }, delayMs);
129
- this.timers.set(account, timer);
130
- }
131
- runQueued() {
132
- while (this.active < this.maxConcurrent) {
133
- const account = this.queued.values().next().value;
134
- if (!account)
135
- return;
136
- this.queued.delete(account);
137
- if (this.pool.findById(account.id) !== account) {
138
- this.resolvers.get(account)?.({ ok: false, reason: "network" });
139
- this.resolvers.delete(account);
140
- this.inFlight.delete(account);
141
- continue;
142
- }
143
- this.startRequest(account);
144
- }
145
- }
146
- startRequest(account) {
147
- this.active++;
148
- const operation = this.inFlight.get(account);
149
- const resolve = this.resolvers.get(account);
150
- if (!operation || !resolve) {
151
- this.active--;
152
- return;
153
- }
154
- void (async () => {
155
- let result;
156
- try {
157
- result = await this.fetchUsage(account);
158
- }
159
- catch {
160
- result = { ok: false, reason: "network" };
161
- }
162
- if (this.pool.findById(account.id) === account) {
163
- this.apply(account, result);
164
- if (this.started)
165
- this.schedule(account, this.nextDelay(account, result));
166
- }
167
- if (this.inFlight.get(account) === operation)
168
- this.inFlight.delete(account);
169
- this.resolvers.delete(account);
170
- this.active--;
171
- resolve(result);
172
- this.reconcile();
173
- this.runQueued();
174
- })();
175
- }
176
- apply(account, result) {
177
- if (result.ok) {
178
- this.failures.delete(account);
179
- account.rateLimits = { ...account.rateLimits, usage: result.snapshot };
180
- return;
181
- }
182
- this.failures.set(account, (this.failures.get(account) ?? 0) + 1);
183
- const prior = account.rateLimits.usage;
184
- const usage = prior
185
- ? { ...prior, fetchStatus: "stale" }
186
- : { modelLimits: [], fetchedAt: this.now(), fetchStatus: "unavailable" };
187
- account.rateLimits = { ...account.rateLimits, usage };
188
- }
189
- nextDelay(account, result) {
190
- if (result.ok)
191
- return SUCCESS_REFRESH_MS;
192
- const failures = this.failures.get(account) ?? 1;
193
- return FAILURE_BACKOFF_MS[Math.min(failures - 1, FAILURE_BACKOFF_MS.length - 1)];
194
- }
195
31
  }
@@ -0,0 +1,77 @@
1
+ import { applyCodexRateLimits } from "./account-state.js";
2
+ import { parseCodexUsagePayload } from "./usage.js";
3
+ import { UsageRefresher } from "../../proxy/usage-refresher.js";
4
+ /**
5
+ * The endpoint the Codex CLI's own backend client reads rate limits from
6
+ * (codex-rs/backend-client, ChatGPT path style). Answers a bearer-only GET
7
+ * with the JSON twin of the `x-codex-*` response headers — which is what
8
+ * makes usage visible without burning a request through /codex/responses.
9
+ */
10
+ export const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
11
+ export async function fetchCodexUsage(account, options = {}) {
12
+ const fetchImpl = options.fetch ?? globalThis.fetch;
13
+ const now = options.now ?? Date.now;
14
+ let response;
15
+ try {
16
+ response = await fetchImpl(CODEX_USAGE_ENDPOINT, {
17
+ headers: { authorization: `Bearer ${account.accessToken}` },
18
+ signal: AbortSignal.timeout(10_000),
19
+ });
20
+ }
21
+ catch {
22
+ return { ok: false, reason: "network" };
23
+ }
24
+ if (response.status === 401 || response.status === 403)
25
+ return { ok: false, reason: "auth" };
26
+ if (!response.ok)
27
+ return { ok: false, reason: "http" };
28
+ let body;
29
+ try {
30
+ body = await response.json();
31
+ }
32
+ catch {
33
+ return { ok: false, reason: "malformed" };
34
+ }
35
+ const update = parseCodexUsagePayload(body, now());
36
+ return update ? { ok: true, update } : { ok: false, reason: "malformed" };
37
+ }
38
+ /**
39
+ * The OpenAI/Codex instantiation of the shared usage scheduler: polls the
40
+ * usage endpoint and feeds the result through `applyCodexRateLimits` — the
41
+ * same merge the response headers go through — so the dashboard shows an
42
+ * account's windows right after startup instead of only after its first
43
+ * routed request. A failed fetch keeps whatever the account already knew;
44
+ * unlike the Anthropic snapshot there is no per-fetch staleness field, and
45
+ * header-fed data must not be erased by a flaky poll.
46
+ */
47
+ export class OpenAIUsageRefresher extends UsageRefresher {
48
+ constructor(pool, options = {}) {
49
+ const now = options.now ?? Date.now;
50
+ const fetchUsage = options.fetchUsage ?? ((account) => fetchCodexUsage(account, { now }));
51
+ const prepare = options.prepare;
52
+ super(pool, {
53
+ fetchUsage: async (account) => {
54
+ if (prepare) {
55
+ let ready;
56
+ try {
57
+ ready = await prepare(account);
58
+ }
59
+ catch {
60
+ ready = false;
61
+ }
62
+ if (!ready)
63
+ return { ok: false, reason: "auth" };
64
+ }
65
+ return fetchUsage(account);
66
+ },
67
+ cancelledResult: () => ({ ok: false, reason: "network" }),
68
+ applyResult: (account, result) => {
69
+ if (result.ok)
70
+ applyCodexRateLimits(account, result.update, now());
71
+ },
72
+ ...(options.now !== undefined ? { now: options.now } : {}),
73
+ ...(options.startupStaggerMs !== undefined ? { startupStaggerMs: options.startupStaggerMs } : {}),
74
+ ...(options.maxConcurrent !== undefined ? { maxConcurrent: options.maxConcurrent } : {}),
75
+ });
76
+ }
77
+ }
@@ -132,6 +132,122 @@ export function parseCodexRateLimits(headers, nowMs) {
132
132
  const credits = parseCredits(headers);
133
133
  return { buckets, ...(credits ? { credits } : {}) };
134
134
  }
135
+ // ─── Usage endpoint payload (GET chatgpt.com/backend-api/wham/usage) ─────────
136
+ // The JSON twin of the `x-codex-*` header family: the same window fields
137
+ // (used_percent / limit_window_seconds / reset_after_seconds / reset_at)
138
+ // under `rate_limit.{primary,secondary}_window`, named buckets under
139
+ // `additional_rate_limits`, and a `credits` object. Parsed with the same
140
+ // trust rules as the headers — the payload is upstream-controlled data
141
+ // either way.
142
+ function usageNumber(value) {
143
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
144
+ }
145
+ function usageWindowFromJson(value, nowMs) {
146
+ if (typeof value !== "object" || value === null)
147
+ return undefined;
148
+ const window = value;
149
+ const percent = usageNumber(window["used_percent"]);
150
+ if (percent === undefined)
151
+ return undefined;
152
+ const nowSec = Math.floor(nowMs / 1000);
153
+ const horizonSec = nowSec + MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC;
154
+ let resetAt = 0;
155
+ const absolute = usageNumber(window["reset_at"]);
156
+ if (absolute !== undefined && absolute > 0) {
157
+ const seconds = absolute > MS_TIMESTAMP_THRESHOLD ? Math.floor(absolute / 1000) : Math.floor(absolute);
158
+ if (seconds > nowSec && seconds <= horizonSec)
159
+ resetAt = seconds;
160
+ }
161
+ if (resetAt === 0) {
162
+ // Same fallback the header parser applies: an unusable absolute reset
163
+ // must not discard a usable relative one (see parseResetAtSeconds).
164
+ const relative = usageNumber(window["reset_after_seconds"]);
165
+ if (relative !== undefined && relative > 0) {
166
+ const candidate = nowSec + Math.floor(relative);
167
+ if (candidate <= horizonSec)
168
+ resetAt = candidate;
169
+ }
170
+ }
171
+ const windowSeconds = usageNumber(window["limit_window_seconds"]);
172
+ return {
173
+ utilization: Math.max(0, Math.min(1, percent / 100)),
174
+ resetAt,
175
+ windowMinutes: windowSeconds !== undefined && windowSeconds > 0
176
+ ? Math.min(Math.floor(windowSeconds / 60), MAX_TRUSTED_WINDOW_MINUTES)
177
+ : 0,
178
+ };
179
+ }
180
+ function usageBucketFromJson(limitId, limitName, rateLimit, nowMs) {
181
+ if (typeof rateLimit !== "object" || rateLimit === null)
182
+ return undefined;
183
+ const windows = rateLimit;
184
+ const primary = usageWindowFromJson(windows["primary_window"], nowMs);
185
+ const secondary = usageWindowFromJson(windows["secondary_window"], nowMs);
186
+ if (!primary && !secondary)
187
+ return undefined;
188
+ return {
189
+ limitId,
190
+ ...(limitName ? { limitName: limitName.slice(0, 64) } : {}),
191
+ ...(primary ? { primary } : {}),
192
+ ...(secondary ? { secondary } : {}),
193
+ };
194
+ }
195
+ /**
196
+ * Parse a usage-endpoint response body into the same update shape the
197
+ * response-header parser produces, so both feed `applyCodexRateLimits`
198
+ * identically. Returns null when the body is not a usage payload at all
199
+ * (e.g. an error object) — as opposed to a payload with no windows, which
200
+ * is a valid empty update.
201
+ */
202
+ export function parseCodexUsagePayload(value, nowMs) {
203
+ if (typeof value !== "object" || value === null)
204
+ return null;
205
+ const payload = value;
206
+ if (typeof payload["rate_limit"] !== "object" || payload["rate_limit"] === null)
207
+ return null;
208
+ const buckets = [];
209
+ const defaultBucket = usageBucketFromJson(DEFAULT_CODEX_LIMIT_ID, undefined, payload["rate_limit"], nowMs);
210
+ if (defaultBucket)
211
+ buckets.push(defaultBucket);
212
+ const additional = payload["additional_rate_limits"];
213
+ if (Array.isArray(additional)) {
214
+ for (const entry of additional) {
215
+ if (typeof entry !== "object" || entry === null)
216
+ continue;
217
+ const record = entry;
218
+ const limitName = typeof record["limit_name"] === "string" ? record["limit_name"].trim() : undefined;
219
+ const idSource = typeof record["metered_feature"] === "string" && record["metered_feature"].trim()
220
+ ? record["metered_feature"]
221
+ : limitName;
222
+ if (!idSource)
223
+ continue;
224
+ const limitId = normalizeCodexLimitId(idSource);
225
+ if (!/^[a-z0-9_]{1,64}$/.test(limitId) || limitId === DEFAULT_CODEX_LIMIT_ID)
226
+ continue;
227
+ const bucket = usageBucketFromJson(limitId, limitName, record["rate_limit"], nowMs);
228
+ if (bucket)
229
+ buckets.push(bucket);
230
+ }
231
+ }
232
+ let credits;
233
+ const rawCredits = payload["credits"];
234
+ if (typeof rawCredits === "object" && rawCredits !== null) {
235
+ const record = rawCredits;
236
+ const hasCredits = typeof record["has_credits"] === "boolean" ? record["has_credits"] : undefined;
237
+ const unlimited = typeof record["unlimited"] === "boolean" ? record["unlimited"] : undefined;
238
+ if (hasCredits !== undefined || unlimited !== undefined) {
239
+ const balance = typeof record["balance"] === "string"
240
+ ? record["balance"].replace(CONTROL_CHAR_PATTERN, "").trim()
241
+ : undefined;
242
+ credits = {
243
+ hasCredits: hasCredits === true,
244
+ unlimited: unlimited === true,
245
+ ...(balance ? { balance: balance.slice(0, 32) } : {}),
246
+ };
247
+ }
248
+ }
249
+ return { buckets, ...(credits ? { credits } : {}) };
250
+ }
135
251
  export function resolveActiveLimit(headers) {
136
252
  const raw = headerString(headers, "x-codex-active-limit")?.trim();
137
253
  if (!raw)
@@ -25,6 +25,8 @@ import chalk from "chalk";
25
25
  import { SessionRouter } from "./session-router.js";
26
26
  import { createAnthropicProxy } from "./anthropic-proxy.js";
27
27
  import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
28
+ import { OpenAIUsageRefresher } from "../providers/openai/usage-fetch.js";
29
+ import { createAnthropicUsageCapture } from "./usage-capture.js";
28
30
  import { canUseExtraUsage } from "../providers/anthropic/usage.js";
29
31
  import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
30
32
  import { persistProviderEnabledState } from "./provider-routing.js";
@@ -429,6 +431,15 @@ export async function startServer(opts = {}) {
429
431
  startOpenAIRefreshLoop(openAIAccounts, persistOpenAIAccounts);
430
432
  const usageRefresher = new AnthropicUsageRefresher(pool);
431
433
  usageRefresher.start();
434
+ // Codex usage otherwise arrives only on response headers, so a freshly
435
+ // restarted daemon showed empty OpenAI bars until the first request
436
+ // happened to route there. Poll the usage endpoint the Codex CLI itself
437
+ // uses, so `cc-router status` is populated immediately — mirroring the
438
+ // Anthropic refresher above.
439
+ const openAIUsageRefresher = new OpenAIUsageRefresher(openAIPool, {
440
+ prepare: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
441
+ });
442
+ openAIUsageRefresher.start();
432
443
  const app = express();
433
444
  const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
434
445
  // ─── Proxy auth middleware ─────────────────────────────────────────────────
@@ -1035,8 +1046,11 @@ export async function startServer(opts = {}) {
1035
1046
  // message_start → input_tokens, cache_read/creation_input_tokens
1036
1047
  // message_delta → output_tokens
1037
1048
  // Non-streaming JSON carries all fields in a single usage object.
1038
- // We use incremental line parsing (not buffering) so we can capture
1039
- // both events without holding the full stream in memory.
1049
+ // The proxy is byte-transparent and the client's accept-encoding makes
1050
+ // upstream compress, so the capture decompresses its own copy of the
1051
+ // stream (see usage-capture.ts) — previously compressed responses were
1052
+ // skipped, which in practice was EVERY response: no cache rate or
1053
+ // token counts ever appeared on Anthropic activity rows.
1040
1054
  const contentType = String(proxyRes.headers["content-type"] ?? "");
1041
1055
  const encoding = String(proxyRes.headers["content-encoding"] ?? "");
1042
1056
  const isCompressed = /gzip|br|deflate/.test(encoding);
@@ -1044,51 +1058,17 @@ export async function startServer(opts = {}) {
1044
1058
  entry.streamLifecycle = streamTracker.state;
1045
1059
  streamTracker.attach(proxyRes, response);
1046
1060
  proxyRes.on("data", (chunk) => streamTracker.observeChunk(chunk));
1047
- if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
1048
- const isSSE = contentType.includes("text/event-stream");
1049
- if (isSSE) {
1050
- let lineBuf = "";
1051
- let gotInput = false;
1052
- let gotOutput = false;
1053
- proxyRes.on("data", (chunk) => {
1054
- if (gotInput && gotOutput)
1055
- return;
1056
- lineBuf += chunk.toString("utf8");
1057
- const lines = lineBuf.split("\n");
1058
- lineBuf = lines.pop() ?? ""; // keep incomplete last line
1059
- for (const line of lines) {
1060
- if (!line.startsWith("data: "))
1061
- continue;
1062
- try {
1063
- const evt = JSON.parse(line.slice(6));
1064
- if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
1065
- applyInputUsage(entry, evt.message.usage);
1066
- gotInput = true;
1067
- }
1068
- if (!gotOutput && evt.type === "message_delta" && evt.usage) {
1069
- applyOutputUsage(entry, evt.usage);
1070
- gotOutput = true;
1071
- }
1072
- }
1073
- catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
1074
- }
1075
- });
1076
- }
1077
- else {
1078
- // Non-streaming JSON: buffer full body then parse once
1079
- let buf = "";
1080
- proxyRes.on("data", (chunk) => { buf += chunk.toString("utf8"); });
1081
- proxyRes.on("end", () => {
1082
- try {
1083
- const body = JSON.parse(buf);
1084
- if (body.usage) {
1085
- applyInputUsage(entry, body.usage);
1086
- applyOutputUsage(entry, body.usage);
1087
- }
1088
- }
1089
- catch { /* ignore */ }
1090
- });
1091
- }
1061
+ const usageCapture = createAnthropicUsageCapture({
1062
+ contentType,
1063
+ contentEncoding: encoding,
1064
+ // Mutates the already-logged entry in place; the dashboard picks the
1065
+ // values up on its next poll.
1066
+ onInputUsage: (usage) => applyInputUsage(entry, usage),
1067
+ onOutputUsage: (usage) => applyOutputUsage(entry, usage),
1068
+ });
1069
+ if (usageCapture) {
1070
+ proxyRes.on("data", (chunk) => usageCapture.write(chunk));
1071
+ proxyRes.on("end", () => usageCapture.end());
1092
1072
  }
1093
1073
  },
1094
1074
  error: (err, _req, res) => {
@@ -1179,6 +1159,7 @@ export async function startServer(opts = {}) {
1179
1159
  const shutdown = () => {
1180
1160
  console.log(chalk.yellow("\nShutting down — saving tokens..."));
1181
1161
  usageRefresher.stop();
1162
+ openAIUsageRefresher.stop();
1182
1163
  saveAccounts(pool.getAll());
1183
1164
  if (managesPidFile()) {
1184
1165
  removePid();
@@ -0,0 +1,119 @@
1
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
2
+ /** Non-streaming bodies are buffered for one parse at end-of-stream; a body
3
+ * past this size stops being buffered (usage is best-effort diagnostics —
4
+ * unbounded buffering of a pathological body is not worth it). */
5
+ const MAX_JSON_BODY_BYTES = 20 * 1024 * 1024;
6
+ function createDecoder(contentEncoding) {
7
+ const encoding = contentEncoding.trim().toLowerCase();
8
+ // `identity` and absent mean the bytes are already readable.
9
+ if (encoding === "" || encoding === "identity")
10
+ return null;
11
+ if (encoding === "gzip" || encoding === "x-gzip")
12
+ return createGunzip();
13
+ if (encoding === "br")
14
+ return createBrotliDecompress();
15
+ if (encoding === "deflate")
16
+ return createInflate();
17
+ // Multi-codings ("gzip, br") and unknown codings are not worth chasing.
18
+ return undefined;
19
+ }
20
+ export function createAnthropicUsageCapture(options) {
21
+ const isSSE = options.contentType.includes("text/event-stream");
22
+ const isJSON = options.contentType.includes("application/json");
23
+ if (!isSSE && !isJSON)
24
+ return null;
25
+ const decoder = createDecoder(options.contentEncoding);
26
+ if (decoder === undefined)
27
+ return null;
28
+ let dead = false;
29
+ const die = () => {
30
+ if (dead)
31
+ return;
32
+ dead = true;
33
+ decoder?.destroy();
34
+ };
35
+ // ── SSE: incremental line parsing, stop once both events were seen ────────
36
+ let lineBuf = "";
37
+ let gotInput = false;
38
+ let gotOutput = false;
39
+ const parseSSEChunk = (text) => {
40
+ lineBuf += text;
41
+ const lines = lineBuf.split("\n");
42
+ lineBuf = lines.pop() ?? ""; // keep incomplete last line
43
+ for (const line of lines) {
44
+ if (!line.startsWith("data: "))
45
+ continue;
46
+ try {
47
+ const evt = JSON.parse(line.slice(6));
48
+ if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
49
+ options.onInputUsage(evt.message.usage);
50
+ gotInput = true;
51
+ }
52
+ if (!gotOutput && evt.type === "message_delta" && evt.usage) {
53
+ options.onOutputUsage(evt.usage);
54
+ gotOutput = true;
55
+ }
56
+ // Everything of interest has been seen — stop paying for the rest of
57
+ // the stream (and free the decompressor's zlib state).
58
+ if (gotInput && gotOutput)
59
+ die();
60
+ }
61
+ catch { /* partial JSON across chunk boundary — next chunk completes it */ }
62
+ }
63
+ };
64
+ // ── Non-streaming JSON: buffer, parse once at end ─────────────────────────
65
+ let jsonBuf = "";
66
+ const parseJSONBody = () => {
67
+ try {
68
+ const body = JSON.parse(jsonBuf);
69
+ if (body.usage) {
70
+ options.onInputUsage(body.usage);
71
+ options.onOutputUsage(body.usage);
72
+ }
73
+ }
74
+ catch { /* not a JSON body after all */ }
75
+ };
76
+ const consume = (chunk) => {
77
+ if (dead)
78
+ return;
79
+ if (isSSE) {
80
+ parseSSEChunk(chunk.toString("utf8"));
81
+ return;
82
+ }
83
+ if (jsonBuf.length + chunk.length > MAX_JSON_BODY_BYTES) {
84
+ die();
85
+ return;
86
+ }
87
+ jsonBuf += chunk.toString("utf8");
88
+ };
89
+ const finish = () => {
90
+ if (dead)
91
+ return;
92
+ if (isJSON)
93
+ parseJSONBody();
94
+ dead = true;
95
+ };
96
+ if (!decoder) {
97
+ return {
98
+ write: (chunk) => consume(chunk),
99
+ end: () => finish(),
100
+ };
101
+ }
102
+ decoder.on("data", (chunk) => consume(chunk));
103
+ decoder.on("end", () => finish());
104
+ // Corrupt or truncated compressed data — the capture just stops; the
105
+ // proxied bytes were never ours to begin with.
106
+ decoder.on("error", () => die());
107
+ return {
108
+ write: (chunk) => {
109
+ if (dead)
110
+ return;
111
+ decoder.write(chunk);
112
+ },
113
+ end: () => {
114
+ if (dead)
115
+ return;
116
+ decoder.end();
117
+ },
118
+ };
119
+ }
@@ -0,0 +1,188 @@
1
+ const SUCCESS_REFRESH_MS = 5 * 60_000;
2
+ const FAILURE_BACKOFF_MS = [60_000, 2 * 60_000, 5 * 60_000, 15 * 60_000];
3
+ const DEFAULT_STARTUP_STAGGER_MS = 250;
4
+ const MAX_CONCURRENT_REFRESHES = 2;
5
+ const RECONCILE_INTERVAL_MS = 60_000;
6
+ /**
7
+ * Schedules bounded usage refreshes without becoming a source of routing or
8
+ * health traffic. Account identity, rather than ID alone, owns both work and
9
+ * result application so an account replacement cannot receive stale data.
10
+ * Provider-agnostic: what "usage" is and how a result lands on the account
11
+ * comes in through the hooks (see AnthropicUsageRefresher /
12
+ * OpenAIUsageRefresher).
13
+ */
14
+ export class UsageRefresher {
15
+ pool;
16
+ hooks;
17
+ now;
18
+ startupStaggerMs;
19
+ maxConcurrent;
20
+ timers = new Map();
21
+ inFlight = new Map();
22
+ resolvers = new Map();
23
+ queued = new Set();
24
+ failures = new Map();
25
+ reconcileTimer;
26
+ active = 0;
27
+ started = false;
28
+ stopped = false;
29
+ constructor(pool, hooks) {
30
+ this.pool = pool;
31
+ this.hooks = hooks;
32
+ this.now = hooks.now ?? Date.now;
33
+ this.startupStaggerMs = Math.max(0, hooks.startupStaggerMs ?? DEFAULT_STARTUP_STAGGER_MS);
34
+ this.maxConcurrent = Math.max(1, Math.floor(hooks.maxConcurrent ?? MAX_CONCURRENT_REFRESHES));
35
+ }
36
+ /** Begin staggered startup work for the accounts currently in the pool. */
37
+ start() {
38
+ if (this.started)
39
+ return;
40
+ this.started = true;
41
+ this.stopped = false;
42
+ this.reconcile(true);
43
+ this.reconcileTimer = setInterval(() => this.reconcile(), RECONCILE_INTERVAL_MS);
44
+ }
45
+ /** Cancel scheduled work. In-flight calls may settle, but cannot reschedule. */
46
+ stop() {
47
+ if (!this.started)
48
+ return;
49
+ this.started = false;
50
+ this.stopped = true;
51
+ if (this.reconcileTimer)
52
+ clearInterval(this.reconcileTimer);
53
+ this.reconcileTimer = undefined;
54
+ for (const timer of this.timers.values())
55
+ clearTimeout(timer);
56
+ this.timers.clear();
57
+ for (const account of this.queued) {
58
+ this.resolvers.get(account)?.(this.hooks.cancelledResult());
59
+ this.resolvers.delete(account);
60
+ this.inFlight.delete(account);
61
+ }
62
+ this.queued.clear();
63
+ }
64
+ /** Join or initiate the one usage refresh owned by this exact account object. */
65
+ refreshNow(account) {
66
+ const existing = this.inFlight.get(account);
67
+ if (existing)
68
+ return existing;
69
+ if (this.stopped)
70
+ return Promise.resolve(this.hooks.cancelledResult());
71
+ const scheduled = this.timers.get(account);
72
+ if (scheduled) {
73
+ clearTimeout(scheduled);
74
+ this.timers.delete(account);
75
+ }
76
+ let resolve;
77
+ const pending = new Promise(done => { resolve = done; });
78
+ this.inFlight.set(account, pending);
79
+ this.resolvers.set(account, resolve);
80
+ this.queued.add(account);
81
+ this.runQueued();
82
+ return pending;
83
+ }
84
+ /** Start a refresh after any request that was already in flight at call time. */
85
+ refreshAfterCurrent(account) {
86
+ const existing = this.inFlight.get(account);
87
+ return existing
88
+ ? existing.then(() => this.refreshNow(account))
89
+ : this.refreshNow(account);
90
+ }
91
+ reconcile(startup = false) {
92
+ if (!this.started)
93
+ return;
94
+ const accounts = this.pool.getAll();
95
+ const current = new Set(accounts);
96
+ for (const [account, timer] of this.timers) {
97
+ if (!current.has(account)) {
98
+ clearTimeout(timer);
99
+ this.timers.delete(account);
100
+ }
101
+ }
102
+ for (const account of [...this.queued]) {
103
+ if (!current.has(account)) {
104
+ this.queued.delete(account);
105
+ this.resolvers.get(account)?.(this.hooks.cancelledResult());
106
+ this.resolvers.delete(account);
107
+ this.inFlight.delete(account);
108
+ }
109
+ }
110
+ for (const account of this.failures.keys()) {
111
+ if (!current.has(account))
112
+ this.failures.delete(account);
113
+ }
114
+ accounts.forEach((account, index) => {
115
+ if (!this.timers.has(account) && !this.inFlight.has(account) && !this.queued.has(account)) {
116
+ if (startup)
117
+ this.schedule(account, index * this.startupStaggerMs);
118
+ else
119
+ void this.refreshNow(account);
120
+ }
121
+ });
122
+ }
123
+ schedule(account, delayMs) {
124
+ if (!this.started || this.pool.findById(account.id) !== account)
125
+ return;
126
+ const timer = setTimeout(() => {
127
+ this.timers.delete(account);
128
+ this.reconcile();
129
+ void this.refreshNow(account);
130
+ }, delayMs);
131
+ this.timers.set(account, timer);
132
+ }
133
+ runQueued() {
134
+ while (this.active < this.maxConcurrent) {
135
+ const account = this.queued.values().next().value;
136
+ if (!account)
137
+ return;
138
+ this.queued.delete(account);
139
+ if (this.pool.findById(account.id) !== account) {
140
+ this.resolvers.get(account)?.(this.hooks.cancelledResult());
141
+ this.resolvers.delete(account);
142
+ this.inFlight.delete(account);
143
+ continue;
144
+ }
145
+ this.startRequest(account);
146
+ }
147
+ }
148
+ startRequest(account) {
149
+ this.active++;
150
+ const operation = this.inFlight.get(account);
151
+ const resolve = this.resolvers.get(account);
152
+ if (!operation || !resolve) {
153
+ this.active--;
154
+ return;
155
+ }
156
+ void (async () => {
157
+ let result;
158
+ try {
159
+ result = await this.hooks.fetchUsage(account);
160
+ }
161
+ catch {
162
+ result = this.hooks.cancelledResult();
163
+ }
164
+ if (this.pool.findById(account.id) === account) {
165
+ if (result.ok)
166
+ this.failures.delete(account);
167
+ else
168
+ this.failures.set(account, (this.failures.get(account) ?? 0) + 1);
169
+ this.hooks.applyResult(account, result);
170
+ if (this.started)
171
+ this.schedule(account, this.nextDelay(account, result));
172
+ }
173
+ if (this.inFlight.get(account) === operation)
174
+ this.inFlight.delete(account);
175
+ this.resolvers.delete(account);
176
+ this.active--;
177
+ resolve(result);
178
+ this.reconcile();
179
+ this.runQueued();
180
+ })();
181
+ }
182
+ nextDelay(account, result) {
183
+ if (result.ok)
184
+ return SUCCESS_REFRESH_MS;
185
+ const failures = this.failures.get(account) ?? 1;
186
+ return FAILURE_BACKOFF_MS[Math.min(failures - 1, FAILURE_BACKOFF_MS.length - 1)];
187
+ }
188
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.0-rc.3",
3
+ "version": "0.10.0-rc.5",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {