@timo972/cc-router 0.10.1 → 0.11.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.
@@ -4,12 +4,14 @@ import { ServerResponse } from "http";
4
4
  import { timingSafeEqual } from "crypto";
5
5
  import { TokenPool } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
- import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
7
+ import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
8
8
  import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
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
12
  import { createLocalRoutingErrorLog, stats } from "./stats.js";
13
+ import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
14
+ import { mountAnthropicMessagesRoute, withOAuthBeta } from "./anthropic-messages-route.js";
13
15
  import { PROXY_PORT, LITELLM_URL, ACCOUNTS_PATH } from "../config/paths.js";
14
16
  import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
15
17
  import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
@@ -26,14 +28,13 @@ import { SessionRouter } from "./session-router.js";
26
28
  import { createAnthropicProxy } from "./anthropic-proxy.js";
27
29
  import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
28
30
  import { OpenAIUsageRefresher } from "../providers/openai/usage-fetch.js";
29
- import { createAnthropicUsageCapture } from "./usage-capture.js";
31
+ import { attachAnthropicResponseCapture } from "./anthropic-response-capture.js";
30
32
  import { canUseExtraUsage } from "../providers/anthropic/usage.js";
31
33
  import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
32
34
  import { persistProviderEnabledState } from "./provider-routing.js";
33
35
  import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpenAIAccountTransaction, } from "./account-deletion.js";
34
36
  import { addOpenAIAccountTransaction } from "./account-add.js";
35
37
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
36
- import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
37
38
  const zeroRoutingMetrics = () => ({
38
39
  inFlightRequests: 0,
39
40
  activeSessions: 0,
@@ -139,6 +140,8 @@ function publicUsageSnapshot(usage) {
139
140
  fetchStatus: usage.fetchStatus,
140
141
  };
141
142
  }
143
+ // An unreported utilization surfaces as 0 here, which is what the dashboard
144
+ // has always shown for it; only release decisions need the distinction.
142
145
  function publicWindow(window) {
143
146
  return { utilization: publicUtilization(window.utilization), resetAt: publicTimestamp(window.resetAt) };
144
147
  }
@@ -277,57 +280,9 @@ function providerStatus(accounts) {
277
280
  enabled: accounts.filter(a => a.enabled !== false).length,
278
281
  };
279
282
  }
280
- // Mutates entry and updates aggregate counters with token usage from Anthropic's
281
- // response. Called asynchronously after the log entry is already stored,
282
- // so the dashboard picks up the values on the next poll.
283
- function applyInputUsage(entry, usage) {
284
- entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
285
- entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
286
- entry.inputTokens = usage["input_tokens"] ?? 0;
287
- stats.totalCacheReadTokens += entry.cacheReadTokens;
288
- stats.totalCacheCreationTokens += entry.cacheCreationTokens;
289
- stats.totalInputTokens += entry.inputTokens;
290
- }
291
- function applyOutputUsage(entry, usage) {
292
- entry.outputTokens = usage["output_tokens"] ?? 0;
293
- stats.totalOutputTokens += entry.outputTokens;
294
- }
295
- // ─── Rate limit header extraction ──────────────────────────────────────────
296
- function inferPlan(requestsLimit) {
297
- if (requestsLimit <= 0)
298
- return "";
299
- if (requestsLimit <= 100)
300
- return "Pro";
301
- if (requestsLimit <= 500)
302
- return "Max 5x";
303
- return "Max 20x";
304
- }
305
- function extractRateLimits(headers) {
306
- const h = (name) => String(headers[name] ?? "");
307
- const status = h("anthropic-ratelimit-unified-status");
308
- if (!status)
309
- return null; // No unified headers in this response
310
- const requestsLimit = parseInt(h("anthropic-ratelimit-requests-limit"), 10) || 0;
311
- return {
312
- status: status === "rate_limited" ? "rate_limited" : "allowed",
313
- fiveHourUtil: parseFloat(h("anthropic-ratelimit-unified-5h-utilization")) || 0,
314
- fiveHourReset: parseInt(h("anthropic-ratelimit-unified-5h-reset"), 10) || 0,
315
- sevenDayUtil: parseFloat(h("anthropic-ratelimit-unified-7d-utilization")) || 0,
316
- sevenDayReset: parseInt(h("anthropic-ratelimit-unified-7d-reset"), 10) || 0,
317
- claim: h("anthropic-ratelimit-unified-representative-claim"),
318
- plan: inferPlan(requestsLimit),
319
- requestsLimit,
320
- lastUpdated: Date.now(),
321
- };
322
- }
323
- /** Apply upstream rate-limit headers without discarding the usage snapshot. */
324
- export function applyRateLimitHeaders(account, headers) {
325
- const rateLimits = extractRateLimits(headers);
326
- if (!rateLimits)
327
- return false;
328
- account.rateLimits = { ...account.rateLimits, ...rateLimits };
329
- return true;
330
- }
283
+ // Re-exported so existing importers keep working; the implementation moved to
284
+ // providers/anthropic so both Anthropic transports share it.
285
+ export { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
331
286
  /**
332
287
  * Build the single function through which this server writes OpenAI accounts.
333
288
  *
@@ -442,6 +397,13 @@ export async function startServer(opts = {}) {
442
397
  openAIUsageRefresher.start();
443
398
  const app = express();
444
399
  const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
400
+ // Router-side 429 failover / 5xx retry is on by default; `"autoFailover":
401
+ // false` in config.json opts out for anyone who cannot work with the
402
+ // trade-off (a committed retry abandons the original failure response).
403
+ // A single-attempt budget IS the off switch: both transports then relay
404
+ // every upstream failure unchanged, exactly as before the feature existed.
405
+ const autoFailover = getAutoFailoverEnabled();
406
+ const upstreamAttempts = autoFailover ? {} : { maxAttempts: 1 };
445
407
  // ─── Proxy auth middleware ─────────────────────────────────────────────────
446
408
  // If a proxySecret is configured, all requests must present it as EITHER
447
409
  // "Authorization: Bearer <secret>" (Claude Code CLI, HTTP clients)
@@ -923,6 +885,7 @@ export async function startServer(opts = {}) {
923
885
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
924
886
  modelRouting,
925
887
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
888
+ ...upstreamAttempts,
926
889
  });
927
890
  mountMessagesCrossProviderRoute(app, {
928
891
  openAIRouter,
@@ -930,6 +893,58 @@ export async function startServer(opts = {}) {
930
893
  prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, persistOpenAIAccounts),
931
894
  modelRouting,
932
895
  onUpstreamAuthFailure: onOpenAIUpstreamAuthFailure,
896
+ ...upstreamAttempts,
897
+ });
898
+ // Shared between the retrying /v1/messages route and the generic /v1 chain
899
+ // so a locally rejected request is reported identically on both.
900
+ const onAnthropicEmptyPool = (err, _req, res) => {
901
+ stats.totalErrors++;
902
+ logError("proxy", 503, err.message);
903
+ res.status(503).json({
904
+ type: "error",
905
+ error: { type: "no_accounts", message: err.message },
906
+ });
907
+ };
908
+ const onAnthropicNoEligibleAccount = (err, req) => {
909
+ stats.totalErrors++;
910
+ const entry = createLocalRoutingErrorLog(err.reason, req._ccRouteContext?.modelFamily);
911
+ stats.addLog(entry);
912
+ logError(entry.accountId, entry.statusCode ?? 0, entry.details ?? "no-eligible");
913
+ };
914
+ const onAnthropicRefreshFailure = (account) => {
915
+ stats.totalErrors++;
916
+ logError(account.id, 401, "Token refresh failed");
917
+ };
918
+ // Claude-bound POST /v1/messages goes through its own transport with
919
+ // router-side 429 failover and 5xx retry; every other /v1 endpoint stays on
920
+ // the generic byte-transparent proxy below.
921
+ mountAnthropicMessagesRoute(app, {
922
+ target,
923
+ timeoutMs: proxyRequestTimeoutMs,
924
+ pool,
925
+ sessionRouter,
926
+ ...upstreamAttempts,
927
+ needsRefresh,
928
+ refresh: account => refreshAccountIfCurrent(account, pool),
929
+ onRefreshFailure: onAnthropicRefreshFailure,
930
+ onEmptyPool: onAnthropicEmptyPool,
931
+ onNoEligibleAccount: onAnthropicNoEligibleAccount,
932
+ // A relayed 401 means the token is stale — refresh in the background so
933
+ // the next request succeeds without making this client wait on it.
934
+ onUpstream401: account => {
935
+ void refreshAccountIfCurrent(account, pool).catch(console.error);
936
+ },
937
+ // Refresh in the background to narrow only ambiguity-owned global state
938
+ // when fresh usage proves a requested-model exhaustion.
939
+ onRateLimited: (route, ambiguousCooldownToken) => {
940
+ queueMicrotask(() => {
941
+ void usageRefresher.refreshAfterCurrent(route.account).then(result => {
942
+ if (result.ok) {
943
+ reconcileAmbiguousRateLimitCooldown(route, pool, ambiguousCooldownToken);
944
+ }
945
+ });
946
+ });
947
+ },
933
948
  });
934
949
  // ─── Proxy middleware ──────────────────────────────────────────────────────
935
950
  // IMPORTANT: selfHandleResponse must be false (default) for SSE streaming to
@@ -952,15 +967,9 @@ export async function startServer(opts = {}) {
952
967
  // CRITICAL: api.anthropic.com requires the "oauth-2025-04-20" beta flag to
953
968
  // accept OAuth tokens (sk-ant-oat01-*). Without it the request is rejected
954
969
  // with "OAuth authentication is currently not supported."
955
- // APPEND — do NOT replace — so existing betas (tools, computer-use, etc.) are preserved.
956
- const existingBeta = proxyReq.getHeader("anthropic-beta");
957
- const betas = existingBeta
958
- ? String(existingBeta).split(",").map(b => b.trim()).filter(Boolean)
959
- : [];
960
- if (!betas.includes("oauth-2025-04-20")) {
961
- betas.push("oauth-2025-04-20");
962
- proxyReq.setHeader("anthropic-beta", betas.join(","));
963
- }
970
+ // APPEND — do NOT replace — so existing betas (tools, computer-use, etc.)
971
+ // are preserved. Shared with the retrying /v1/messages transport.
972
+ proxyReq.setHeader("anthropic-beta", withOAuthBeta(proxyReq.getHeader("anthropic-beta")));
964
973
  // All other headers are forwarded automatically by http-proxy-middleware:
965
974
  // anthropic-version — required by Anthropic API
966
975
  // X-Claude-Code-Session-Id — session aggregation header sent by Claude Code
@@ -1061,34 +1070,9 @@ export async function startServer(opts = {}) {
1061
1070
  const entry = pendingLog;
1062
1071
  stats.addLog(entry);
1063
1072
  // ── Capture token usage from Anthropic response body ─────────────────
1064
- // SSE streams carry usage across two events:
1065
- // message_start → input_tokens, cache_read/creation_input_tokens
1066
- // message_delta → output_tokens
1067
- // Non-streaming JSON carries all fields in a single usage object.
1068
- // The proxy is byte-transparent and the client's accept-encoding makes
1069
- // upstream compress, so the capture decompresses its own copy of the
1070
- // stream (see usage-capture.ts) — previously compressed responses were
1071
- // skipped, which in practice was EVERY response: no cache rate or
1072
- // token counts ever appeared on Anthropic activity rows.
1073
- const contentType = String(proxyRes.headers["content-type"] ?? "");
1074
- const encoding = String(proxyRes.headers["content-encoding"] ?? "");
1075
- const isCompressed = /gzip|br|deflate/.test(encoding);
1076
- const streamTracker = createStreamLifecycleTracker(req._startTime ?? Date.now(), !isCompressed && contentType.includes("text/event-stream"));
1077
- entry.streamLifecycle = streamTracker.state;
1078
- streamTracker.attach(proxyRes, response);
1079
- proxyRes.on("data", (chunk) => streamTracker.observeChunk(chunk));
1080
- const usageCapture = createAnthropicUsageCapture({
1081
- contentType,
1082
- contentEncoding: encoding,
1083
- // Mutates the already-logged entry in place; the dashboard picks the
1084
- // values up on its next poll.
1085
- onInputUsage: (usage) => applyInputUsage(entry, usage),
1086
- onOutputUsage: (usage) => applyOutputUsage(entry, usage),
1087
- });
1088
- if (usageCapture) {
1089
- proxyRes.on("data", (chunk) => usageCapture.write(chunk));
1090
- proxyRes.on("end", () => usageCapture.end());
1091
- }
1073
+ // Passive stream-lifecycle + token-usage taps, shared with the
1074
+ // retrying /v1/messages transport (see anthropic-response-capture.ts).
1075
+ attachAnthropicResponseCapture(proxyRes, response, entry, req._startTime ?? Date.now());
1092
1076
  },
1093
1077
  error: (err, _req, res) => {
1094
1078
  const request = _req;
@@ -1125,27 +1109,12 @@ export async function startServer(opts = {}) {
1125
1109
  // and breaks SSE streaming passthrough.
1126
1110
  app.use("/v1", createAnthropicRoutingMiddleware({
1127
1111
  sessionRouter,
1128
- onEmptyPool: (err, _req, res) => {
1129
- stats.totalErrors++;
1130
- logError("proxy", 503, err.message);
1131
- res.status(503).json({
1132
- type: "error",
1133
- error: { type: "no_accounts", message: err.message },
1134
- });
1135
- },
1136
- onNoEligibleAccount: (err, req) => {
1137
- stats.totalErrors++;
1138
- const entry = createLocalRoutingErrorLog(err.reason, req._ccRouteContext?.modelFamily);
1139
- stats.addLog(entry);
1140
- logError(entry.accountId, entry.statusCode ?? 0, entry.details ?? "no-eligible");
1141
- },
1112
+ onEmptyPool: onAnthropicEmptyPool,
1113
+ onNoEligibleAccount: onAnthropicNoEligibleAccount,
1142
1114
  }), createAnthropicRefreshMiddleware({
1143
1115
  needsRefresh,
1144
1116
  refresh: account => refreshAccountIfCurrent(account, pool),
1145
- onRefreshFailure: (account) => {
1146
- stats.totalErrors++;
1147
- logError(account.id, 401, "Token refresh failed");
1148
- },
1117
+ onRefreshFailure: onAnthropicRefreshFailure,
1149
1118
  }), (req, _res, next) => {
1150
1119
  const route = req._ccRoute;
1151
1120
  const account = route.account;
@@ -1258,6 +1227,9 @@ export async function startServer(opts = {}) {
1258
1227
  console.log(autoUpdate
1259
1228
  ? chalk.gray(" Auto-update: enabled (patch/minor)")
1260
1229
  : chalk.gray(" Auto-update: off (notify-only) — run 'cc-router update' to install"));
1230
+ console.log(autoFailover
1231
+ ? chalk.gray(" Auto-failover: on — 429/5xx retried across accounts before the first relayed byte")
1232
+ : chalk.gray(" Auto-failover: off — upstream failures pass through; clients own retries"));
1261
1233
  // Anonymous telemetry — fire-and-forget, never blocks proxy startup.
1262
1234
  try {
1263
1235
  const telemetryState = loadTelemetryState();
@@ -52,14 +52,33 @@ class ProxyStats {
52
52
  }
53
53
  // Singleton — shared across server and health endpoint
54
54
  export const stats = new ProxyStats();
55
+ /**
56
+ * Record Anthropic input-side usage (message_start, or a non-streaming JSON
57
+ * body) on both the request's log entry and the running totals. Mutates an
58
+ * entry that is typically already stored — the dashboard picks the values up
59
+ * on its next poll.
60
+ */
61
+ export function applyAnthropicInputUsage(entry, usage) {
62
+ entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
63
+ entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
64
+ entry.inputTokens = usage["input_tokens"] ?? 0;
65
+ stats.totalCacheReadTokens += entry.cacheReadTokens;
66
+ stats.totalCacheCreationTokens += entry.cacheCreationTokens;
67
+ stats.totalInputTokens += entry.inputTokens;
68
+ }
69
+ /** Record Anthropic output-side usage (message_delta) — see input counterpart. */
70
+ export function applyAnthropicOutputUsage(entry, usage) {
71
+ entry.outputTokens = usage["output_tokens"] ?? 0;
72
+ stats.totalOutputTokens += entry.outputTokens;
73
+ }
55
74
  /** Record Codex token usage on both the request's log entry and the running totals. */
56
75
  export function applyCodexUsage(entry, usage) {
57
76
  if (!usage)
58
77
  return;
59
- entry.inputTokens = usage.inputTokens;
78
+ entry.inputTokens = Math.max(0, usage.inputTokens - usage.cachedInputTokens);
60
79
  entry.outputTokens = usage.outputTokens;
61
80
  entry.cacheReadTokens = usage.cachedInputTokens;
62
- stats.totalInputTokens += usage.inputTokens;
81
+ stats.totalInputTokens += entry.inputTokens;
63
82
  stats.totalOutputTokens += usage.outputTokens;
64
83
  stats.totalCacheReadTokens += usage.cachedInputTokens;
65
84
  }