@timo972/cc-router 0.7.0 → 0.9.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.
@@ -9,7 +9,7 @@ import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner } from ".
9
9
  import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
10
  import { loadTelemetryState } from "../config/telemetry.js";
11
11
  import { logRoute, logError, logStartup } from "./logger.js";
12
- import { stats } from "./stats.js";
12
+ import { createLocalRoutingErrorLog, stats } from "./stats.js";
13
13
  import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
14
14
  import { writePid, removePid } from "../daemon/pid.js";
15
15
  import { createOpenAIAccountPicker } from "../providers/openai/account-pool.js";
@@ -20,15 +20,19 @@ import { mountModelsRoute } from "./models-server.js";
20
20
  import chalk from "chalk";
21
21
  import { SessionRouter } from "./session-router.js";
22
22
  import { createAnthropicProxy } from "./anthropic-proxy.js";
23
- import { applyUpstreamFailureRouting, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
23
+ import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
24
+ import { canUseExtraUsage } from "../providers/anthropic/usage.js";
25
+ import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
24
26
  import { persistProviderEnabledState } from "./provider-routing.js";
25
- import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, } from "./account-deletion.js";
27
+ import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpenAIAccountTransaction, } from "./account-deletion.js";
28
+ import { addOpenAIAccountTransaction } from "./account-add.js";
26
29
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
27
30
  import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
28
31
  const zeroRoutingMetrics = () => ({
29
32
  inFlightRequests: 0,
30
33
  activeSessions: 0,
31
34
  coolingDown: false,
35
+ cooldownUntilMs: 0,
32
36
  });
33
37
  export function createOperationalStatus(opts) {
34
38
  const anthropicAccounts = opts.accounts.filter(a => a.provider === "anthropic_subscription");
@@ -79,6 +83,9 @@ function publicAnthropicAccountView(a, metrics) {
79
83
  weeklyLimitPercent: a.weeklyLimitPercent,
80
84
  healthy: a.enabled !== false && a.healthy,
81
85
  busy: a.busy || metrics.coolingDown,
86
+ cooldownUntilMs: metrics.cooldownUntilMs ?? 0,
87
+ globalCooldownUntilMs: metrics.globalCooldownUntilMs ?? 0,
88
+ modelCooldowns: publicModelCooldowns(metrics.modelCooldowns),
82
89
  inFlightRequests: metrics.inFlightRequests,
83
90
  activeSessions: metrics.activeSessions,
84
91
  requestCount: a.requestCount,
@@ -86,9 +93,95 @@ function publicAnthropicAccountView(a, metrics) {
86
93
  expiresInMs: a.tokens.expiresAt - Date.now(),
87
94
  lastUsedMs: a.lastUsed,
88
95
  lastRefreshMs: a.lastRefresh,
89
- rateLimits: a.rateLimits,
96
+ rateLimits: publicRateLimits(a.rateLimits),
90
97
  };
91
98
  }
99
+ function publicRateLimits(rateLimits) {
100
+ return {
101
+ status: rateLimits.status,
102
+ fiveHourUtil: publicUtilization(rateLimits.fiveHourUtil),
103
+ fiveHourReset: publicTimestamp(rateLimits.fiveHourReset),
104
+ sevenDayUtil: publicUtilization(rateLimits.sevenDayUtil),
105
+ sevenDayReset: publicTimestamp(rateLimits.sevenDayReset),
106
+ claim: publicRepresentativeClaim(rateLimits.claim),
107
+ plan: publicPlan(rateLimits.plan),
108
+ requestsLimit: publicNonNegativeInteger(rateLimits.requestsLimit),
109
+ lastUpdated: publicTimestamp(rateLimits.lastUpdated),
110
+ ...(rateLimits.usage ? { usage: publicUsageSnapshot(rateLimits.usage) } : {}),
111
+ };
112
+ }
113
+ function publicUsageSnapshot(usage) {
114
+ return {
115
+ ...(usage.fiveHour ? { fiveHour: publicWindow(usage.fiveHour) } : {}),
116
+ ...(usage.sevenDay ? { sevenDay: publicWindow(usage.sevenDay) } : {}),
117
+ modelLimits: usage.modelLimits.slice(0, 12).map(limit => ({
118
+ modelFamily: publicModelFamily(limit.modelFamily),
119
+ displayName: publicDisplayName(limit.displayName),
120
+ utilization: publicUtilization(limit.utilization),
121
+ resetAt: publicTimestamp(limit.resetAt),
122
+ active: limit.active === true,
123
+ severity: publicSeverity(limit.severity),
124
+ })),
125
+ ...(usage.extraUsage ? {
126
+ extraUsage: {
127
+ enabled: usage.extraUsage.enabled === true,
128
+ spendLimitReached: usage.extraUsage.spendLimitReached === true,
129
+ usable: usage.fetchStatus === "fresh" && canUseExtraUsage(usage.extraUsage),
130
+ },
131
+ } : {}),
132
+ fetchedAt: publicTimestamp(usage.fetchedAt),
133
+ fetchStatus: usage.fetchStatus,
134
+ };
135
+ }
136
+ function publicWindow(window) {
137
+ return { utilization: publicUtilization(window.utilization), resetAt: publicTimestamp(window.resetAt) };
138
+ }
139
+ function publicModelCooldowns(cooldowns) {
140
+ return (cooldowns ?? []).slice(0, 12).map(cooldown => ({
141
+ modelFamily: publicModelFamily(cooldown.modelFamily),
142
+ untilMs: publicTimestamp(cooldown.untilMs),
143
+ })).filter(cooldown => cooldown.untilMs > 0);
144
+ }
145
+ function publicUtilization(value) {
146
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
147
+ }
148
+ function publicTimestamp(value) {
149
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
150
+ }
151
+ function publicNonNegativeInteger(value) {
152
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
153
+ }
154
+ function publicModelFamily(value) {
155
+ return typeof value === "string" && /^[a-z0-9-]{1,64}$/.test(value) ? value : "unknown";
156
+ }
157
+ function publicDisplayName(value) {
158
+ if (typeof value !== "string")
159
+ return "Unknown model";
160
+ const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80);
161
+ return normalized || "Unknown model";
162
+ }
163
+ function publicSeverity(value) {
164
+ return value === "warning" || value === "critical" ? value : value ? "unknown" : "";
165
+ }
166
+ function publicPlan(value) {
167
+ return value === "Pro" || value === "Max 5x" || value === "Max 20x" ? value : "";
168
+ }
169
+ function publicRepresentativeClaim(claim) {
170
+ if (typeof claim !== "string")
171
+ return "unknown";
172
+ const normalized = claim.trim().toLowerCase();
173
+ if (!normalized)
174
+ return "";
175
+ if (normalized === "five_hour" ||
176
+ normalized === "seven_day" ||
177
+ normalized === "seven_day_oauth_apps" ||
178
+ normalized === "seven_day_overage_included")
179
+ return normalized;
180
+ if (normalized.startsWith("seven_day_") && normalized.length > "seven_day_".length) {
181
+ return "seven_day_model";
182
+ }
183
+ return "unknown";
184
+ }
92
185
  function publicOpenAIAccountView(a) {
93
186
  const expiresInMs = a.expiresAt - Date.now();
94
187
  return {
@@ -157,6 +250,14 @@ function extractRateLimits(headers) {
157
250
  lastUpdated: Date.now(),
158
251
  };
159
252
  }
253
+ /** Apply upstream rate-limit headers without discarding the usage snapshot. */
254
+ export function applyRateLimitHeaders(account, headers) {
255
+ const rateLimits = extractRateLimits(headers);
256
+ if (!rateLimits)
257
+ return false;
258
+ account.rateLimits = { ...account.rateLimits, ...rateLimits };
259
+ return true;
260
+ }
160
261
  export async function startServer(opts = {}) {
161
262
  const port = opts.port ?? PROXY_PORT;
162
263
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
@@ -182,11 +283,17 @@ export async function startServer(opts = {}) {
182
283
  const sessionRouter = new SessionRouter(pool);
183
284
  const createRoutingMetricsResolver = () => {
184
285
  const activeSessionCounts = sessionRouter.getActiveSessionCountsSnapshot();
185
- return accountId => ({
186
- inFlightRequests: pool.getInFlight(accountId),
187
- activeSessions: activeSessionCounts.get(accountId) ?? 0,
188
- coolingDown: pool.isCoolingDown(accountId),
189
- });
286
+ return accountId => {
287
+ const cooldowns = pool.getCooldownSummary(accountId);
288
+ return {
289
+ inFlightRequests: pool.getInFlight(accountId),
290
+ activeSessions: activeSessionCounts.get(accountId) ?? 0,
291
+ coolingDown: pool.isCoolingDown(accountId),
292
+ cooldownUntilMs: pool.getEarliestCooldownUntil(accountId),
293
+ globalCooldownUntilMs: cooldowns.globalUntilMs,
294
+ modelCooldowns: cooldowns.modelCooldowns,
295
+ };
296
+ };
190
297
  };
191
298
  const pickOpenAIAccount = createOpenAIAccountPicker(openAIAccounts);
192
299
  const initialConfig = readConfig();
@@ -206,6 +313,8 @@ export async function startServer(opts = {}) {
206
313
  };
207
314
  startRefreshLoop(accounts);
208
315
  startOpenAIRefreshLoop(openAIAccounts, saveOpenAIAccounts);
316
+ const usageRefresher = new AnthropicUsageRefresher(pool);
317
+ usageRefresher.start();
209
318
  const app = express();
210
319
  const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
211
320
  // ─── Proxy auth middleware ─────────────────────────────────────────────────
@@ -440,10 +549,36 @@ export async function startServer(opts = {}) {
440
549
  res.status(400).json({ error: "Invalid field types on account record" });
441
550
  return;
442
551
  }
443
- if (pool.findById(body.id)) {
552
+ // IDs are unique across providers, so a new account may not collide with an
553
+ // existing account in either the Claude pool or the OpenAI picker.
554
+ if (pool.findById(body.id) || openAIAccounts.some(a => a.id === body.id)) {
444
555
  res.status(409).json({ error: `Account "${body.id}" already exists` });
445
556
  return;
446
557
  }
558
+ if (body.provider === "openai_subscription") {
559
+ let addedOpenAI;
560
+ try {
561
+ addedOpenAI = addOpenAIAccountTransaction({
562
+ record: {
563
+ id: body.id,
564
+ accessToken: body.accessToken,
565
+ refreshToken: body.refreshToken,
566
+ expiresAt: body.expiresAt,
567
+ enabled: body.enabled,
568
+ },
569
+ accounts: openAIAccounts,
570
+ persist: saveOpenAIAccounts,
571
+ });
572
+ }
573
+ catch (err) {
574
+ const message = err instanceof Error ? err.message : String(err);
575
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
576
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
577
+ return;
578
+ }
579
+ res.status(201).json({ account: publicOpenAIAccountView(addedOpenAI) });
580
+ return;
581
+ }
447
582
  const record = {
448
583
  id: body.id,
449
584
  accessToken: body.accessToken,
@@ -475,18 +610,41 @@ export async function startServer(opts = {}) {
475
610
  });
476
611
  accountsRouter.delete("/:id", async (req, res) => {
477
612
  const { id } = req.params;
478
- // Refuse to remove the last account — downstream /v1/* would have no
479
- // token to route with and the pool would throw EmptyPoolError on the
480
- // next request. Users who want an empty pool should `cc-router stop`.
481
- if (pool.getAll().length <= 1) {
482
- res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
483
- return;
484
- }
485
613
  const existing = pool.findById(id);
486
- if (!existing) {
614
+ const openAIExisting = openAIAccounts.find(account => account.id === id);
615
+ if (!existing && !openAIExisting) {
487
616
  res.status(404).json({ error: `Account "${id}" not found` });
488
617
  return;
489
618
  }
619
+ if (openAIExisting && !existing) {
620
+ try {
621
+ deleteOpenAIAccountTransaction({
622
+ id,
623
+ accounts: openAIAccounts,
624
+ otherAccountCount: pool.getAll().length,
625
+ persist: saveOpenAIAccounts,
626
+ });
627
+ }
628
+ catch (err) {
629
+ const message = err instanceof Error ? err.message : String(err);
630
+ const status = accountDeletionStatusCode(err);
631
+ if (status === 409) {
632
+ res.status(409).json({ error: message });
633
+ return;
634
+ }
635
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
636
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
637
+ return;
638
+ }
639
+ res.json({ ok: true, id });
640
+ return;
641
+ }
642
+ // Preserve the existing Anthropic invariant: a running Anthropic pool
643
+ // always retains at least one account.
644
+ if (pool.getAll().length <= 1) {
645
+ res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
646
+ return;
647
+ }
490
648
  try {
491
649
  await deleteAnthropicAccountTransaction({
492
650
  id,
@@ -590,9 +748,10 @@ export async function startServer(opts = {}) {
590
748
  pendingLog.statusCode = status;
591
749
  if (durationMs !== undefined)
592
750
  pendingLog.durationMs = durationMs;
593
- const cooldownSeconds = route
594
- ? applyUpstreamFailureRouting(status, proxyRes.headers["retry-after"], route, sessionRouter, pool)
751
+ const failureRouting = route
752
+ ? applyUpstreamFailureRoutingDetailed(status, proxyRes.headers, route, sessionRouter, pool)
595
753
  : undefined;
754
+ const cooldownSeconds = failureRouting?.cooldownSeconds;
596
755
  if (status === 401) {
597
756
  // Token invalid or expired mid-request.
598
757
  // Forward the 401 to the client (Claude Code will retry on 401).
@@ -613,9 +772,19 @@ export async function startServer(opts = {}) {
613
772
  const retryAfter = cooldownSeconds ?? 60;
614
773
  pendingLog.type = "error";
615
774
  pendingLog.details = route
616
- ? routeFailureDetails(route, "rate-limited")
775
+ ? routeFailureDetails(route, "rate-limited", failureRouting?.limitingScope)
617
776
  : "rate-limited";
618
777
  logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
778
+ // Refresh in the background to narrow only ambiguity-owned global
779
+ // state when fresh usage proves a requested-model exhaustion. The
780
+ // current upstream response remains on the native proxy stream.
781
+ queueMicrotask(() => {
782
+ void usageRefresher.refreshAfterCurrent(account).then(result => {
783
+ if (result.ok && route) {
784
+ reconcileAmbiguousRateLimitCooldown(route, pool, failureRouting?.ambiguousCooldownToken);
785
+ }
786
+ });
787
+ });
619
788
  }
620
789
  else if (status === 529) {
621
790
  // Anthropic service overloaded — short cooldown on this account.
@@ -628,9 +797,7 @@ export async function startServer(opts = {}) {
628
797
  logError(account.id, 529, "Service overloaded — cooldown 30s");
629
798
  }
630
799
  // ── Capture rate limit utilization from response headers ────────────
631
- const rl = extractRateLimits(proxyRes.headers);
632
- if (rl)
633
- account.rateLimits = rl;
800
+ applyRateLimitHeaders(account, proxyRes.headers);
634
801
  const entry = pendingLog;
635
802
  stats.addLog(entry);
636
803
  // ── Capture token usage from Anthropic response body ─────────────────
@@ -737,6 +904,12 @@ export async function startServer(opts = {}) {
737
904
  error: { type: "no_accounts", message: err.message },
738
905
  });
739
906
  },
907
+ onNoEligibleAccount: (err, req) => {
908
+ stats.totalErrors++;
909
+ const entry = createLocalRoutingErrorLog(err.reason, req._ccRouteContext?.modelFamily);
910
+ stats.addLog(entry);
911
+ logError(entry.accountId, entry.statusCode ?? 0, entry.details ?? "no-eligible");
912
+ },
740
913
  }), createAnthropicRefreshMiddleware({
741
914
  needsRefresh,
742
915
  refresh: account => refreshAccountIfCurrent(account, pool),
@@ -775,6 +948,7 @@ export async function startServer(opts = {}) {
775
948
  // ─── Graceful shutdown ────────────────────────────────────────────────────
776
949
  const shutdown = () => {
777
950
  console.log(chalk.yellow("\nShutting down — saving tokens..."));
951
+ usageRefresher.stop();
778
952
  saveAccounts(pool.getAll());
779
953
  if (process.env["CC_ROUTER_DAEMON"] === "1") {
780
954
  removePid();
@@ -1,3 +1,4 @@
1
+ import { normalizeModelFamily } from "../providers/anthropic/usage.js";
1
2
  const DEFAULT_TTL_MS = 60 * 60 * 1000;
2
3
  const DEFAULT_MAX_ENTRIES = 10_000;
3
4
  const MAX_SESSION_ID_BYTES = 256;
@@ -41,25 +42,26 @@ export class SessionRouter {
41
42
  throw new RangeError("session binding capacity must be a positive integer");
42
43
  }
43
44
  }
44
- acquire(sessionHeader) {
45
+ acquire(sessionHeader, context) {
45
46
  const sessionId = normalizeSessionId(sessionHeader);
47
+ const modelFamily = normalizeModelFamily(context?.modelFamily);
46
48
  const now = this.now();
47
49
  this.sweepExpiredBindings(now);
48
50
  if (!sessionId) {
49
- return this.wrapUnscoped(this.pool.acquireBest(this.activeSessionCounts));
51
+ return this.wrapUnscoped(this.pool.acquireBest(this.activeSessionCounts, context), modelFamily);
50
52
  }
51
53
  const existing = this.bindings.get(sessionId);
52
54
  if (existing) {
53
- const stickyLease = this.pool.tryAcquire(existing.accountId);
55
+ const stickyLease = this.pool.tryAcquire(existing.accountId, context);
54
56
  if (stickyLease) {
55
57
  existing.lastSeen = now;
56
- return this.wrapScoped(stickyLease, "sticky", sessionId, existing.generation);
58
+ return this.wrapScoped(stickyLease, "sticky", sessionId, existing.generation, modelFamily);
57
59
  }
58
60
  this.removeBinding(sessionId);
59
61
  }
60
- const lease = this.pool.acquireBest(this.activeSessionCounts);
62
+ const lease = this.pool.acquireBest(this.activeSessionCounts, context);
61
63
  const binding = this.insertBinding(sessionId, lease.account.id, now);
62
- return this.wrapScoped(lease, existing ? "failover" : "new-session", sessionId, binding.generation);
64
+ return this.wrapScoped(lease, existing ? "failover" : "new-session", sessionId, binding.generation, modelFamily);
63
65
  }
64
66
  /**
65
67
  * Remove a binding only if it still belongs to the expected account. This
@@ -103,15 +105,16 @@ export class SessionRouter {
103
105
  this.sweepExpiredBindings(this.now());
104
106
  return new Map(this.activeSessionCounts);
105
107
  }
106
- wrapUnscoped(lease) {
108
+ wrapUnscoped(lease, modelFamily) {
107
109
  return {
108
110
  account: lease.account,
109
111
  fallback: lease.fallback,
110
112
  release: lease.release,
111
113
  reason: "unscoped",
114
+ ...(modelFamily ? { modelFamily } : {}),
112
115
  };
113
116
  }
114
- wrapScoped(lease, reason, sessionId, bindingGeneration) {
117
+ wrapScoped(lease, reason, sessionId, bindingGeneration, modelFamily) {
115
118
  return {
116
119
  account: lease.account,
117
120
  fallback: lease.fallback,
@@ -119,6 +122,7 @@ export class SessionRouter {
119
122
  reason,
120
123
  sessionId,
121
124
  bindingGeneration,
125
+ ...(modelFamily ? { modelFamily } : {}),
122
126
  };
123
127
  }
124
128
  insertBinding(sessionId, accountId, lastSeen) {
@@ -1,3 +1,14 @@
1
+ /** Build a bounded diagnostic for a request rejected before account selection. */
2
+ export function createLocalRoutingErrorLog(reason, modelFamily, now = Date.now()) {
3
+ return {
4
+ ts: now,
5
+ accountId: "proxy",
6
+ model: modelFamily ?? "-",
7
+ type: "error",
8
+ details: `no-eligible:${reason.replace("_", "-")}`,
9
+ statusCode: reason === "rate_limited" ? 429 : 503,
10
+ };
11
+ }
1
12
  const MAX_LOG_ENTRIES = 100;
2
13
  class ProxyStats {
3
14
  totalRequests = 0;