@timo972/cc-router 0.7.0 → 0.9.0-rc.1

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,195 @@
1
+ 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
+ /**
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.
11
+ */
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;
27
+ 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
+ }
119
+ });
120
+ }
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
+ }
@@ -0,0 +1,217 @@
1
+ const ANTHROPIC_USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage";
2
+ const OAUTH_BETA_HEADER = "oauth-2025-04-20";
3
+ const DEFAULT_USAGE_TIMEOUT_MS = 5_000;
4
+ const USAGE_FIELDS = new Set([
5
+ "five_hour",
6
+ "seven_day",
7
+ "seven_day_sonnet",
8
+ "seven_day_opus",
9
+ "limits",
10
+ "extra_usage",
11
+ ]);
12
+ function isRecord(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function stringValue(value) {
16
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
17
+ }
18
+ function numberValue(value) {
19
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
20
+ }
21
+ function utilization(value) {
22
+ const number = numberValue(value);
23
+ if (number === undefined)
24
+ return 0;
25
+ return Math.max(0, Math.min(1, number / 100));
26
+ }
27
+ function resetAt(value) {
28
+ if (typeof value === "number" && Number.isFinite(value)) {
29
+ return Math.max(0, Math.floor(value > 10_000_000_000 ? value / 1_000 : value));
30
+ }
31
+ if (typeof value !== "string")
32
+ return 0;
33
+ const timestamp = Date.parse(value);
34
+ return Number.isFinite(timestamp) ? Math.floor(timestamp / 1_000) : 0;
35
+ }
36
+ function getFirst(record, keys) {
37
+ for (const key of keys) {
38
+ if (key in record)
39
+ return record[key];
40
+ }
41
+ return undefined;
42
+ }
43
+ function parseWindow(value) {
44
+ if (!isRecord(value))
45
+ return undefined;
46
+ return {
47
+ utilization: utilization(getFirst(value, ["utilization", "percentage", "percent"])),
48
+ resetAt: resetAt(getFirst(value, ["resets_at", "reset_at", "resetAt"])),
49
+ };
50
+ }
51
+ /** Normalize an upstream model id or display name into a stable routing family. */
52
+ export function normalizeModelFamily(modelIdOrName) {
53
+ const input = modelIdOrName?.trim().toLowerCase();
54
+ if (!input)
55
+ return undefined;
56
+ for (const family of ["fable", "sonnet", "opus", "haiku"]) {
57
+ if (input.includes(family))
58
+ return family;
59
+ }
60
+ const slug = input
61
+ .replace(/[^a-z0-9]+/g, "-")
62
+ .replace(/^-+|-+$/g, "")
63
+ .slice(0, 64)
64
+ .replace(/-+$/g, "");
65
+ return slug || undefined;
66
+ }
67
+ function modelDetails(limit) {
68
+ const model = isRecord(limit.model) ? limit.model : undefined;
69
+ const scope = isRecord(limit.scope) ? limit.scope : undefined;
70
+ const scopedModel = scope && isRecord(scope.model) ? scope.model : undefined;
71
+ const modelId = stringValue(getFirst(limit, ["model_id", "modelId"]))
72
+ ?? (model ? stringValue(getFirst(model, ["id", "model_id"])) : undefined)
73
+ ?? (scopedModel ? stringValue(getFirst(scopedModel, ["id", "model_id"])) : undefined);
74
+ const displayName = stringValue(getFirst(limit, ["model_name", "display_name", "displayName"]))
75
+ ?? (typeof limit.model === "string" ? stringValue(limit.model) : undefined)
76
+ ?? (model ? stringValue(getFirst(model, ["display_name", "displayName", "name"])) : undefined)
77
+ ?? (scopedModel ? stringValue(getFirst(scopedModel, ["display_name", "displayName", "name"])) : undefined)
78
+ ?? modelId;
79
+ const modelFamily = normalizeModelFamily(modelId ?? displayName);
80
+ if (!displayName || !modelFamily)
81
+ return undefined;
82
+ return { ...(modelId ? { modelId } : {}), displayName, modelFamily };
83
+ }
84
+ function parseModelLimit(value) {
85
+ if (!isRecord(value) || stringValue(value.kind) !== "weekly_scoped")
86
+ return undefined;
87
+ const model = modelDetails(value);
88
+ if (!model)
89
+ return undefined;
90
+ const active = getFirst(value, ["active", "is_active"]);
91
+ return {
92
+ kind: "weekly_scoped",
93
+ group: stringValue(value.group) ?? "weekly",
94
+ ...model,
95
+ utilization: utilization(getFirst(value, ["utilization", "percentage", "percent"])),
96
+ resetAt: resetAt(getFirst(value, ["resets_at", "reset_at", "resetAt"])),
97
+ active: typeof active === "boolean" ? active : true,
98
+ severity: stringValue(value.severity) ?? "",
99
+ };
100
+ }
101
+ function parseExtraUsage(value) {
102
+ if (!isRecord(value))
103
+ return undefined;
104
+ const enabled = getFirst(value, ["is_enabled", "enabled"]) === true;
105
+ const disabledReason = stringValue(getFirst(value, ["disabled_reason", "disabledReason", "reason"]));
106
+ const usedMinor = numberValue(getFirst(value, ["used_minor", "used_credits", "used"]));
107
+ const limitMinor = numberValue(getFirst(value, ["limit_minor", "monthly_limit", "spend_limit", "limit"]));
108
+ const explicitReached = getFirst(value, ["spend_limit_reached", "is_spend_limit_reached"]);
109
+ const spendLimitReached = explicitReached === true
110
+ || (usedMinor !== undefined && limitMinor !== undefined && limitMinor >= 0 && usedMinor >= limitMinor);
111
+ const parsed = { enabled, spendLimitReached };
112
+ if (disabledReason)
113
+ parsed.disabledReason = disabledReason;
114
+ const rawUtilization = getFirst(value, ["utilization", "percentage", "percent"]);
115
+ if (rawUtilization !== undefined)
116
+ parsed.utilization = utilization(rawUtilization);
117
+ const currency = stringValue(value.currency);
118
+ if (currency)
119
+ parsed.currency = currency;
120
+ if (usedMinor !== undefined)
121
+ parsed.usedMinor = usedMinor;
122
+ if (limitMinor !== undefined)
123
+ parsed.limitMinor = limitMinor;
124
+ return parsed;
125
+ }
126
+ function legacyModelLimit(family, value) {
127
+ const window = parseWindow(value);
128
+ if (!window)
129
+ return undefined;
130
+ return {
131
+ kind: "weekly_scoped",
132
+ group: "weekly",
133
+ modelFamily: family,
134
+ displayName: family,
135
+ ...window,
136
+ active: true,
137
+ severity: "",
138
+ };
139
+ }
140
+ /** Parse the OAuth usage endpoint without retaining its provider-specific payload. */
141
+ export function parseAnthropicUsage(value, fetchedAt) {
142
+ if (!isRecord(value) || !Object.keys(value).some((key) => USAGE_FIELDS.has(key)))
143
+ return null;
144
+ const limits = Array.isArray(value.limits) ? value.limits : undefined;
145
+ const modelLimits = limits
146
+ ? limits.map(parseModelLimit).filter((limit) => limit !== undefined)
147
+ : [
148
+ legacyModelLimit("sonnet", value.seven_day_sonnet),
149
+ legacyModelLimit("opus", value.seven_day_opus),
150
+ ].filter((limit) => limit !== undefined);
151
+ const snapshot = {
152
+ modelLimits,
153
+ fetchedAt,
154
+ fetchStatus: "fresh",
155
+ };
156
+ const fiveHour = parseWindow(value.five_hour);
157
+ const sevenDay = parseWindow(value.seven_day);
158
+ const extraUsage = parseExtraUsage(value.extra_usage);
159
+ if (fiveHour)
160
+ snapshot.fiveHour = fiveHour;
161
+ if (sevenDay)
162
+ snapshot.sevenDay = sevenDay;
163
+ if (extraUsage)
164
+ snapshot.extraUsage = extraUsage;
165
+ return snapshot;
166
+ }
167
+ export function canUseExtraUsage(state) {
168
+ return state?.enabled === true
169
+ && state.spendLimitReached === false
170
+ && !state.disabledReason;
171
+ }
172
+ /**
173
+ * Fetch and normalize the OAuth usage snapshot for one account.
174
+ *
175
+ * All expected provider failures are deliberately represented as compact
176
+ * results. In particular, this function neither reads nor exposes an error
177
+ * response body, which prevents tokens or provider diagnostics from leaking
178
+ * through refresh logs.
179
+ */
180
+ export async function fetchAnthropicUsage(account, options = {}) {
181
+ const request = options.fetch ?? globalThis.fetch;
182
+ const now = options.now ?? Date.now;
183
+ const timeoutMs = Math.max(0, options.timeoutMs ?? DEFAULT_USAGE_TIMEOUT_MS);
184
+ const controller = new AbortController();
185
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
186
+ try {
187
+ const response = await request(ANTHROPIC_USAGE_ENDPOINT, {
188
+ method: "GET",
189
+ headers: {
190
+ Authorization: `Bearer ${account.tokens.accessToken}`,
191
+ "anthropic-beta": OAUTH_BETA_HEADER,
192
+ },
193
+ signal: controller.signal,
194
+ });
195
+ if (!response.ok)
196
+ return { ok: false, reason: "http", status: response.status };
197
+ let body;
198
+ try {
199
+ body = await response.json();
200
+ }
201
+ catch {
202
+ return { ok: false, reason: "invalid_json" };
203
+ }
204
+ const snapshot = parseAnthropicUsage(body, now());
205
+ return snapshot
206
+ ? { ok: true, snapshot }
207
+ : { ok: false, reason: "invalid_schema" };
208
+ }
209
+ catch {
210
+ return controller.signal.aborted
211
+ ? { ok: false, reason: "timeout" }
212
+ : { ok: false, reason: "network" };
213
+ }
214
+ finally {
215
+ clearTimeout(timeout);
216
+ }
217
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Append an OpenAI subscription account to the running pool and persist it.
3
+ *
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.
9
+ */
10
+ export function addOpenAIAccountTransaction(options) {
11
+ const account = {
12
+ id: options.record.id,
13
+ provider: "openai_subscription",
14
+ accessToken: options.record.accessToken,
15
+ refreshToken: options.record.refreshToken,
16
+ expiresAt: options.record.expiresAt,
17
+ enabled: options.record.enabled !== false,
18
+ };
19
+ options.accounts.push(account);
20
+ try {
21
+ options.persist(options.accounts);
22
+ }
23
+ catch (err) {
24
+ const index = options.accounts.indexOf(account);
25
+ if (index >= 0)
26
+ options.accounts.splice(index, 1);
27
+ throw err;
28
+ }
29
+ return account;
30
+ }
@@ -42,3 +42,19 @@ 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. */
46
+ export function deleteOpenAIAccountTransaction(options) {
47
+ const account = options.accounts.find(candidate => candidate.id === options.id);
48
+ if (!account)
49
+ throw new Error(`Account "${options.id}" not found`);
50
+ if (options.accounts.length + options.otherAccountCount <= 1) {
51
+ throw new LastAccountDeletionError();
52
+ }
53
+ const prospective = options.accounts.filter(candidate => candidate !== account);
54
+ options.persist(prospective);
55
+ const index = options.accounts.indexOf(account);
56
+ if (index < 0)
57
+ throw new AccountDeletionConflictError(options.id);
58
+ options.accounts.splice(index, 1);
59
+ return account;
60
+ }
@@ -1,6 +1,6 @@
1
1
  import { acquireRequestRoute } from "./lease-lifecycle.js";
2
2
  import { normalizeSessionId } from "./session-router.js";
3
- import { EmptyPoolError } from "./token-pool.js";
3
+ import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
4
4
  const SESSION_HEADER = "x-claude-code-session-id";
5
5
  /** Extract exactly one native HTTP session header field without joined duplicates. */
6
6
  export function extractClaudeSessionId(request) {
@@ -21,12 +21,36 @@ export function extractClaudeSessionId(request) {
21
21
  return undefined;
22
22
  return normalizeSessionId(values[0]);
23
23
  }
24
+ const NO_ELIGIBLE_ACCOUNT_MESSAGE = "All configured accounts are unavailable for the requested model";
25
+ function sendNoEligibleAccountResponse(error, response, now) {
26
+ if (error.reason === "rate_limited") {
27
+ if (error.retryAtMs !== undefined) {
28
+ const retryAfterSeconds = Math.max(0, Math.ceil((error.retryAtMs - now) / 1_000));
29
+ response.setHeader("Retry-After", String(retryAfterSeconds));
30
+ }
31
+ response.status(429).json({
32
+ type: "error",
33
+ error: {
34
+ type: "rate_limit_error",
35
+ message: NO_ELIGIBLE_ACCOUNT_MESSAGE,
36
+ },
37
+ });
38
+ return;
39
+ }
40
+ response.status(503).json({
41
+ type: "error",
42
+ error: {
43
+ type: "service_unavailable",
44
+ message: NO_ELIGIBLE_ACCOUNT_MESSAGE,
45
+ },
46
+ });
47
+ }
24
48
  /** Acquire the production route and bind its lease to downstream termination. */
25
49
  export function createAnthropicRoutingMiddleware(options) {
26
50
  return (request, response, next) => {
27
51
  const routedRequest = request;
28
52
  try {
29
- const selected = acquireRequestRoute(extractClaudeSessionId(request), response, options.sessionRouter);
53
+ const selected = acquireRequestRoute(extractClaudeSessionId(request), response, options.sessionRouter, request._ccRouteContext);
30
54
  routedRequest._ccRoute = selected.route;
31
55
  routedRequest._ccReleaseLease = selected.release;
32
56
  routedRequest._ccAccount = selected.route.account;
@@ -37,6 +61,11 @@ export function createAnthropicRoutingMiddleware(options) {
37
61
  options.onEmptyPool(error, request, response);
38
62
  return;
39
63
  }
64
+ if (error instanceof NoEligibleAccountError) {
65
+ options.onNoEligibleAccount?.(error, request, response);
66
+ sendNoEligibleAccountResponse(error, response, (options.now ?? Date.now)());
67
+ return;
68
+ }
40
69
  next(error);
41
70
  }
42
71
  };