@timo972/cc-router 0.7.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Dockerfile +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +716 -0
  5. package/accounts.example.json +25 -0
  6. package/dist/cli/cmd-accounts.js +248 -0
  7. package/dist/cli/cmd-client.js +612 -0
  8. package/dist/cli/cmd-configure.js +145 -0
  9. package/dist/cli/cmd-docker.js +140 -0
  10. package/dist/cli/cmd-logs.js +85 -0
  11. package/dist/cli/cmd-models.js +125 -0
  12. package/dist/cli/cmd-service.js +193 -0
  13. package/dist/cli/cmd-setup.js +501 -0
  14. package/dist/cli/cmd-start.js +318 -0
  15. package/dist/cli/cmd-status.js +177 -0
  16. package/dist/cli/cmd-stop.js +100 -0
  17. package/dist/cli/cmd-telemetry.js +58 -0
  18. package/dist/cli/cmd-update.js +37 -0
  19. package/dist/cli/index.js +59 -0
  20. package/dist/config/manager.js +262 -0
  21. package/dist/config/paths.js +21 -0
  22. package/dist/config/telemetry.js +64 -0
  23. package/dist/daemon/launcher.js +163 -0
  24. package/dist/daemon/pid.js +98 -0
  25. package/dist/daemon/service.js +260 -0
  26. package/dist/interceptor/mitmproxy-manager.js +616 -0
  27. package/dist/protocol/anthropic-to-openai.js +51 -0
  28. package/dist/protocol/anthropic-types.js +1 -0
  29. package/dist/protocol/model-ref.js +36 -0
  30. package/dist/protocol/model-routing-config.js +30 -0
  31. package/dist/protocol/openai-response-to-anthropic.js +20 -0
  32. package/dist/protocol/openai-responses-types.js +1 -0
  33. package/dist/protocol/openai-stream-to-anthropic.js +75 -0
  34. package/dist/protocol/openai-to-anthropic.js +61 -0
  35. package/dist/protocol/sse.js +17 -0
  36. package/dist/providers/model-discovery.js +71 -0
  37. package/dist/providers/openai/account-pool.js +11 -0
  38. package/dist/providers/openai/account-record.js +33 -0
  39. package/dist/providers/openai/codex-transport.js +36 -0
  40. package/dist/providers/openai/device-oauth.js +116 -0
  41. package/dist/providers/openai/token-refresher.js +56 -0
  42. package/dist/providers/route-selector.js +8 -0
  43. package/dist/providers/types.js +1 -0
  44. package/dist/proxy/account-deletion.js +44 -0
  45. package/dist/proxy/anthropic-proxy.js +26 -0
  46. package/dist/proxy/anthropic-routing.js +90 -0
  47. package/dist/proxy/lease-lifecycle.js +68 -0
  48. package/dist/proxy/logger.js +39 -0
  49. package/dist/proxy/messages-cross-route.js +179 -0
  50. package/dist/proxy/models-server.js +150 -0
  51. package/dist/proxy/provider-routing.js +14 -0
  52. package/dist/proxy/responses-server.js +91 -0
  53. package/dist/proxy/server.js +875 -0
  54. package/dist/proxy/session-router.js +171 -0
  55. package/dist/proxy/stats.js +25 -0
  56. package/dist/proxy/stream-lifecycle.js +83 -0
  57. package/dist/proxy/token-pool.js +407 -0
  58. package/dist/proxy/token-refresher.js +209 -0
  59. package/dist/proxy/types.js +29 -0
  60. package/dist/ui/Dashboard.js +640 -0
  61. package/dist/ui/accountsApi.js +48 -0
  62. package/dist/ui/modelsApi.js +47 -0
  63. package/dist/utils/claude-config.js +185 -0
  64. package/dist/utils/codex-config.js +62 -0
  65. package/dist/utils/network.js +16 -0
  66. package/dist/utils/platform.js +13 -0
  67. package/dist/utils/self-update.js +239 -0
  68. package/dist/utils/telemetry.js +88 -0
  69. package/dist/utils/token-extractor.js +95 -0
  70. package/dist/utils/token-validator.js +26 -0
  71. package/docker-compose.yml +63 -0
  72. package/litellm-config.yaml +44 -0
  73. package/package.json +69 -0
  74. package/src/interceptor/addon.py +78 -0
@@ -0,0 +1,875 @@
1
+ import express from "express";
2
+ import { createProxyMiddleware } from "http-proxy-middleware";
3
+ import { ServerResponse } from "http";
4
+ import { timingSafeEqual } from "crypto";
5
+ import { TokenPool } from "./token-pool.js";
6
+ import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
+ import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccounts, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
8
+ import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner } from "../utils/self-update.js";
9
+ import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
+ import { loadTelemetryState } from "../config/telemetry.js";
11
+ import { logRoute, logError, logStartup } from "./logger.js";
12
+ import { stats } from "./stats.js";
13
+ import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
14
+ import { writePid, removePid } from "../daemon/pid.js";
15
+ import { createOpenAIAccountPicker } from "../providers/openai/account-pool.js";
16
+ import { prepareOpenAIAccountForRequest, startOpenAIRefreshLoop } from "../providers/openai/token-refresher.js";
17
+ import { mountResponsesRoutes } from "./responses-server.js";
18
+ import { mountMessagesCrossProviderRoute } from "./messages-cross-route.js";
19
+ import { mountModelsRoute } from "./models-server.js";
20
+ import chalk from "chalk";
21
+ import { SessionRouter } from "./session-router.js";
22
+ import { createAnthropicProxy } from "./anthropic-proxy.js";
23
+ import { applyUpstreamFailureRouting, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
24
+ import { persistProviderEnabledState } from "./provider-routing.js";
25
+ import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, } from "./account-deletion.js";
26
+ import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
27
+ import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
28
+ const zeroRoutingMetrics = () => ({
29
+ inFlightRequests: 0,
30
+ activeSessions: 0,
31
+ coolingDown: false,
32
+ });
33
+ export function createOperationalStatus(opts) {
34
+ const anthropicAccounts = opts.accounts.filter(a => a.provider === "anthropic_subscription");
35
+ const openAIAccounts = opts.accounts.filter(a => a.provider === "openai_subscription");
36
+ const modelRouting = opts.modelRouting ?? {};
37
+ return {
38
+ mode: opts.mode,
39
+ target: opts.target,
40
+ auth: { required: opts.authRequired },
41
+ providers: {
42
+ anthropic: providerStatus(anthropicAccounts),
43
+ openai: providerStatus(openAIAccounts),
44
+ },
45
+ endpoints: {
46
+ health: "/cc-router/health",
47
+ accounts: "/cc-router/accounts",
48
+ messages: "/v1/messages",
49
+ responses: "/v1/responses",
50
+ models: "/v1/models",
51
+ },
52
+ routing: {
53
+ anthropicDefaultModel: modelRouting.anthropicDefaultModel,
54
+ openAIDefaultModel: modelRouting.openAIDefaultModel,
55
+ anthropicAliases: Object.keys(modelRouting.anthropicAliases ?? {}).sort(),
56
+ openAIAliases: Object.keys(modelRouting.openAIAliases ?? {}).sort(),
57
+ },
58
+ capabilities: {
59
+ anthropicMessages: anthropicAccounts.length > 0,
60
+ openAIResponses: openAIAccounts.length > 0,
61
+ crossProviderMessages: openAIAccounts.length > 0,
62
+ dynamicModels: true,
63
+ accountManagement: true,
64
+ },
65
+ };
66
+ }
67
+ export function createHealthAccountViews(anthropicAccounts, openAIAccounts, resolveRoutingMetrics = zeroRoutingMetrics) {
68
+ return [
69
+ ...anthropicAccounts.map(account => (publicAnthropicAccountView(account, resolveRoutingMetrics(account.id)))),
70
+ ...openAIAccounts.map(publicOpenAIAccountView),
71
+ ];
72
+ }
73
+ function publicAnthropicAccountView(a, metrics) {
74
+ return {
75
+ id: a.id,
76
+ provider: "anthropic_subscription",
77
+ enabled: a.enabled,
78
+ sessionLimitPercent: a.sessionLimitPercent,
79
+ weeklyLimitPercent: a.weeklyLimitPercent,
80
+ healthy: a.enabled !== false && a.healthy,
81
+ busy: a.busy || metrics.coolingDown,
82
+ inFlightRequests: metrics.inFlightRequests,
83
+ activeSessions: metrics.activeSessions,
84
+ requestCount: a.requestCount,
85
+ errorCount: a.errorCount,
86
+ expiresInMs: a.tokens.expiresAt - Date.now(),
87
+ lastUsedMs: a.lastUsed,
88
+ lastRefreshMs: a.lastRefresh,
89
+ rateLimits: a.rateLimits,
90
+ };
91
+ }
92
+ function publicOpenAIAccountView(a) {
93
+ const expiresInMs = a.expiresAt - Date.now();
94
+ return {
95
+ id: a.id,
96
+ provider: "openai_subscription",
97
+ enabled: a.enabled !== false,
98
+ healthy: a.enabled !== false && expiresInMs > 0,
99
+ busy: false,
100
+ inFlightRequests: 0,
101
+ activeSessions: 0,
102
+ requestCount: 0,
103
+ errorCount: 0,
104
+ expiresInMs,
105
+ lastUsedMs: 0,
106
+ lastRefreshMs: 0,
107
+ };
108
+ }
109
+ function providerStatus(accounts) {
110
+ return {
111
+ configured: accounts.length > 0,
112
+ accounts: accounts.length,
113
+ healthy: accounts.filter(a => a.healthy).length,
114
+ enabled: accounts.filter(a => a.enabled !== false).length,
115
+ };
116
+ }
117
+ // Mutates entry and updates aggregate counters with token usage from Anthropic's
118
+ // response. Called asynchronously after the log entry is already stored,
119
+ // so the dashboard picks up the values on the next poll.
120
+ function applyInputUsage(entry, usage) {
121
+ entry.cacheReadTokens = usage["cache_read_input_tokens"] ?? 0;
122
+ entry.cacheCreationTokens = usage["cache_creation_input_tokens"] ?? 0;
123
+ entry.inputTokens = usage["input_tokens"] ?? 0;
124
+ stats.totalCacheReadTokens += entry.cacheReadTokens;
125
+ stats.totalCacheCreationTokens += entry.cacheCreationTokens;
126
+ stats.totalInputTokens += entry.inputTokens;
127
+ }
128
+ function applyOutputUsage(entry, usage) {
129
+ entry.outputTokens = usage["output_tokens"] ?? 0;
130
+ stats.totalOutputTokens += entry.outputTokens;
131
+ }
132
+ // ─── Rate limit header extraction ──────────────────────────────────────────
133
+ function inferPlan(requestsLimit) {
134
+ if (requestsLimit <= 0)
135
+ return "";
136
+ if (requestsLimit <= 100)
137
+ return "Pro";
138
+ if (requestsLimit <= 500)
139
+ return "Max 5x";
140
+ return "Max 20x";
141
+ }
142
+ function extractRateLimits(headers) {
143
+ const h = (name) => String(headers[name] ?? "");
144
+ const status = h("anthropic-ratelimit-unified-status");
145
+ if (!status)
146
+ return null; // No unified headers in this response
147
+ const requestsLimit = parseInt(h("anthropic-ratelimit-requests-limit"), 10) || 0;
148
+ return {
149
+ status: status === "rate_limited" ? "rate_limited" : "allowed",
150
+ fiveHourUtil: parseFloat(h("anthropic-ratelimit-unified-5h-utilization")) || 0,
151
+ fiveHourReset: parseInt(h("anthropic-ratelimit-unified-5h-reset"), 10) || 0,
152
+ sevenDayUtil: parseFloat(h("anthropic-ratelimit-unified-7d-utilization")) || 0,
153
+ sevenDayReset: parseInt(h("anthropic-ratelimit-unified-7d-reset"), 10) || 0,
154
+ claim: h("anthropic-ratelimit-unified-representative-claim"),
155
+ plan: inferPlan(requestsLimit),
156
+ requestsLimit,
157
+ lastUpdated: Date.now(),
158
+ };
159
+ }
160
+ export async function startServer(opts = {}) {
161
+ const port = opts.port ?? PROXY_PORT;
162
+ // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
163
+ // Priority: explicit option > LITELLM_URL env var > direct to Anthropic
164
+ const litellmUrl = opts.litellmUrl ?? LITELLM_URL;
165
+ const target = litellmUrl ?? "https://api.anthropic.com";
166
+ const mode = litellmUrl ? "litellm" : "standalone";
167
+ const accountsPath = opts.accountsPath;
168
+ if (!accountsFileExists(accountsPath)) {
169
+ console.error(chalk.red("\n✗ accounts.json not found."));
170
+ console.error(chalk.yellow(" Run: cc-router setup\n"));
171
+ process.exit(1);
172
+ }
173
+ migrateLegacyAccountProviders(accountsPath);
174
+ const accounts = accountsPath ? readAccountsFromPath(accountsPath) : loadAccounts();
175
+ const openAIAccounts = loadOpenAIAccounts(accountsPath);
176
+ if (accounts.length === 0 && openAIAccounts.length === 0) {
177
+ console.error(chalk.red("\n✗ No accounts found in accounts.json."));
178
+ console.error(chalk.yellow(" Run: cc-router setup\n"));
179
+ process.exit(1);
180
+ }
181
+ const pool = new TokenPool(accounts);
182
+ const sessionRouter = new SessionRouter(pool);
183
+ const createRoutingMetricsResolver = () => {
184
+ const activeSessionCounts = sessionRouter.getActiveSessionCountsSnapshot();
185
+ return accountId => ({
186
+ inFlightRequests: pool.getInFlight(accountId),
187
+ activeSessions: activeSessionCounts.get(accountId) ?? 0,
188
+ coolingDown: pool.isCoolingDown(accountId),
189
+ });
190
+ };
191
+ const pickOpenAIAccount = createOpenAIAccountPicker(openAIAccounts);
192
+ const initialConfig = readConfig();
193
+ const modelRouting = initialConfig.modelRouting ?? {};
194
+ // Log when the pool falls back to a capped account — makes the cap bypass
195
+ // visible in the dashboard's "RECENT ACTIVITY" instead of being silent.
196
+ pool.onCapBypass = (a) => {
197
+ const msg = `all accounts capped — routing to ${a.id} (5h: ${Math.round(a.rateLimits.fiveHourUtil * 100)}%, 7d: ${Math.round(a.rateLimits.sevenDayUtil * 100)}%)`;
198
+ logError(a.id, 0, msg);
199
+ stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "error", details: msg });
200
+ };
201
+ // Surface rate-limit recovery in the dashboard so users see the account
202
+ // rejoin the rotation instead of wondering why it stayed red.
203
+ pool.onCooldownExpired = (a) => {
204
+ const msg = `${a.id} cooldown expired — rate limit cleared`;
205
+ stats.addLog({ ts: Date.now(), accountId: a.id, model: "-", type: "route", details: msg });
206
+ };
207
+ startRefreshLoop(accounts);
208
+ startOpenAIRefreshLoop(openAIAccounts, saveOpenAIAccounts);
209
+ const app = express();
210
+ const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
211
+ // ─── Proxy auth middleware ─────────────────────────────────────────────────
212
+ // If a proxySecret is configured, all requests must present it as EITHER
213
+ // "Authorization: Bearer <secret>" (Claude Code CLI, HTTP clients)
214
+ // OR "x-api-key: <secret>" (Claude Desktop via mitmproxy, Anthropic SDK)
215
+ // The /cc-router/health endpoint is always exempt so monitoring and PM2
216
+ // healthchecks keep working.
217
+ const { proxySecret } = initialConfig;
218
+ const secretBuf = proxySecret ? Buffer.from(proxySecret, "utf-8") : null;
219
+ // Pull the presented secret from either accepted header.
220
+ const presentedSecret = (req) => {
221
+ const auth = req.headers["authorization"] ?? "";
222
+ const bearerToken = auth.startsWith("Bearer ") ? auth.slice(7) : "";
223
+ const apiKey = req.headers["x-api-key"] ?? "";
224
+ return bearerToken || apiKey;
225
+ };
226
+ // Constant-time comparison with the configured secret (length pre-check is
227
+ // required — timingSafeEqual throws on length mismatch).
228
+ const secretMatches = (presented) => {
229
+ if (!secretBuf)
230
+ return false;
231
+ const presentedBuf = Buffer.from(presented, "utf-8");
232
+ return presentedBuf.length === secretBuf.length && timingSafeEqual(presentedBuf, secretBuf);
233
+ };
234
+ if (secretBuf) {
235
+ app.use((req, res, next) => {
236
+ if (req.path === "/cc-router/health")
237
+ return next();
238
+ if (!secretMatches(presentedSecret(req))) {
239
+ res.status(401).json({
240
+ type: "error",
241
+ error: { type: "authentication_error", message: "Invalid or missing proxy authentication token" },
242
+ });
243
+ return;
244
+ }
245
+ next();
246
+ });
247
+ }
248
+ // ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
249
+ // Always reachable without auth so PM2/monitoring liveness checks keep
250
+ // working — but the DETAILED payload (account IDs, plan tier, usage, recent
251
+ // request paths) is disclosure-sensitive. Return it only to an authenticated
252
+ // caller. When no secret is configured the server is loopback-only (enforced
253
+ // before app.listen), so returning full detail to localhost is acceptable.
254
+ app.get("/cc-router/health", (req, res) => {
255
+ // Sweep expired cooldowns on each poll so the dashboard reflects recovery
256
+ // even during idle periods when no /v1 request would trigger getNext().
257
+ pool.sweepExpiredCooldowns();
258
+ const resolveRoutingMetrics = createRoutingMetricsResolver();
259
+ const accountViews = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics);
260
+ const status = accountViews.some(a => a.healthy) ? "ok" : "degraded";
261
+ if (secretBuf && !secretMatches(presentedSecret(req))) {
262
+ res.json({ status });
263
+ return;
264
+ }
265
+ res.json({
266
+ status,
267
+ mode,
268
+ target,
269
+ operational: createOperationalStatus({
270
+ mode,
271
+ target,
272
+ authRequired: Boolean(proxySecret),
273
+ accounts: accountViews,
274
+ modelRouting,
275
+ }),
276
+ uptime: stats.getUptimeSeconds(),
277
+ totalRequests: stats.totalRequests,
278
+ totalErrors: stats.totalErrors,
279
+ totalRefreshes: stats.totalRefreshes,
280
+ totalCacheReadTokens: stats.totalCacheReadTokens,
281
+ totalCacheCreationTokens: stats.totalCacheCreationTokens,
282
+ totalInputTokens: stats.totalInputTokens,
283
+ totalOutputTokens: stats.totalOutputTokens,
284
+ accounts: accountViews,
285
+ recentLogs: stats.getRecentLogs(50),
286
+ });
287
+ });
288
+ // ─── Account management endpoints (authenticated) ─────────────────────────
289
+ // These are mounted BEFORE the /v1/* proxy middleware so they don't get
290
+ // forwarded to Anthropic. express.json() is scoped to this sub-router so
291
+ // the SSE streaming on /v1/* is never touched (see comment at /v1 handler).
292
+ const accountsRouter = express.Router();
293
+ accountsRouter.use(express.json({ limit: "32kb" }));
294
+ // Shape returned to clients — NEVER includes access/refresh tokens.
295
+ accountsRouter.get("/", (_req, res) => {
296
+ const resolveRoutingMetrics = createRoutingMetricsResolver();
297
+ res.json({
298
+ accounts: createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics),
299
+ });
300
+ });
301
+ accountsRouter.patch("/providers/:provider", (req, res) => {
302
+ const providerParam = req.params.provider;
303
+ if (providerParam !== "anthropic_subscription" && providerParam !== "openai_subscription") {
304
+ res.status(400).json({ error: "provider must be anthropic_subscription or openai_subscription" });
305
+ return;
306
+ }
307
+ const body = (req.body ?? {});
308
+ if (typeof body.enabled !== "boolean") {
309
+ res.status(400).json({ error: "enabled must be boolean" });
310
+ return;
311
+ }
312
+ const enabled = body.enabled;
313
+ const provider = providerParam;
314
+ const snapshots = {
315
+ anthropic: pool.getAll().map(a => ({ id: a.id, enabled: a.enabled })),
316
+ openai: openAIAccounts.map(a => ({ id: a.id, enabled: a.enabled })),
317
+ };
318
+ const applyRuntime = (enabled) => {
319
+ if (provider === "anthropic_subscription") {
320
+ for (const account of pool.getAll()) {
321
+ pool.updateAccount(account.id, { enabled });
322
+ }
323
+ }
324
+ else {
325
+ for (const account of openAIAccounts) {
326
+ account.enabled = enabled;
327
+ }
328
+ }
329
+ };
330
+ const rollback = () => {
331
+ for (const snapshot of snapshots.anthropic) {
332
+ pool.updateAccount(snapshot.id, { enabled: snapshot.enabled });
333
+ }
334
+ for (const snapshot of snapshots.openai) {
335
+ const account = openAIAccounts.find(a => a.id === snapshot.id);
336
+ if (account)
337
+ account.enabled = snapshot.enabled;
338
+ }
339
+ };
340
+ applyRuntime(enabled);
341
+ try {
342
+ const changed = persistProviderEnabledState({
343
+ provider,
344
+ enabled,
345
+ accountIds: pool.getAll().map(account => account.id),
346
+ persist: () => setProviderAccountsEnabled(provider, enabled, accountsPath),
347
+ invalidateAccount: accountId => sessionRouter.invalidateAccount(accountId),
348
+ });
349
+ res.json({ provider, enabled, changed });
350
+ }
351
+ catch (err) {
352
+ rollback();
353
+ const message = err instanceof Error ? err.message : String(err);
354
+ logError("accounts", 0, `Failed to persist provider state: ${message}`);
355
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
356
+ }
357
+ });
358
+ /**
359
+ * Persist the pool to disk, returning a structured result instead of
360
+ * throwing. Callers hold a rollback closure for in-memory state in case
361
+ * the disk write fails — so a ENOSPC / EACCES doesn't leave the server
362
+ * silently out of sync with accounts.json.
363
+ */
364
+ const tryPersist = (rollback) => {
365
+ try {
366
+ saveAccounts(pool.getAll());
367
+ return { ok: true };
368
+ }
369
+ catch (err) {
370
+ const message = err instanceof Error ? err.message : String(err);
371
+ try {
372
+ rollback();
373
+ }
374
+ catch { /* best effort */ }
375
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
376
+ return { ok: false, message };
377
+ }
378
+ };
379
+ accountsRouter.patch("/:id", (req, res) => {
380
+ const { id } = req.params;
381
+ const body = (req.body ?? {});
382
+ const patch = {};
383
+ if (body.enabled !== undefined) {
384
+ if (typeof body.enabled !== "boolean") {
385
+ res.status(400).json({ error: "enabled must be boolean" });
386
+ return;
387
+ }
388
+ patch.enabled = body.enabled;
389
+ }
390
+ for (const key of ["sessionLimitPercent", "weeklyLimitPercent"]) {
391
+ const v = body[key];
392
+ if (v === undefined)
393
+ continue;
394
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 100) {
395
+ res.status(400).json({ error: `${key} must be a number between 0 and 100` });
396
+ return;
397
+ }
398
+ patch[key] = v;
399
+ }
400
+ // Snapshot the previous values so we can roll back on persistence failure
401
+ const existing = pool.findById(id);
402
+ if (!existing) {
403
+ res.status(404).json({ error: `Account "${id}" not found` });
404
+ return;
405
+ }
406
+ const prev = {
407
+ enabled: existing.enabled,
408
+ sessionLimitPercent: existing.sessionLimitPercent,
409
+ weeklyLimitPercent: existing.weeklyLimitPercent,
410
+ };
411
+ const updated = pool.updateAccount(id, patch);
412
+ if (!updated) {
413
+ res.status(404).json({ error: `Account "${id}" not found` });
414
+ return;
415
+ }
416
+ const result = tryPersist(() => {
417
+ pool.updateAccount(id, prev);
418
+ });
419
+ if (!result.ok) {
420
+ res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
421
+ return;
422
+ }
423
+ if (patch.enabled === false)
424
+ sessionRouter.invalidateAccount(id);
425
+ res.json({
426
+ account: publicAnthropicAccountView(updated, createRoutingMetricsResolver()(updated.id)),
427
+ });
428
+ });
429
+ accountsRouter.post("/", (req, res) => {
430
+ const body = (req.body ?? {});
431
+ const required = ["id", "accessToken", "refreshToken", "expiresAt"];
432
+ for (const k of required) {
433
+ if (body[k] === undefined || body[k] === null || body[k] === "") {
434
+ res.status(400).json({ error: `Missing required field: ${k}` });
435
+ return;
436
+ }
437
+ }
438
+ if (typeof body.id !== "string" || typeof body.accessToken !== "string" ||
439
+ typeof body.refreshToken !== "string" || typeof body.expiresAt !== "number") {
440
+ res.status(400).json({ error: "Invalid field types on account record" });
441
+ return;
442
+ }
443
+ if (pool.findById(body.id)) {
444
+ res.status(409).json({ error: `Account "${body.id}" already exists` });
445
+ return;
446
+ }
447
+ const record = {
448
+ id: body.id,
449
+ accessToken: body.accessToken,
450
+ refreshToken: body.refreshToken,
451
+ expiresAt: body.expiresAt,
452
+ scopes: Array.isArray(body.scopes) ? body.scopes : ["user:inference", "user:profile"],
453
+ enabled: body.enabled,
454
+ sessionLimitPercent: body.sessionLimitPercent,
455
+ weeklyLimitPercent: body.weeklyLimitPercent,
456
+ };
457
+ let added;
458
+ try {
459
+ added = pool.addAccount(record);
460
+ }
461
+ catch (err) {
462
+ res.status(400).json({ error: err.message });
463
+ return;
464
+ }
465
+ const result = tryPersist(() => {
466
+ pool.removeAccount(record.id);
467
+ });
468
+ if (!result.ok) {
469
+ res.status(500).json({ error: `Failed to persist accounts.json: ${result.message}` });
470
+ return;
471
+ }
472
+ res.status(201).json({
473
+ account: publicAnthropicAccountView(added, createRoutingMetricsResolver()(added.id)),
474
+ });
475
+ });
476
+ accountsRouter.delete("/:id", async (req, res) => {
477
+ 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
+ const existing = pool.findById(id);
486
+ if (!existing) {
487
+ res.status(404).json({ error: `Account "${id}" not found` });
488
+ return;
489
+ }
490
+ try {
491
+ await deleteAnthropicAccountTransaction({
492
+ id,
493
+ pool,
494
+ sessionRouter,
495
+ persist: saveAccounts,
496
+ });
497
+ }
498
+ catch (err) {
499
+ const message = err instanceof Error ? err.message : String(err);
500
+ const status = accountDeletionStatusCode(err);
501
+ if (status === 409) {
502
+ res.status(409).json({ error: message });
503
+ return;
504
+ }
505
+ logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
506
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
507
+ return;
508
+ }
509
+ res.json({ ok: true, id });
510
+ });
511
+ app.use("/cc-router/accounts", accountsRouter);
512
+ mountModelsRoute(app, {
513
+ getAnthropicAccounts: () => pool.getAll(),
514
+ getOpenAIAccounts: () => openAIAccounts,
515
+ getModelRouting: () => modelRouting,
516
+ setModelRouting: async (next) => {
517
+ Object.keys(modelRouting).forEach(key => {
518
+ delete modelRouting[key];
519
+ });
520
+ Object.assign(modelRouting, next);
521
+ writeConfig({ ...readConfig(), modelRouting: next });
522
+ },
523
+ prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, saveOpenAIAccounts),
524
+ });
525
+ mountResponsesRoutes(app, {
526
+ getOpenAIAccount: pickOpenAIAccount,
527
+ prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, saveOpenAIAccounts),
528
+ modelRouting,
529
+ });
530
+ mountMessagesCrossProviderRoute(app, {
531
+ getOpenAIAccount: pickOpenAIAccount,
532
+ prepareOpenAIAccount: (account) => prepareOpenAIAccountForRequest(account, openAIAccounts, saveOpenAIAccounts),
533
+ modelRouting,
534
+ });
535
+ // ─── Proxy middleware ──────────────────────────────────────────────────────
536
+ // IMPORTANT: selfHandleResponse must be false (default) for SSE streaming to
537
+ // work transparently. Setting it to true breaks streaming.
538
+ const proxy = createAnthropicProxy({
539
+ target,
540
+ timeoutMs: proxyRequestTimeoutMs,
541
+ on: {
542
+ proxyReq: (proxyReq, req) => {
543
+ const account = req._ccAccount;
544
+ if (!account)
545
+ return;
546
+ // Replace the placeholder/proxy auth token with the real OAuth token.
547
+ // Claude Code sends ANTHROPIC_AUTH_TOKEN as "Authorization: Bearer proxy-managed".
548
+ // We replace it with the real OAuth token for this account.
549
+ proxyReq.setHeader("authorization", `Bearer ${account.tokens.accessToken}`);
550
+ // Remove x-api-key if present — OAuth authentication uses Authorization Bearer,
551
+ // not x-api-key. Having both set can cause conflicts at Anthropic's side.
552
+ proxyReq.removeHeader("x-api-key");
553
+ // CRITICAL: api.anthropic.com requires the "oauth-2025-04-20" beta flag to
554
+ // accept OAuth tokens (sk-ant-oat01-*). Without it the request is rejected
555
+ // with "OAuth authentication is currently not supported."
556
+ // APPEND — do NOT replace — so existing betas (tools, computer-use, etc.) are preserved.
557
+ const existingBeta = proxyReq.getHeader("anthropic-beta");
558
+ const betas = existingBeta
559
+ ? String(existingBeta).split(",").map(b => b.trim()).filter(Boolean)
560
+ : [];
561
+ if (!betas.includes("oauth-2025-04-20")) {
562
+ betas.push("oauth-2025-04-20");
563
+ proxyReq.setHeader("anthropic-beta", betas.join(","));
564
+ }
565
+ // All other headers are forwarded automatically by http-proxy-middleware:
566
+ // anthropic-version — required by Anthropic API
567
+ // X-Claude-Code-Session-Id — session aggregation header sent by Claude Code
568
+ // content-type — always application/json
569
+ if (req._ccRawBody) {
570
+ proxyReq.setHeader("content-length", Buffer.byteLength(req._ccRawBody));
571
+ proxyReq.write(req._ccRawBody);
572
+ }
573
+ },
574
+ proxyRes: (proxyRes, req, response) => {
575
+ const account = req._ccAccount;
576
+ const route = req._ccRoute;
577
+ if (!account)
578
+ return;
579
+ const status = proxyRes.statusCode ?? 0;
580
+ const durationMs = req._startTime
581
+ ? Date.now() - req._startTime
582
+ : undefined;
583
+ // Complete the pending log entry with response info
584
+ const pendingLog = req._pendingLog ?? {
585
+ ts: Date.now(),
586
+ accountId: account.id,
587
+ model: "-",
588
+ type: "route",
589
+ };
590
+ pendingLog.statusCode = status;
591
+ if (durationMs !== undefined)
592
+ pendingLog.durationMs = durationMs;
593
+ const cooldownSeconds = route
594
+ ? applyUpstreamFailureRouting(status, proxyRes.headers["retry-after"], route, sessionRouter, pool)
595
+ : undefined;
596
+ if (status === 401) {
597
+ // Token invalid or expired mid-request.
598
+ // Forward the 401 to the client (Claude Code will retry on 401).
599
+ // Schedule a background refresh so the next request succeeds.
600
+ stats.totalErrors++;
601
+ account.errorCount++;
602
+ pendingLog.type = "error";
603
+ pendingLog.details = route
604
+ ? routeFailureDetails(route, "token-invalid")
605
+ : "token-invalid";
606
+ logError(account.id, 401, "Token invalid — scheduling background refresh");
607
+ void refreshAccountIfCurrent(account, pool).catch(console.error);
608
+ }
609
+ else if (status === 429) {
610
+ // Rate limited — put account on cooldown for Retry-After seconds.
611
+ stats.totalErrors++;
612
+ account.errorCount++;
613
+ const retryAfter = cooldownSeconds ?? 60;
614
+ pendingLog.type = "error";
615
+ pendingLog.details = route
616
+ ? routeFailureDetails(route, "rate-limited")
617
+ : "rate-limited";
618
+ logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
619
+ }
620
+ else if (status === 529) {
621
+ // Anthropic service overloaded — short cooldown on this account.
622
+ stats.totalErrors++;
623
+ account.errorCount++;
624
+ pendingLog.type = "error";
625
+ pendingLog.details = route
626
+ ? routeFailureDetails(route, "service-overloaded")
627
+ : "service-overloaded";
628
+ logError(account.id, 529, "Service overloaded — cooldown 30s");
629
+ }
630
+ // ── Capture rate limit utilization from response headers ────────────
631
+ const rl = extractRateLimits(proxyRes.headers);
632
+ if (rl)
633
+ account.rateLimits = rl;
634
+ const entry = pendingLog;
635
+ stats.addLog(entry);
636
+ // ── Capture token usage from Anthropic response body ─────────────────
637
+ // SSE streams carry usage across two events:
638
+ // message_start → input_tokens, cache_read/creation_input_tokens
639
+ // message_delta → output_tokens
640
+ // Non-streaming JSON carries all fields in a single usage object.
641
+ // We use incremental line parsing (not buffering) so we can capture
642
+ // both events without holding the full stream in memory.
643
+ const contentType = String(proxyRes.headers["content-type"] ?? "");
644
+ const encoding = String(proxyRes.headers["content-encoding"] ?? "");
645
+ const isCompressed = /gzip|br|deflate/.test(encoding);
646
+ const streamTracker = createStreamLifecycleTracker(req._startTime ?? Date.now(), !isCompressed && contentType.includes("text/event-stream"));
647
+ entry.streamLifecycle = streamTracker.state;
648
+ streamTracker.attach(proxyRes, response);
649
+ proxyRes.on("data", (chunk) => streamTracker.observeChunk(chunk));
650
+ if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
651
+ const isSSE = contentType.includes("text/event-stream");
652
+ if (isSSE) {
653
+ let lineBuf = "";
654
+ let gotInput = false;
655
+ let gotOutput = false;
656
+ proxyRes.on("data", (chunk) => {
657
+ if (gotInput && gotOutput)
658
+ return;
659
+ lineBuf += chunk.toString("utf8");
660
+ const lines = lineBuf.split("\n");
661
+ lineBuf = lines.pop() ?? ""; // keep incomplete last line
662
+ for (const line of lines) {
663
+ if (!line.startsWith("data: "))
664
+ continue;
665
+ try {
666
+ const evt = JSON.parse(line.slice(6));
667
+ if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
668
+ applyInputUsage(entry, evt.message.usage);
669
+ gotInput = true;
670
+ }
671
+ if (!gotOutput && evt.type === "message_delta" && evt.usage) {
672
+ applyOutputUsage(entry, evt.usage);
673
+ gotOutput = true;
674
+ }
675
+ }
676
+ catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
677
+ }
678
+ });
679
+ }
680
+ else {
681
+ // Non-streaming JSON: buffer full body then parse once
682
+ let buf = "";
683
+ proxyRes.on("data", (chunk) => { buf += chunk.toString("utf8"); });
684
+ proxyRes.on("end", () => {
685
+ try {
686
+ const body = JSON.parse(buf);
687
+ if (body.usage) {
688
+ applyInputUsage(entry, body.usage);
689
+ applyOutputUsage(entry, body.usage);
690
+ }
691
+ }
692
+ catch { /* ignore */ }
693
+ });
694
+ }
695
+ }
696
+ },
697
+ error: (err, _req, res) => {
698
+ const request = _req;
699
+ request._ccReleaseLease?.();
700
+ stats.totalErrors++;
701
+ logError("proxy", 0, err.message);
702
+ // Complete the pending log entry for connection-level errors
703
+ const pendingLog = request._pendingLog;
704
+ if (pendingLog) {
705
+ pendingLog.type = "error";
706
+ pendingLog.statusCode = 0;
707
+ pendingLog.details = request._ccRoute
708
+ ? routeFailureDetails(request._ccRoute, "proxy-error")
709
+ : "proxy-error";
710
+ if (request._startTime) {
711
+ pendingLog.durationMs = Date.now() - request._startTime;
712
+ }
713
+ stats.addLog(pendingLog);
714
+ }
715
+ // res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
716
+ if (res instanceof ServerResponse && !res.headersSent) {
717
+ // Match Anthropic's error response format so Claude Code handles it gracefully
718
+ res.writeHead(502, { "Content-Type": "application/json" });
719
+ res.end(JSON.stringify({
720
+ type: "error",
721
+ error: { type: "proxy_error", message: err.message },
722
+ }));
723
+ }
724
+ },
725
+ },
726
+ });
727
+ // ─── /v1/* — select account, refresh if needed, then proxy ───────────────
728
+ // CRITICAL: Do NOT use express.json() here — it consumes the body stream
729
+ // and breaks SSE streaming passthrough.
730
+ app.use("/v1", createAnthropicRoutingMiddleware({
731
+ sessionRouter,
732
+ onEmptyPool: (err, _req, res) => {
733
+ stats.totalErrors++;
734
+ logError("proxy", 503, err.message);
735
+ res.status(503).json({
736
+ type: "error",
737
+ error: { type: "no_accounts", message: err.message },
738
+ });
739
+ },
740
+ }), createAnthropicRefreshMiddleware({
741
+ needsRefresh,
742
+ refresh: account => refreshAccountIfCurrent(account, pool),
743
+ onRefreshFailure: (account) => {
744
+ stats.totalErrors++;
745
+ logError(account.id, 401, "Token refresh failed");
746
+ },
747
+ }), (req, _res, next) => {
748
+ const route = req._ccRoute;
749
+ const account = route.account;
750
+ req._startTime = Date.now();
751
+ const source = route.sessionId !== undefined
752
+ ? "cli"
753
+ : req.headers["x-api-key"]
754
+ ? "desktop"
755
+ : "api";
756
+ req._pendingLog = {
757
+ ts: Date.now(),
758
+ accountId: account.id,
759
+ model: "-",
760
+ type: "route",
761
+ method: req.method,
762
+ path: req.path,
763
+ source,
764
+ details: routeReasonDetails(route),
765
+ };
766
+ stats.totalRequests++;
767
+ logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
768
+ next();
769
+ }, proxy);
770
+ // ─── Catch-all — forward everything else (LiteLLM UI, /v1/models, etc.) ──
771
+ app.use("/", createProxyMiddleware({
772
+ target,
773
+ changeOrigin: true,
774
+ }));
775
+ // ─── Graceful shutdown ────────────────────────────────────────────────────
776
+ const shutdown = () => {
777
+ console.log(chalk.yellow("\nShutting down — saving tokens..."));
778
+ saveAccounts(pool.getAll());
779
+ if (process.env["CC_ROUTER_DAEMON"] === "1") {
780
+ removePid();
781
+ }
782
+ process.exit(0);
783
+ };
784
+ process.on("SIGTERM", shutdown);
785
+ process.on("SIGINT", shutdown);
786
+ // ─── Update handling ──────────────────────────────────────────────────────
787
+ // Auto-update is OFF by default: installing code unattended from the npm
788
+ // registry (no signature/provenance check) turns any publish-channel
789
+ // compromise into RCE across every running proxy. Default behaviour is
790
+ // notify-only, like pip/npm. Opt in explicitly with `autoUpdate: true` in
791
+ // config or CC_ROUTER_AUTO_UPDATE=1.
792
+ const cfg = readConfig();
793
+ const autoUpdate = (cfg.autoUpdate === true || process.env["CC_ROUTER_AUTO_UPDATE"] === "1") &&
794
+ process.env["CC_ROUTER_NO_AUTO_UPDATE"] !== "1";
795
+ if (autoUpdate) {
796
+ const AUTO_UPDATE_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
797
+ const runAutoUpdate = async () => {
798
+ try {
799
+ const check = await checkForUpdate();
800
+ if (!check.updateAvailable || check.diff === "major")
801
+ return;
802
+ console.log(chalk.cyan(`[auto-update] v${check.current} → v${check.latest} (${check.diff})`));
803
+ const ok = await performUpdate(check.latest);
804
+ if (ok) {
805
+ console.log(chalk.green("[auto-update] Restarting with new version..."));
806
+ saveAccounts(pool.getAll());
807
+ restartSelf();
808
+ }
809
+ }
810
+ catch (err) {
811
+ console.error(chalk.gray(`[auto-update] Check failed: ${err.message}`));
812
+ }
813
+ };
814
+ // First check 60s after startup, then every 6h
815
+ setTimeout(runAutoUpdate, 60_000).unref();
816
+ setInterval(runAutoUpdate, AUTO_UPDATE_INTERVAL).unref();
817
+ }
818
+ else {
819
+ // Notify-only: a single background check shortly after startup. Never
820
+ // installs — just prints the banner telling the user how to update.
821
+ setTimeout(() => {
822
+ void checkForUpdate()
823
+ .then(printUpdateBanner)
824
+ .catch(() => { });
825
+ }, 60_000).unref();
826
+ }
827
+ // ─── Start ────────────────────────────────────────────────────────────────
828
+ // HOST env var lets teams bind to 0.0.0.0 for LAN/VPS shared access.
829
+ // Defaults to 127.0.0.1 (localhost-only) for single-user safety.
830
+ const host = process.env["HOST"] ?? "127.0.0.1";
831
+ // Hard safety net: never expose the proxy on a non-loopback interface without
832
+ // a secret. Every start path funnels through app.listen(host), so this guards
833
+ // the daemon / service / HOST=0.0.0.0 cases the interactive wizard can't.
834
+ const isLoopbackHost = (h) => h === "127.0.0.1" || h === "::1" || h === "localhost" || h === "::ffff:127.0.0.1";
835
+ if (!isLoopbackHost(host) && !proxySecret) {
836
+ console.error(chalk.red(`\n✗ Refusing to bind ${host}:${port} without a proxy secret.\n` +
837
+ ` Exposing the proxy to the network without authentication would let\n` +
838
+ ` anyone who can reach it use your Claude/OpenAI accounts.\n` +
839
+ ` Fix: run 'cc-router start' and set a password when asked, or add a\n` +
840
+ ` "proxySecret" to ~/.cc-router/config.json. To bind localhost only,\n` +
841
+ ` unset HOST (or set HOST=127.0.0.1).\n`));
842
+ process.exit(1);
843
+ }
844
+ app.listen(port, host, () => {
845
+ // Write PID for daemon/service process management
846
+ if (process.env["CC_ROUTER_DAEMON"] === "1") {
847
+ writePid(process.pid);
848
+ }
849
+ const totalAccountCount = accounts.length + openAIAccounts.length;
850
+ logStartup(port, host, mode, target, {
851
+ anthropic: accounts.length,
852
+ openai: openAIAccounts.length,
853
+ });
854
+ console.log(autoUpdate
855
+ ? chalk.gray(" Auto-update: enabled (patch/minor)")
856
+ : chalk.gray(" Auto-update: off (notify-only) — run 'cc-router update' to install"));
857
+ // Anonymous telemetry — fire-and-forget, never blocks proxy startup.
858
+ try {
859
+ const telemetryState = loadTelemetryState();
860
+ // First-run detection: if the install is brand new, emit app_started too
861
+ const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
862
+ if (firstRunAge < 5 * 60 * 1000) {
863
+ void trackEvent("app_started", { first_run: true });
864
+ }
865
+ void trackEvent("proxy_started", {
866
+ account_count: totalAccountCount,
867
+ mode,
868
+ });
869
+ startHeartbeat(totalAccountCount);
870
+ }
871
+ catch {
872
+ // never let telemetry break the proxy
873
+ }
874
+ });
875
+ }