@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.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- 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
|
+
}
|