ai-cc-router 0.1.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/Dockerfile +32 -0
- package/LICENSE +21 -0
- package/README.md +395 -0
- package/accounts.example.json +16 -0
- package/dist/cli/cmd-accounts.js +142 -0
- package/dist/cli/cmd-configure.js +37 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +248 -0
- package/dist/cli/cmd-start.js +80 -0
- package/dist/cli/cmd-status.js +46 -0
- package/dist/cli/cmd-stop.js +128 -0
- package/dist/cli/index.js +38 -0
- package/dist/config/manager.js +56 -0
- package/dist/config/paths.js +11 -0
- package/dist/proxy/logger.js +34 -0
- package/dist/proxy/server.js +178 -0
- package/dist/proxy/stats.js +21 -0
- package/dist/proxy/token-pool.js +47 -0
- package/dist/proxy/token-refresher.js +114 -0
- package/dist/proxy/types.js +1 -0
- package/dist/ui/Dashboard.js +110 -0
- package/dist/utils/claude-config.js +76 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/token-extractor.js +90 -0
- package/dist/utils/token-validator.js +24 -0
- package/docker-compose.yml +45 -0
- package/litellm-config.yaml +44 -0
- package/package.json +64 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { PROXY_PORT } from "../config/paths.js";
|
|
3
|
+
export function registerStatus(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("status")
|
|
6
|
+
.description("Live dashboard: account health, request counts, recent routing log")
|
|
7
|
+
.option("--port <port>", "Proxy port to connect to", String(PROXY_PORT))
|
|
8
|
+
.option("--json", "Output current stats as JSON and exit (non-interactive)")
|
|
9
|
+
.action(async (opts) => {
|
|
10
|
+
const port = parseInt(opts.port, 10);
|
|
11
|
+
if (opts.json) {
|
|
12
|
+
await jsonOutput(port);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
await launchDashboard(port);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async function jsonOutput(port) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`http://localhost:${port}/cc-router/health`, {
|
|
21
|
+
signal: AbortSignal.timeout(2_000),
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
console.error(chalk.red(`Proxy returned HTTP ${res.status}`));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
console.log(JSON.stringify(await res.json(), null, 2));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
console.error(chalk.red(`Cannot connect to proxy at http://localhost:${port}`));
|
|
31
|
+
console.error(chalk.gray("Is it running? Start with: cc-router start"));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function launchDashboard(port) {
|
|
36
|
+
// Dynamic imports keep these heavy deps out of the cold-start path
|
|
37
|
+
const [{ render }, { createElement }, { Dashboard }] = await Promise.all([
|
|
38
|
+
import("ink"),
|
|
39
|
+
import("react"),
|
|
40
|
+
import("../ui/Dashboard.js"),
|
|
41
|
+
]);
|
|
42
|
+
render(createElement(Dashboard, { port }), {
|
|
43
|
+
// Let Ink handle Ctrl+C — it calls exit() which cleanly unmounts
|
|
44
|
+
exitOnCtrlC: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { removeClaudeSettings, readClaudeProxySettings } from "../utils/claude-config.js";
|
|
5
|
+
import { isWindows } from "../utils/platform.js";
|
|
6
|
+
import { PROXY_PORT } from "../config/paths.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
export function registerStop(program) {
|
|
9
|
+
program
|
|
10
|
+
.command("stop")
|
|
11
|
+
.description("Stop the proxy and restore Claude Code to normal authentication")
|
|
12
|
+
.option("--keep-config", "Stop the proxy process but keep ~/.claude/settings.json untouched")
|
|
13
|
+
.action(async (opts) => {
|
|
14
|
+
await stopProxy({ revertConfig: !opts.keepConfig });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function registerRevert(program) {
|
|
18
|
+
program
|
|
19
|
+
.command("revert")
|
|
20
|
+
.description("Restore Claude Code to its normal authentication (removes proxy config)")
|
|
21
|
+
.action(async () => {
|
|
22
|
+
await stopProxy({ revertConfig: true });
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// ─── Core logic (shared by stop and revert) ────────────────────────────────
|
|
26
|
+
async function stopProxy({ revertConfig }) {
|
|
27
|
+
let anythingDone = false;
|
|
28
|
+
// 1. Stop the proxy process (PM2 if registered, else kill by port)
|
|
29
|
+
const stopped = await tryStopProcess();
|
|
30
|
+
if (stopped) {
|
|
31
|
+
console.log(chalk.green("✓ Proxy process stopped"));
|
|
32
|
+
anythingDone = true;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const running = await isProxyRunning();
|
|
36
|
+
if (running) {
|
|
37
|
+
console.log(chalk.yellow("⚠ Could not stop proxy automatically."));
|
|
38
|
+
console.log(chalk.gray(" If it's running in a terminal, press Ctrl+C there."));
|
|
39
|
+
console.log(chalk.gray(` Or kill manually: kill $(lsof -ti:${PROXY_PORT})`));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(chalk.gray(" Proxy is not running."));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// 2. Remove Claude Code proxy settings
|
|
46
|
+
if (revertConfig) {
|
|
47
|
+
const current = readClaudeProxySettings();
|
|
48
|
+
if (current.baseUrl) {
|
|
49
|
+
removeClaudeSettings();
|
|
50
|
+
console.log(chalk.green("✓ Removed proxy settings from ~/.claude/settings.json"));
|
|
51
|
+
console.log(chalk.gray(" Claude Code will use its normal authentication on next launch."));
|
|
52
|
+
anythingDone = true;
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
console.log(chalk.gray(" ~/.claude/settings.json already has no proxy config."));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!anythingDone) {
|
|
59
|
+
console.log(chalk.gray("\nNothing to do — proxy was not running and config was not set."));
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
console.log(chalk.green("\n✓ Done. Claude Code is back to normal."));
|
|
63
|
+
console.log(chalk.gray(" To re-enable the proxy: cc-router start"));
|
|
64
|
+
console.log(chalk.gray(" To reconfigure: cc-router configure\n"));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// ─── Process management ────────────────────────────────────────────────────
|
|
68
|
+
async function tryStopProcess() {
|
|
69
|
+
// Try PM2 first (Phase 5 service)
|
|
70
|
+
const pm2Stopped = await tryStopPm2();
|
|
71
|
+
if (pm2Stopped)
|
|
72
|
+
return true;
|
|
73
|
+
// Fall back to killing by port
|
|
74
|
+
return killByPort(PROXY_PORT);
|
|
75
|
+
}
|
|
76
|
+
async function tryStopPm2() {
|
|
77
|
+
try {
|
|
78
|
+
await execFileAsync("pm2", ["stop", "cc-router"]);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function killByPort(port) {
|
|
86
|
+
try {
|
|
87
|
+
if (isWindows()) {
|
|
88
|
+
// Windows: netstat to find PID, then taskkill
|
|
89
|
+
const { stdout } = await execFileAsync("netstat", ["-ano"]);
|
|
90
|
+
const match = stdout
|
|
91
|
+
.split("\n")
|
|
92
|
+
.find(line => line.includes(`:${port}`) && line.includes("LISTENING"));
|
|
93
|
+
if (!match)
|
|
94
|
+
return false;
|
|
95
|
+
const pid = match.trim().split(/\s+/).at(-1);
|
|
96
|
+
if (!pid || isNaN(Number(pid)))
|
|
97
|
+
return false;
|
|
98
|
+
await execFileAsync("taskkill", ["/PID", pid, "/F"]);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// macOS / Linux: lsof to find PIDs, then kill
|
|
103
|
+
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
|
|
104
|
+
const pids = stdout.trim().split("\n").filter(Boolean);
|
|
105
|
+
if (pids.length === 0)
|
|
106
|
+
return false;
|
|
107
|
+
// kill each PID — args are array, no shell injection possible
|
|
108
|
+
for (const pid of pids) {
|
|
109
|
+
await execFileAsync("kill", ["-TERM", pid]);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function isProxyRunning() {
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`http://localhost:${PROXY_PORT}/cc-router/health`, {
|
|
121
|
+
signal: AbortSignal.timeout(500),
|
|
122
|
+
});
|
|
123
|
+
return res.ok;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerSetup } from "./cmd-setup.js";
|
|
4
|
+
import { registerStart } from "./cmd-start.js";
|
|
5
|
+
import { registerStop, registerRevert } from "./cmd-stop.js";
|
|
6
|
+
import { registerStatus } from "./cmd-status.js";
|
|
7
|
+
import { registerAccounts } from "./cmd-accounts.js";
|
|
8
|
+
import { registerService } from "./cmd-service.js";
|
|
9
|
+
import { registerConfigure } from "./cmd-configure.js";
|
|
10
|
+
import { registerDocker } from "./cmd-docker.js";
|
|
11
|
+
const program = new Command();
|
|
12
|
+
program
|
|
13
|
+
.name("cc-router")
|
|
14
|
+
.description("Round-robin proxy for Claude Max OAuth tokens.\n" +
|
|
15
|
+
"Distributes Claude Code requests across multiple Claude Max accounts.")
|
|
16
|
+
.version("0.1.0")
|
|
17
|
+
.addHelpText("after", `
|
|
18
|
+
Examples:
|
|
19
|
+
$ cc-router setup # First-time wizard: extract tokens + configure Claude Code
|
|
20
|
+
$ cc-router start # Start proxy on localhost:3456
|
|
21
|
+
$ cc-router start --daemon # Start in background via PM2
|
|
22
|
+
$ cc-router status # Live dashboard with account stats
|
|
23
|
+
$ cc-router service install # Auto-start on system boot
|
|
24
|
+
$ cc-router accounts list # Show all configured accounts
|
|
25
|
+
$ cc-router revert # Restore Claude Code to normal (remove proxy config)
|
|
26
|
+
$ cc-router docker up # Full stack: cc-router + LiteLLM in Docker
|
|
27
|
+
$ cc-router docker down # Stop Docker stack
|
|
28
|
+
`);
|
|
29
|
+
registerSetup(program);
|
|
30
|
+
registerStart(program);
|
|
31
|
+
registerStop(program);
|
|
32
|
+
registerRevert(program);
|
|
33
|
+
registerStatus(program);
|
|
34
|
+
registerAccounts(program);
|
|
35
|
+
registerService(program);
|
|
36
|
+
registerConfigure(program);
|
|
37
|
+
registerDocker(program);
|
|
38
|
+
program.parse();
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
+
import { CONFIG_DIR, ACCOUNTS_PATH } from "./paths.js";
|
|
3
|
+
export function ensureConfigDir() {
|
|
4
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
5
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function accountsFileExists(path) {
|
|
9
|
+
return existsSync(path ?? ACCOUNTS_PATH);
|
|
10
|
+
}
|
|
11
|
+
export function readAccountsRaw() {
|
|
12
|
+
return readRawFromPath(ACCOUNTS_PATH);
|
|
13
|
+
}
|
|
14
|
+
function readRawFromPath(path) {
|
|
15
|
+
if (!existsSync(path))
|
|
16
|
+
return [];
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** Deserialize Account[] from an explicit file path */
|
|
25
|
+
export function readAccountsFromPath(path) {
|
|
26
|
+
return deserialize(readRawFromPath(path));
|
|
27
|
+
}
|
|
28
|
+
// Escritura atómica: escribe a .tmp y renombra — evita JSON corrupto si el proceso muere mid-write
|
|
29
|
+
export function writeAccountsAtomic(data) {
|
|
30
|
+
ensureConfigDir();
|
|
31
|
+
const tmp = ACCOUNTS_PATH + ".tmp";
|
|
32
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
33
|
+
renameSync(tmp, ACCOUNTS_PATH);
|
|
34
|
+
}
|
|
35
|
+
/** Deserialize flat AccountRecord[] from the default path into runtime Account[] */
|
|
36
|
+
export function loadAccounts() {
|
|
37
|
+
return deserialize(readAccountsRaw());
|
|
38
|
+
}
|
|
39
|
+
function deserialize(records) {
|
|
40
|
+
return records.map(a => ({
|
|
41
|
+
id: a.id,
|
|
42
|
+
tokens: {
|
|
43
|
+
accessToken: a.accessToken,
|
|
44
|
+
refreshToken: a.refreshToken,
|
|
45
|
+
expiresAt: a.expiresAt,
|
|
46
|
+
scopes: a.scopes ?? ["user:inference", "user:profile"],
|
|
47
|
+
},
|
|
48
|
+
healthy: true,
|
|
49
|
+
busy: false,
|
|
50
|
+
requestCount: 0,
|
|
51
|
+
errorCount: 0,
|
|
52
|
+
lastUsed: 0,
|
|
53
|
+
lastRefresh: 0,
|
|
54
|
+
consecutiveErrors: 0,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
// All paths support env var overrides so Docker can inject them via environment
|
|
4
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".cc-router");
|
|
5
|
+
export const ACCOUNTS_PATH = process.env["ACCOUNTS_PATH"] ??
|
|
6
|
+
path.join(CONFIG_DIR, "accounts.json");
|
|
7
|
+
export const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
|
8
|
+
export const PROXY_PORT = parseInt(process.env["PORT"] ?? "3456", 10);
|
|
9
|
+
export const LITELLM_PORT = 4000;
|
|
10
|
+
// When set, the server forwards to LiteLLM instead of Anthropic directly
|
|
11
|
+
export const LITELLM_URL = process.env["LITELLM_URL"];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
function ts() {
|
|
3
|
+
return new Date().toISOString().slice(11, 19); // HH:MM:SS
|
|
4
|
+
}
|
|
5
|
+
export function logRoute(accountId, requestCount, expiresInMin) {
|
|
6
|
+
console.log(chalk.gray(`[${ts()}]`) +
|
|
7
|
+
chalk.green(` → ${accountId}`) +
|
|
8
|
+
chalk.gray(` req#${requestCount}`) +
|
|
9
|
+
chalk.yellow(` exp=${expiresInMin}min`));
|
|
10
|
+
}
|
|
11
|
+
export function logRefresh(accountId, ok, expiresInMin) {
|
|
12
|
+
if (ok) {
|
|
13
|
+
console.log(chalk.yellow(`[${ts()}] [REFRESH] ${accountId}: OK — expires in ${expiresInMin}min`));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.log(chalk.red(`[${ts()}] [REFRESH] ${accountId}: FAILED`));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function logError(accountId, status, message) {
|
|
20
|
+
const statusStr = status > 0 ? ` HTTP ${status}` : "";
|
|
21
|
+
console.log(chalk.red(`[${ts()}] [ERROR] ${accountId}:${statusStr} ${message}`));
|
|
22
|
+
}
|
|
23
|
+
export function logStartup(port, host, mode, target, accountCount) {
|
|
24
|
+
const listen = host === "127.0.0.1" ? `http://localhost:${port}` : `http://${host}:${port}`;
|
|
25
|
+
console.log(chalk.cyan(`
|
|
26
|
+
╔══════════════════════════════════════════════╗
|
|
27
|
+
║ CC-Router ║
|
|
28
|
+
║ Listening: ${listen.padEnd(33)}║
|
|
29
|
+
║ Mode : ${mode.padEnd(33)}║
|
|
30
|
+
║ Target : ${target.slice(0, 33).padEnd(33)}║
|
|
31
|
+
║ Accounts : ${String(accountCount).padEnd(33)}║
|
|
32
|
+
╚══════════════════════════════════════════════╝
|
|
33
|
+
`));
|
|
34
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createProxyMiddleware } from "http-proxy-middleware";
|
|
3
|
+
import { ServerResponse } from "http";
|
|
4
|
+
import { TokenPool } from "./token-pool.js";
|
|
5
|
+
import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
|
|
6
|
+
import { loadAccounts, accountsFileExists, readAccountsFromPath } from "../config/manager.js";
|
|
7
|
+
import { logRoute, logError, logStartup } from "./logger.js";
|
|
8
|
+
import { stats } from "./stats.js";
|
|
9
|
+
import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
export async function startServer(opts = {}) {
|
|
12
|
+
const port = opts.port ?? PROXY_PORT;
|
|
13
|
+
// Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
|
|
14
|
+
// Priority: explicit option > LITELLM_URL env var > direct to Anthropic
|
|
15
|
+
const litellmUrl = opts.litellmUrl ?? LITELLM_URL;
|
|
16
|
+
const target = litellmUrl ?? "https://api.anthropic.com";
|
|
17
|
+
const mode = litellmUrl ? "litellm" : "standalone";
|
|
18
|
+
const accountsPath = opts.accountsPath;
|
|
19
|
+
if (!accountsFileExists(accountsPath)) {
|
|
20
|
+
console.error(chalk.red("\n✗ accounts.json not found."));
|
|
21
|
+
console.error(chalk.yellow(" Run: cc-router setup\n"));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
const accounts = accountsPath ? readAccountsFromPath(accountsPath) : loadAccounts();
|
|
25
|
+
if (accounts.length === 0) {
|
|
26
|
+
console.error(chalk.red("\n✗ No accounts found in accounts.json."));
|
|
27
|
+
console.error(chalk.yellow(" Run: cc-router setup\n"));
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
const pool = new TokenPool(accounts);
|
|
31
|
+
startRefreshLoop(accounts);
|
|
32
|
+
const app = express();
|
|
33
|
+
// ─── Health endpoint (cc-router internal, NOT proxied) ────────────────────
|
|
34
|
+
app.get("/cc-router/health", (_req, res) => {
|
|
35
|
+
res.json({
|
|
36
|
+
status: pool.getHealthy().length > 0 ? "ok" : "degraded",
|
|
37
|
+
mode,
|
|
38
|
+
target,
|
|
39
|
+
uptime: stats.getUptimeSeconds(),
|
|
40
|
+
totalRequests: stats.totalRequests,
|
|
41
|
+
totalErrors: stats.totalErrors,
|
|
42
|
+
totalRefreshes: stats.totalRefreshes,
|
|
43
|
+
accounts: pool.getStats(),
|
|
44
|
+
recentLogs: stats.getRecentLogs(),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
// ─── Proxy middleware ──────────────────────────────────────────────────────
|
|
48
|
+
// IMPORTANT: selfHandleResponse must be false (default) for SSE streaming to
|
|
49
|
+
// work transparently. Setting it to true breaks streaming.
|
|
50
|
+
const proxy = createProxyMiddleware({
|
|
51
|
+
target,
|
|
52
|
+
changeOrigin: true,
|
|
53
|
+
// Express strips the /v1 mount prefix from req.url before passing it to middleware.
|
|
54
|
+
// pathRewrite restores it so the proxy forwards /v1/messages, not /messages.
|
|
55
|
+
pathRewrite: (path) => `/v1${path}`,
|
|
56
|
+
// Long timeouts — Claude Code requests can be >5min (thinking, agents)
|
|
57
|
+
proxyTimeout: 5 * 60 * 1000,
|
|
58
|
+
timeout: 5 * 60 * 1000,
|
|
59
|
+
on: {
|
|
60
|
+
proxyReq: (proxyReq, req) => {
|
|
61
|
+
const account = req._ccAccount;
|
|
62
|
+
if (!account)
|
|
63
|
+
return;
|
|
64
|
+
// Replace the placeholder/proxy auth token with the real OAuth token.
|
|
65
|
+
// Claude Code sends ANTHROPIC_AUTH_TOKEN as "Authorization: Bearer proxy-managed".
|
|
66
|
+
// We replace it with the real OAuth token for this account.
|
|
67
|
+
proxyReq.setHeader("authorization", `Bearer ${account.tokens.accessToken}`);
|
|
68
|
+
// Remove x-api-key if present — OAuth authentication uses Authorization Bearer,
|
|
69
|
+
// not x-api-key. Having both set can cause conflicts at Anthropic's side.
|
|
70
|
+
proxyReq.removeHeader("x-api-key");
|
|
71
|
+
// CRITICAL: api.anthropic.com requires the "oauth-2025-04-20" beta flag to
|
|
72
|
+
// accept OAuth tokens (sk-ant-oat01-*). Without it the request is rejected
|
|
73
|
+
// with "OAuth authentication is currently not supported."
|
|
74
|
+
// APPEND — do NOT replace — so existing betas (tools, computer-use, etc.) are preserved.
|
|
75
|
+
const existingBeta = proxyReq.getHeader("anthropic-beta");
|
|
76
|
+
const betas = existingBeta
|
|
77
|
+
? String(existingBeta).split(",").map(b => b.trim()).filter(Boolean)
|
|
78
|
+
: [];
|
|
79
|
+
if (!betas.includes("oauth-2025-04-20")) {
|
|
80
|
+
betas.push("oauth-2025-04-20");
|
|
81
|
+
proxyReq.setHeader("anthropic-beta", betas.join(","));
|
|
82
|
+
}
|
|
83
|
+
// All other headers are forwarded automatically by http-proxy-middleware:
|
|
84
|
+
// anthropic-version — required by Anthropic API
|
|
85
|
+
// X-Claude-Code-Session-Id — session aggregation header sent by Claude Code
|
|
86
|
+
// content-type — always application/json
|
|
87
|
+
},
|
|
88
|
+
proxyRes: (proxyRes, req) => {
|
|
89
|
+
const account = req._ccAccount;
|
|
90
|
+
if (!account)
|
|
91
|
+
return;
|
|
92
|
+
const status = proxyRes.statusCode ?? 0;
|
|
93
|
+
if (status === 401) {
|
|
94
|
+
// Token invalid or expired mid-request.
|
|
95
|
+
// Forward the 401 to the client (Claude Code will retry on 401).
|
|
96
|
+
// Schedule a background refresh so the next request succeeds.
|
|
97
|
+
stats.totalErrors++;
|
|
98
|
+
account.errorCount++;
|
|
99
|
+
logError(account.id, 401, "Token invalid — scheduling background refresh");
|
|
100
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "401" });
|
|
101
|
+
refreshAccountToken(account).then(ok => {
|
|
102
|
+
if (ok)
|
|
103
|
+
saveAccounts(pool.getAll());
|
|
104
|
+
}).catch(console.error);
|
|
105
|
+
}
|
|
106
|
+
if (status === 429) {
|
|
107
|
+
// Rate limited — put account on cooldown for Retry-After seconds.
|
|
108
|
+
stats.totalErrors++;
|
|
109
|
+
account.errorCount++;
|
|
110
|
+
const retryAfter = Number(proxyRes.headers["retry-after"] ?? 60);
|
|
111
|
+
logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
|
|
112
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "429" });
|
|
113
|
+
account.busy = true;
|
|
114
|
+
setTimeout(() => { account.busy = false; }, retryAfter * 1_000);
|
|
115
|
+
}
|
|
116
|
+
if (status === 529) {
|
|
117
|
+
// Anthropic service overloaded — short cooldown on this account.
|
|
118
|
+
stats.totalErrors++;
|
|
119
|
+
account.errorCount++;
|
|
120
|
+
logError(account.id, 529, "Service overloaded — cooldown 30s");
|
|
121
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "error", details: "529" });
|
|
122
|
+
account.busy = true;
|
|
123
|
+
setTimeout(() => { account.busy = false; }, 30_000);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
error: (err, _req, res) => {
|
|
127
|
+
stats.totalErrors++;
|
|
128
|
+
logError("proxy", 0, err.message);
|
|
129
|
+
// res may be a Socket (WebSocket upgrade) — only respond on HTTP ServerResponse
|
|
130
|
+
if (res instanceof ServerResponse && !res.headersSent) {
|
|
131
|
+
// Match Anthropic's error response format so Claude Code handles it gracefully
|
|
132
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
133
|
+
res.end(JSON.stringify({
|
|
134
|
+
type: "error",
|
|
135
|
+
error: { type: "proxy_error", message: err.message },
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
// ─── /v1/* — select account, refresh if needed, then proxy ───────────────
|
|
142
|
+
// CRITICAL: Do NOT use express.json() here — it consumes the body stream
|
|
143
|
+
// and breaks SSE streaming passthrough.
|
|
144
|
+
app.use("/v1", async (req, _res, next) => {
|
|
145
|
+
const account = pool.getNext();
|
|
146
|
+
// Synchronous refresh if token expires within the buffer window
|
|
147
|
+
if (needsRefresh(account)) {
|
|
148
|
+
const ok = await refreshAccountToken(account);
|
|
149
|
+
if (ok)
|
|
150
|
+
saveAccounts(pool.getAll());
|
|
151
|
+
}
|
|
152
|
+
req._ccAccount = account;
|
|
153
|
+
stats.totalRequests++;
|
|
154
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "?", type: "route" });
|
|
155
|
+
logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
|
|
156
|
+
next();
|
|
157
|
+
}, proxy);
|
|
158
|
+
// ─── Catch-all — forward everything else (LiteLLM UI, /v1/models, etc.) ──
|
|
159
|
+
app.use("/", createProxyMiddleware({
|
|
160
|
+
target,
|
|
161
|
+
changeOrigin: true,
|
|
162
|
+
}));
|
|
163
|
+
// ─── Graceful shutdown ────────────────────────────────────────────────────
|
|
164
|
+
const shutdown = () => {
|
|
165
|
+
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
166
|
+
saveAccounts(pool.getAll());
|
|
167
|
+
process.exit(0);
|
|
168
|
+
};
|
|
169
|
+
process.on("SIGTERM", shutdown);
|
|
170
|
+
process.on("SIGINT", shutdown);
|
|
171
|
+
// ─── Start ────────────────────────────────────────────────────────────────
|
|
172
|
+
// HOST env var lets teams bind to 0.0.0.0 for LAN/VPS shared access.
|
|
173
|
+
// Defaults to 127.0.0.1 (localhost-only) for single-user safety.
|
|
174
|
+
const host = process.env["HOST"] ?? "127.0.0.1";
|
|
175
|
+
app.listen(port, host, () => {
|
|
176
|
+
logStartup(port, host, mode, target, accounts.length);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const MAX_LOG_ENTRIES = 50;
|
|
2
|
+
class ProxyStats {
|
|
3
|
+
totalRequests = 0;
|
|
4
|
+
totalErrors = 0;
|
|
5
|
+
totalRefreshes = 0;
|
|
6
|
+
startTime = Date.now();
|
|
7
|
+
logs = [];
|
|
8
|
+
addLog(entry) {
|
|
9
|
+
this.logs.push(entry);
|
|
10
|
+
if (this.logs.length > MAX_LOG_ENTRIES)
|
|
11
|
+
this.logs.shift();
|
|
12
|
+
}
|
|
13
|
+
getRecentLogs(n = 20) {
|
|
14
|
+
return [...this.logs].reverse().slice(0, n);
|
|
15
|
+
}
|
|
16
|
+
getUptimeSeconds() {
|
|
17
|
+
return Math.round((Date.now() - this.startTime) / 1000);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Singleton — shared across server and health endpoint
|
|
21
|
+
export const stats = new ProxyStats();
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class TokenPool {
|
|
2
|
+
accounts;
|
|
3
|
+
currentIndex = 0;
|
|
4
|
+
constructor(accounts) {
|
|
5
|
+
this.accounts = accounts;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Round-robin selection among healthy, non-busy accounts.
|
|
9
|
+
* Falls back to least-loaded if all are busy.
|
|
10
|
+
* Falls back to accounts[0] if all are unhealthy.
|
|
11
|
+
*/
|
|
12
|
+
getNext() {
|
|
13
|
+
const available = this.accounts.filter(a => a.healthy && !a.busy);
|
|
14
|
+
if (available.length === 0) {
|
|
15
|
+
const healthy = this.accounts.filter(a => a.healthy);
|
|
16
|
+
if (healthy.length === 0) {
|
|
17
|
+
// Complete fallback: nothing healthy — return first account and hope it recovers
|
|
18
|
+
return this.accounts[0];
|
|
19
|
+
}
|
|
20
|
+
// All healthy but busy — return least loaded
|
|
21
|
+
return healthy.reduce((a, b) => a.requestCount < b.requestCount ? a : b);
|
|
22
|
+
}
|
|
23
|
+
const account = available[this.currentIndex % available.length];
|
|
24
|
+
this.currentIndex = (this.currentIndex + 1) % available.length;
|
|
25
|
+
account.requestCount++;
|
|
26
|
+
account.lastUsed = Date.now();
|
|
27
|
+
return account;
|
|
28
|
+
}
|
|
29
|
+
getAll() {
|
|
30
|
+
return this.accounts;
|
|
31
|
+
}
|
|
32
|
+
getHealthy() {
|
|
33
|
+
return this.accounts.filter(a => a.healthy);
|
|
34
|
+
}
|
|
35
|
+
getStats() {
|
|
36
|
+
return this.accounts.map(a => ({
|
|
37
|
+
id: a.id,
|
|
38
|
+
healthy: a.healthy,
|
|
39
|
+
busy: a.busy,
|
|
40
|
+
requestCount: a.requestCount,
|
|
41
|
+
errorCount: a.errorCount,
|
|
42
|
+
expiresInMs: a.tokens.expiresAt - Date.now(),
|
|
43
|
+
lastUsedMs: a.lastUsed,
|
|
44
|
+
lastRefreshMs: a.lastRefresh,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
}
|