@timo972/cc-router 0.10.0-rc.3 → 0.10.0-rc.4
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 +31 -0
- package/dist/cli/cmd-start.js +15 -4
- package/dist/daemon/launcher.js +35 -20
- package/dist/providers/anthropic/usage-refresher.js +24 -188
- package/dist/providers/openai/usage-fetch.js +77 -0
- package/dist/providers/openai/usage.js +116 -0
- package/dist/proxy/server.js +11 -0
- package/dist/proxy/usage-refresher.js +188 -0
- package/package.json +1 -1
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,24 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
42
55
|
|
|
43
56
|
### Fixed
|
|
44
57
|
|
|
58
|
+
- `cc-router stop` no longer terminates itself — or your editor sessions —
|
|
59
|
+
when it falls back to killing by port. The fallback listed every process
|
|
60
|
+
with a socket on the proxy port (`lsof -ti :port` reports both ends of
|
|
61
|
+
every connection), which included the stop CLI itself (its health-check
|
|
62
|
+
fetch leaves a keep-alive socket open) and any live Claude Code or Codex
|
|
63
|
+
session talking to the proxy. All of them received SIGTERM alongside the
|
|
64
|
+
daemon; the shell reported the stop command as `zsh: terminated`. The
|
|
65
|
+
listing now selects only the process LISTENING on the port, and killing by
|
|
66
|
+
port additionally never targets the process doing the killing.
|
|
67
|
+
- Service-mode `cc-router start` no longer reports "nothing is answering" for
|
|
68
|
+
a service that comes up moments later. The post-bootstrap health wait was
|
|
69
|
+
10 seconds, but a stop→start restart re-bootstraps a label whose process
|
|
70
|
+
exited moments earlier, and launchd throttles that spawn by up to ~10s
|
|
71
|
+
(default ThrottleInterval) before the daemon even begins booting — so the
|
|
72
|
+
window regularly closed right as throttled spawns landed, and the very
|
|
73
|
+
next `cc-router status` connected fine. The wait now outlasts the throttle
|
|
74
|
+
(30s), says what it is waiting for, and a genuine timeout points to
|
|
75
|
+
`cc-router status` before suggesting the logs.
|
|
45
76
|
- OpenAI activity rows carry the same columns as Claude ones. The OpenAI ingress
|
|
46
77
|
recorded a path but no method and no client, and the dashboard needs both
|
|
47
78
|
`method` and `path` to render the request — so those rows fell back to the
|
package/dist/cli/cmd-start.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
90
|
-
console.log(chalk.gray(`
|
|
91
|
-
console.log(chalk.gray(`
|
|
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
|
}
|
package/dist/daemon/launcher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
package/dist/proxy/server.js
CHANGED
|
@@ -25,6 +25,7 @@ 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";
|
|
28
29
|
import { canUseExtraUsage } from "../providers/anthropic/usage.js";
|
|
29
30
|
import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
|
|
30
31
|
import { persistProviderEnabledState } from "./provider-routing.js";
|
|
@@ -429,6 +430,15 @@ export async function startServer(opts = {}) {
|
|
|
429
430
|
startOpenAIRefreshLoop(openAIAccounts, persistOpenAIAccounts);
|
|
430
431
|
const usageRefresher = new AnthropicUsageRefresher(pool);
|
|
431
432
|
usageRefresher.start();
|
|
433
|
+
// Codex usage otherwise arrives only on response headers, so a freshly
|
|
434
|
+
// restarted daemon showed empty OpenAI bars until the first request
|
|
435
|
+
// happened to route there. Poll the usage endpoint the Codex CLI itself
|
|
436
|
+
// uses, so `cc-router status` is populated immediately — mirroring the
|
|
437
|
+
// Anthropic refresher above.
|
|
438
|
+
const openAIUsageRefresher = new OpenAIUsageRefresher(openAIPool, {
|
|
439
|
+
prepare: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
|
|
440
|
+
});
|
|
441
|
+
openAIUsageRefresher.start();
|
|
432
442
|
const app = express();
|
|
433
443
|
const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
|
|
434
444
|
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
@@ -1179,6 +1189,7 @@ export async function startServer(opts = {}) {
|
|
|
1179
1189
|
const shutdown = () => {
|
|
1180
1190
|
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
1181
1191
|
usageRefresher.stop();
|
|
1192
|
+
openAIUsageRefresher.stop();
|
|
1182
1193
|
saveAccounts(pool.getAll());
|
|
1183
1194
|
if (managesPidFile()) {
|
|
1184
1195
|
removePid();
|
|
@@ -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