@timo972/cc-router 0.9.0 → 0.10.0-rc.0

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.
@@ -0,0 +1,160 @@
1
+ // Codex rate-limit reporting is bucket-based: a default account-level "codex"
2
+ // bucket plus optional named metered buckets, each published as an
3
+ // `x-<limit>-{primary,secondary}-*` header family. Discovery mirrors the
4
+ // Codex CLI (codex-rs/codex-api/src/rate_limits.rs): scan header names for
5
+ // the `-primary-used-percent` suffix.
6
+ export const DEFAULT_CODEX_LIMIT_ID = "codex";
7
+ const USED_PERCENT_SUFFIX = "-primary-used-percent";
8
+ const MS_TIMESTAMP_THRESHOLD = 100_000_000_000;
9
+ /** Strips ASCII control characters (including ESC) from upstream-controlled header text. */
10
+ const CONTROL_CHAR_PATTERN = /[\x00-\x1f\x7f]/g;
11
+ /**
12
+ * Reject a parsed reset timestamp (or clamp a window length) implausibly far
13
+ * in the future rather than trust it verbatim. Matches the same 8-day trust
14
+ * horizon already enforced downstream by `MAX_TRUSTED_RATE_LIMIT_RESET_MS`
15
+ * (token-pool.ts) and `MAX_RATE_LIMIT_COOLDOWN_MS` (lease-lifecycle.ts) — a
16
+ * malformed or malicious header must not be able to park an account in a
17
+ * cooldown/exhausted state for months. A reset beyond the horizon is treated
18
+ * as unknown (0), the same sentinel already used for "no usable reset value".
19
+ */
20
+ export const MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC = 8 * 24 * 60 * 60;
21
+ const MAX_TRUSTED_WINDOW_MINUTES = MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC / 60;
22
+ export function createEmptyCodexRateLimits() {
23
+ return { status: "ok", buckets: new Map(), lastUpdated: 0 };
24
+ }
25
+ export function normalizeCodexLimitId(name) {
26
+ return name.trim().toLowerCase().replace(/-/g, "_");
27
+ }
28
+ export function headersToRecord(headers) {
29
+ const record = {};
30
+ headers.forEach((value, key) => {
31
+ record[key.toLowerCase()] = value;
32
+ });
33
+ return record;
34
+ }
35
+ function headerString(headers, name) {
36
+ const value = headers[name];
37
+ if (typeof value === "string")
38
+ return value;
39
+ if (typeof value === "number" && Number.isFinite(value))
40
+ return String(value);
41
+ return undefined;
42
+ }
43
+ function headerNumber(headers, name) {
44
+ const raw = headerString(headers, name)?.trim();
45
+ if (raw === undefined || raw === "")
46
+ return undefined;
47
+ const parsed = Number(raw);
48
+ return Number.isFinite(parsed) ? parsed : undefined;
49
+ }
50
+ function headerBool(headers, name) {
51
+ const raw = headerString(headers, name)?.trim().toLowerCase();
52
+ if (raw === "true" || raw === "1")
53
+ return true;
54
+ if (raw === "false" || raw === "0")
55
+ return false;
56
+ return undefined;
57
+ }
58
+ function parseResetAtSeconds(headers, prefix, kind, nowMs) {
59
+ const nowSec = Math.floor(nowMs / 1000);
60
+ const horizonSec = nowSec + MAX_TRUSTED_RATE_LIMIT_HORIZON_SEC;
61
+ const absolute = headerNumber(headers, `${prefix}-${kind}-reset-at`);
62
+ if (absolute !== undefined && absolute > 0) {
63
+ const seconds = absolute > MS_TIMESTAMP_THRESHOLD ? Math.floor(absolute / 1000) : Math.floor(absolute);
64
+ if (seconds > nowSec && seconds <= horizonSec)
65
+ return seconds;
66
+ // The absolute value is unusable (already past, or implausibly far out), so
67
+ // fall through to the relative header rather than reporting "no reset
68
+ // known". Giving up here would leave an exhausted window with no
69
+ // trustworthy expiry — an indefinite block the pool can only clear via the
70
+ // multi-hour staleness sweep — while the upstream had just advertised a
71
+ // reset seconds or minutes away.
72
+ }
73
+ const relative = headerNumber(headers, `${prefix}-${kind}-reset-after-seconds`);
74
+ if (relative !== undefined && relative > 0) {
75
+ const candidate = nowSec + Math.floor(relative);
76
+ return candidate <= horizonSec ? candidate : 0;
77
+ }
78
+ return 0;
79
+ }
80
+ function parseWindow(headers, prefix, kind, nowMs) {
81
+ const percent = headerNumber(headers, `${prefix}-${kind}-used-percent`);
82
+ if (percent === undefined)
83
+ return undefined;
84
+ const windowMinutes = headerNumber(headers, `${prefix}-${kind}-window-minutes`);
85
+ return {
86
+ utilization: Math.max(0, Math.min(1, percent / 100)),
87
+ resetAt: parseResetAtSeconds(headers, prefix, kind, nowMs),
88
+ windowMinutes: windowMinutes !== undefined && windowMinutes > 0
89
+ ? Math.min(Math.floor(windowMinutes), MAX_TRUSTED_WINDOW_MINUTES)
90
+ : 0,
91
+ };
92
+ }
93
+ function parseCredits(headers) {
94
+ const hasCredits = headerBool(headers, "x-codex-credits-has-credits");
95
+ const unlimited = headerBool(headers, "x-codex-credits-unlimited");
96
+ if (hasCredits === undefined && unlimited === undefined)
97
+ return undefined;
98
+ const balance = headerString(headers, "x-codex-credits-balance")
99
+ ?.replace(CONTROL_CHAR_PATTERN, "")
100
+ .trim();
101
+ return {
102
+ hasCredits: hasCredits === true,
103
+ unlimited: unlimited === true,
104
+ ...(balance ? { balance: balance.slice(0, 32) } : {}),
105
+ };
106
+ }
107
+ export function parseCodexRateLimits(headers, nowMs) {
108
+ const limitIds = new Set([DEFAULT_CODEX_LIMIT_ID]);
109
+ for (const name of Object.keys(headers)) {
110
+ const lower = name.toLowerCase();
111
+ if (!lower.startsWith("x-") || !lower.endsWith(USED_PERCENT_SUFFIX))
112
+ continue;
113
+ const limitId = normalizeCodexLimitId(lower.slice(2, -USED_PERCENT_SUFFIX.length));
114
+ if (limitId)
115
+ limitIds.add(limitId);
116
+ }
117
+ const buckets = [];
118
+ for (const limitId of limitIds) {
119
+ const prefix = `x-${limitId.replace(/_/g, "-")}`;
120
+ const primary = parseWindow(headers, prefix, "primary", nowMs);
121
+ const secondary = parseWindow(headers, prefix, "secondary", nowMs);
122
+ if (!primary && !secondary)
123
+ continue;
124
+ const limitName = headerString(headers, `${prefix}-limit-name`)?.trim();
125
+ buckets.push({
126
+ limitId,
127
+ ...(limitName ? { limitName: limitName.slice(0, 64) } : {}),
128
+ ...(primary ? { primary } : {}),
129
+ ...(secondary ? { secondary } : {}),
130
+ });
131
+ }
132
+ const credits = parseCredits(headers);
133
+ return { buckets, ...(credits ? { credits } : {}) };
134
+ }
135
+ export function resolveActiveLimit(headers) {
136
+ const raw = headerString(headers, "x-codex-active-limit")?.trim();
137
+ if (!raw)
138
+ return undefined;
139
+ const normalized = normalizeCodexLimitId(raw);
140
+ return /^[a-z0-9_]{1,64}$/.test(normalized) ? normalized : undefined;
141
+ }
142
+ export function decodeOpenAIPlan(accessToken) {
143
+ try {
144
+ const [, payload] = accessToken.split(".");
145
+ if (!payload)
146
+ return undefined;
147
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
148
+ const auth = claims["https://api.openai.com/auth"];
149
+ if (typeof auth !== "object" || auth === null)
150
+ return undefined;
151
+ const plan = auth.chatgpt_plan_type;
152
+ if (typeof plan !== "string")
153
+ return undefined;
154
+ const normalized = plan.trim().toLowerCase().slice(0, 32);
155
+ return /^[a-z0-9_-]+$/.test(normalized) ? normalized : undefined;
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ }
@@ -1,21 +1,24 @@
1
+ import { createOpenAIAccount } from "../providers/openai/account-state.js";
1
2
  /**
2
3
  * Append an OpenAI subscription account to the running pool and persist it.
3
4
  *
4
- * The account is pushed IN PLACE so the picker (`createOpenAIAccountPicker`) and
5
- * the refresh loop — both of which close over the same array reference — pick it
6
- * up immediately without a restart, mirroring `TokenPool.addAccount` for Claude.
7
- * If persistence throws, the in-place append is rolled back so the live routing
8
- * state never diverges from disk.
5
+ * The account is pushed IN PLACE (as a full runtime `OpenAIAccount`, so the
6
+ * `OpenAITokenPool`/`SessionRouter` — which close over the same array
7
+ * reference — can route to it immediately without a restart, mirroring
8
+ * `TokenPool.addAccount` for Claude. If persistence throws, the in-place
9
+ * append is rolled back so the live routing state never diverges from disk.
9
10
  */
10
11
  export function addOpenAIAccountTransaction(options) {
11
- const account = {
12
+ const account = createOpenAIAccount({
12
13
  id: options.record.id,
13
14
  provider: "openai_subscription",
14
15
  accessToken: options.record.accessToken,
15
16
  refreshToken: options.record.refreshToken,
16
17
  expiresAt: options.record.expiresAt,
17
18
  enabled: options.record.enabled !== false,
18
- };
19
+ sessionLimitPercent: options.record.sessionLimitPercent,
20
+ weeklyLimitPercent: options.record.weeklyLimitPercent,
21
+ });
19
22
  options.accounts.push(account);
20
23
  try {
21
24
  options.persist(options.accounts);
@@ -42,7 +42,7 @@ export async function deleteAnthropicAccountTransaction(options) {
42
42
  releaseReservation();
43
43
  }
44
44
  }
45
- /** Persist prospective OpenAI state before removing it from the live picker array. */
45
+ /** Persist prospective OpenAI state before removing it from the live pool array. */
46
46
  export function deleteOpenAIAccountTransaction(options) {
47
47
  const account = options.accounts.find(candidate => candidate.id === options.id);
48
48
  if (!account)
@@ -56,5 +56,9 @@ export function deleteOpenAIAccountTransaction(options) {
56
56
  if (index < 0)
57
57
  throw new AccountDeletionConflictError(options.id);
58
58
  options.accounts.splice(index, 1);
59
+ // Only after the account is out of the live array — mirrors the Anthropic
60
+ // transaction, which removes pool state and then invalidates bindings.
61
+ options.forgetAccount?.(account);
62
+ options.invalidateAccount?.(options.id);
59
63
  return account;
60
64
  }
@@ -0,0 +1,64 @@
1
+ import { clampPercent } from "./types.js";
2
+ /**
3
+ * Validate a `PATCH /cc-router/accounts/:id` request body. Shared between the
4
+ * Anthropic and OpenAI account branches in server.ts so both providers reject
5
+ * a malformed `enabled`/`sessionLimitPercent`/`weeklyLimitPercent` the same
6
+ * way, before either pool is even consulted.
7
+ */
8
+ export function validateAccountPatchBody(body) {
9
+ const patch = {};
10
+ if (body.enabled !== undefined) {
11
+ if (typeof body.enabled !== "boolean") {
12
+ return { ok: false, error: "enabled must be boolean" };
13
+ }
14
+ patch.enabled = body.enabled;
15
+ }
16
+ for (const key of ["sessionLimitPercent", "weeklyLimitPercent"]) {
17
+ const v = body[key];
18
+ if (v === undefined)
19
+ continue;
20
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 100) {
21
+ return { ok: false, error: `${key} must be a number between 0 and 100` };
22
+ }
23
+ patch[key] = v;
24
+ }
25
+ return { ok: true, patch };
26
+ }
27
+ /**
28
+ * Apply an already-validated patch to a runtime OpenAI account in place and
29
+ * persist it — the OpenAI counterpart of `TokenPool.updateAccount` for
30
+ * Claude, which `OpenAITokenPool` has no equivalent of.
31
+ *
32
+ * Returns the updated account, or `undefined` if no account with that id
33
+ * exists in `accounts` (the caller should respond 404). On persistence
34
+ * failure the in-memory mutation is rolled back before the error is
35
+ * re-thrown, mirroring `addOpenAIAccountTransaction`'s rollback contract.
36
+ */
37
+ export function applyOpenAIAccountPatch(options) {
38
+ const account = options.accounts.find(a => a.id === options.id);
39
+ if (!account)
40
+ return undefined;
41
+ const prev = {
42
+ enabled: account.enabled,
43
+ sessionLimitPercent: account.sessionLimitPercent,
44
+ weeklyLimitPercent: account.weeklyLimitPercent,
45
+ };
46
+ if (options.patch.enabled !== undefined)
47
+ account.enabled = options.patch.enabled;
48
+ if (options.patch.sessionLimitPercent !== undefined) {
49
+ account.sessionLimitPercent = clampPercent(options.patch.sessionLimitPercent);
50
+ }
51
+ if (options.patch.weeklyLimitPercent !== undefined) {
52
+ account.weeklyLimitPercent = clampPercent(options.patch.weeklyLimitPercent);
53
+ }
54
+ try {
55
+ options.persist(options.accounts);
56
+ }
57
+ catch (err) {
58
+ account.enabled = prev.enabled;
59
+ account.sessionLimitPercent = prev.sessionLimitPercent;
60
+ account.weeklyLimitPercent = prev.weeklyLimitPercent;
61
+ throw err;
62
+ }
63
+ return account;
64
+ }
@@ -0,0 +1,19 @@
1
+ export class EmptyPoolError extends Error {
2
+ constructor(message) {
3
+ super(message);
4
+ this.name = "EmptyPoolError";
5
+ }
6
+ }
7
+ export class NoEligibleAccountError extends Error {
8
+ reason;
9
+ retryAtMs;
10
+ blockedAccounts;
11
+ constructor(reason, blockedAccounts, retryAtMs) {
12
+ super("no account is currently eligible for routing");
13
+ this.name = "NoEligibleAccountError";
14
+ this.reason = reason;
15
+ this.blockedAccounts = blockedAccounts;
16
+ if (retryAtMs !== undefined)
17
+ this.retryAtMs = retryAtMs;
18
+ }
19
+ }
@@ -22,7 +22,7 @@ export function extractClaudeSessionId(request) {
22
22
  return normalizeSessionId(values[0]);
23
23
  }
24
24
  const NO_ELIGIBLE_ACCOUNT_MESSAGE = "All configured accounts are unavailable for the requested model";
25
- function sendNoEligibleAccountResponse(error, response, now) {
25
+ export function sendAnthropicNoEligibleResponse(error, response, now) {
26
26
  if (error.reason === "rate_limited") {
27
27
  if (error.retryAtMs !== undefined) {
28
28
  const retryAfterSeconds = Math.max(0, Math.ceil((error.retryAtMs - now) / 1_000));
@@ -63,7 +63,7 @@ export function createAnthropicRoutingMiddleware(options) {
63
63
  }
64
64
  if (error instanceof NoEligibleAccountError) {
65
65
  options.onNoEligibleAccount?.(error, request, response);
66
- sendNoEligibleAccountResponse(error, response, (options.now ?? Date.now)());
66
+ sendAnthropicNoEligibleResponse(error, response, (options.now ?? Date.now)());
67
67
  return;
68
68
  }
69
69
  next(error);
@@ -49,12 +49,12 @@ function header(headers, name) {
49
49
  }
50
50
  return undefined;
51
51
  }
52
- function futureExpiry(expiryMs, nowMs) {
52
+ export function futureExpiry(expiryMs, nowMs) {
53
53
  if (!Number.isFinite(expiryMs) || expiryMs <= nowMs)
54
54
  return undefined;
55
55
  return expiryMs - nowMs <= MAX_RATE_LIMIT_COOLDOWN_MS ? expiryMs : undefined;
56
56
  }
57
- function retryAfterExpiry(value, nowMs) {
57
+ export function retryAfterExpiry(value, nowMs) {
58
58
  if (typeof value !== "string" && typeof value !== "number")
59
59
  return undefined;
60
60
  if (typeof value === "string" && value.trim().length === 0)
@@ -69,14 +69,16 @@ function retryAfterExpiry(value, nowMs) {
69
69
  return undefined;
70
70
  return futureExpiry(Date.parse(value), nowMs);
71
71
  }
72
- function resetHeaderExpiry(value, nowMs) {
72
+ export function resetHeaderExpiry(value, nowMs) {
73
73
  if (typeof value !== "string" && typeof value !== "number")
74
74
  return undefined;
75
75
  if (typeof value === "string" && value.trim().length === 0)
76
76
  return undefined;
77
77
  const numeric = Number(value);
78
78
  if (Number.isFinite(numeric)) {
79
- const milliseconds = numeric < 10_000_000_000 ? numeric * 1_000 : numeric;
79
+ // Threshold matches usage.ts's MS_TIMESTAMP_THRESHOLD, so a seconds- vs.
80
+ // milliseconds-epoch reset value is classified the same way across providers.
81
+ const milliseconds = numeric < 100_000_000_000 ? numeric * 1_000 : numeric;
80
82
  return futureExpiry(milliseconds, nowMs);
81
83
  }
82
84
  if (typeof value !== "string")