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,114 @@
|
|
|
1
|
+
import { writeAccountsAtomic } from "../config/manager.js";
|
|
2
|
+
import { logRefresh } from "./logger.js";
|
|
3
|
+
import { stats } from "./stats.js";
|
|
4
|
+
/**
|
|
5
|
+
* Official Claude Code CLI client_id for the OAuth PKCE flow.
|
|
6
|
+
* Source: extracted from Claude Code auth flow.
|
|
7
|
+
* Update this if Anthropic changes it in a future Claude Code version.
|
|
8
|
+
*/
|
|
9
|
+
const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
10
|
+
/**
|
|
11
|
+
* Primary OAuth token endpoint.
|
|
12
|
+
* Alternative: https://claude.ai/v1/oauth/token
|
|
13
|
+
*/
|
|
14
|
+
const TOKEN_ENDPOINT = "https://console.anthropic.com/api/oauth/token";
|
|
15
|
+
/** Refresh 10 minutes before expiry */
|
|
16
|
+
const REFRESH_BUFFER_MS = 10 * 60 * 1000;
|
|
17
|
+
/** Check every 5 minutes */
|
|
18
|
+
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
19
|
+
/** Per-account refresh locks — prevent concurrent refreshes for the same account */
|
|
20
|
+
const refreshLocks = new Map();
|
|
21
|
+
export function needsRefresh(account) {
|
|
22
|
+
return (account.tokens.expiresAt - Date.now()) < REFRESH_BUFFER_MS;
|
|
23
|
+
}
|
|
24
|
+
export async function refreshAccountToken(account) {
|
|
25
|
+
// Deduplicate concurrent refresh calls for the same account
|
|
26
|
+
const existing = refreshLocks.get(account.id);
|
|
27
|
+
if (existing)
|
|
28
|
+
return existing;
|
|
29
|
+
const promise = _doRefresh(account);
|
|
30
|
+
refreshLocks.set(account.id, promise);
|
|
31
|
+
try {
|
|
32
|
+
return await promise;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
refreshLocks.delete(account.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function _doRefresh(account) {
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
grant_type: "refresh_token",
|
|
45
|
+
refresh_token: account.tokens.refreshToken,
|
|
46
|
+
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const body = await res.text();
|
|
51
|
+
logRefresh(account.id, false);
|
|
52
|
+
console.error(` Status: ${res.status} — ${body}`);
|
|
53
|
+
account.consecutiveErrors++;
|
|
54
|
+
if (account.consecutiveErrors >= 3)
|
|
55
|
+
account.healthy = false;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
// CRITICAL: refresh_token ROTATES — save the new one immediately or lose access permanently
|
|
60
|
+
account.tokens.accessToken = data.access_token;
|
|
61
|
+
account.tokens.refreshToken = data.refresh_token;
|
|
62
|
+
account.tokens.expiresAt = Date.now() + data.expires_in * 1000;
|
|
63
|
+
account.tokens.scopes = data.scope.split(" ");
|
|
64
|
+
account.healthy = true;
|
|
65
|
+
account.consecutiveErrors = 0;
|
|
66
|
+
account.lastRefresh = Date.now();
|
|
67
|
+
stats.totalRefreshes++;
|
|
68
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "refresh" });
|
|
69
|
+
const expiresInMin = Math.round(data.expires_in / 60);
|
|
70
|
+
logRefresh(account.id, true, expiresInMin);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
logRefresh(account.id, false);
|
|
75
|
+
console.error(` Error:`, err);
|
|
76
|
+
account.consecutiveErrors++;
|
|
77
|
+
if (account.consecutiveErrors >= 3)
|
|
78
|
+
account.healthy = false;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Persist all accounts to disk.
|
|
84
|
+
* Uses atomic write (tmp + rename) to prevent corruption if process dies mid-write.
|
|
85
|
+
* Must be called after every successful refresh since refresh_token ROTATES.
|
|
86
|
+
*/
|
|
87
|
+
export function saveAccounts(accounts) {
|
|
88
|
+
const records = accounts.map(a => ({
|
|
89
|
+
id: a.id,
|
|
90
|
+
accessToken: a.tokens.accessToken,
|
|
91
|
+
refreshToken: a.tokens.refreshToken,
|
|
92
|
+
expiresAt: a.tokens.expiresAt,
|
|
93
|
+
scopes: a.tokens.scopes,
|
|
94
|
+
}));
|
|
95
|
+
writeAccountsAtomic(records);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Background refresh loop: checks every 5 minutes and refreshes any
|
|
99
|
+
* token expiring within the REFRESH_BUFFER_MS window.
|
|
100
|
+
*/
|
|
101
|
+
export function startRefreshLoop(accounts) {
|
|
102
|
+
const check = async () => {
|
|
103
|
+
for (const account of accounts) {
|
|
104
|
+
if (needsRefresh(account)) {
|
|
105
|
+
const ok = await refreshAccountToken(account);
|
|
106
|
+
if (ok)
|
|
107
|
+
saveAccounts(accounts);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
// Run immediately on startup (catches already-expired tokens)
|
|
112
|
+
check().catch(console.error);
|
|
113
|
+
setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text, useInput, useApp } from "ink";
|
|
4
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
5
|
+
// ─── Dashboard component ──────────────────────────────────────────────────────
|
|
6
|
+
export function Dashboard({ port }) {
|
|
7
|
+
const { exit } = useApp();
|
|
8
|
+
const [data, setData] = useState(null);
|
|
9
|
+
const [connectError, setConnectError] = useState(null);
|
|
10
|
+
const [lastUpdate, setLastUpdate] = useState(0);
|
|
11
|
+
const [retryCount, setRetryCount] = useState(0);
|
|
12
|
+
useInput((input, key) => {
|
|
13
|
+
if (input === "q" || key.escape)
|
|
14
|
+
exit();
|
|
15
|
+
});
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
const poll = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`http://localhost:${port}/cc-router/health`, {
|
|
21
|
+
signal: AbortSignal.timeout(1_500),
|
|
22
|
+
});
|
|
23
|
+
if (cancelled)
|
|
24
|
+
return;
|
|
25
|
+
if (res.ok) {
|
|
26
|
+
setData(await res.json());
|
|
27
|
+
setConnectError(null);
|
|
28
|
+
setLastUpdate(Date.now());
|
|
29
|
+
setRetryCount(0);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
setConnectError(`Proxy returned HTTP ${res.status}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
if (cancelled)
|
|
37
|
+
return;
|
|
38
|
+
setConnectError(`Cannot connect to http://localhost:${port}`);
|
|
39
|
+
setRetryCount(n => n + 1);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
poll();
|
|
43
|
+
const timer = setInterval(poll, POLL_INTERVAL_MS);
|
|
44
|
+
return () => { cancelled = true; clearInterval(timer); };
|
|
45
|
+
}, [port]);
|
|
46
|
+
if (connectError) {
|
|
47
|
+
return _jsx(ErrorScreen, { error: connectError, port: port, retries: retryCount });
|
|
48
|
+
}
|
|
49
|
+
if (!data) {
|
|
50
|
+
return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: _jsxs(Text, { color: "yellow", children: ["\u280B Connecting to http://localhost:", port, "..."] }) }));
|
|
51
|
+
}
|
|
52
|
+
return _jsx(LiveDashboard, { data: data, port: port, lastUpdate: lastUpdate });
|
|
53
|
+
}
|
|
54
|
+
// ─── Error screen ─────────────────────────────────────────────────────────────
|
|
55
|
+
function ErrorScreen({ error, port, retries }) {
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", marginY: 1, marginX: 2, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2717 ", error] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "yellow", children: "Is the proxy running? Start it with:" }), _jsx(Text, { color: "cyan", children: " cc-router start" })] }), _jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "gray", children: ["Retrying every ", POLL_INTERVAL_MS / 1000, "s"] }), retries > 0 && _jsxs(Text, { color: "gray", children: [" (attempt ", retries, ")"] }), _jsx(Text, { color: "gray", children: " \u00B7 [q] quit" })] })] }));
|
|
57
|
+
}
|
|
58
|
+
// ─── Live dashboard ───────────────────────────────────────────────────────────
|
|
59
|
+
function LiveDashboard({ data, port, lastUpdate }) {
|
|
60
|
+
const healthyCount = data.accounts.filter(a => a.healthy).length;
|
|
61
|
+
const updatedAgo = Math.round((Date.now() - lastUpdate) / 1000);
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: " CC-Router " }), _jsx(Text, { color: "gray", children: "\u00B7 " }), _jsx(Text, { color: "green", children: data.mode }), _jsxs(Text, { color: "gray", children: [" \u2192 ", data.target, " \u00B7 "] }), _jsxs(Text, { children: ["up ", formatUptime(data.uptime)] }), _jsxs(Text, { color: "gray", children: [" \u00B7 updated ", updatedAgo, "s ago \u00B7 [q] quit"] })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, children: [" ACCOUNTS ", _jsxs(Text, { color: healthyCount === data.accounts.length ? "green" : "yellow", children: [healthyCount, "/", data.accounts.length, " healthy"] })] }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.accounts.map(a => (_jsx(AccountRow, { account: a }, a.id))) })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: " TOTALS " }), _jsx(Text, { children: "requests " }), _jsx(Text, { color: "cyan", children: data.totalRequests }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "errors " }), _jsx(Text, { color: data.totalErrors > 0 ? "red" : "green", children: data.totalErrors }), _jsx(Text, { color: "gray", children: " \u00B7 " }), _jsx(Text, { children: "refreshes " }), _jsx(Text, { color: "yellow", children: data.totalRefreshes })] }), _jsx(Box, { marginTop: 1 }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " RECENT ACTIVITY" }), _jsx(Box, { marginTop: 1, flexDirection: "column", children: data.recentLogs.length === 0
|
|
63
|
+
? _jsx(Text, { color: "gray", children: " No activity yet" })
|
|
64
|
+
: data.recentLogs.slice(0, 10).map((log, i) => (_jsx(LogRow, { log: log }, i))) })] })] }));
|
|
65
|
+
}
|
|
66
|
+
// ─── Account row ──────────────────────────────────────────────────────────────
|
|
67
|
+
function AccountRow({ account: a }) {
|
|
68
|
+
const dot = a.busy ? "◌" : a.healthy ? "●" : "●";
|
|
69
|
+
const dotColor = a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
70
|
+
const statusLabel = a.busy ? "busy " : a.healthy ? "ok " : "ERROR ";
|
|
71
|
+
const statusColor = a.busy ? "yellow" : a.healthy ? "green" : "red";
|
|
72
|
+
const expiryLabel = a.expiresInMs > 0 ? formatMs(a.expiresInMs) : "EXPIRED";
|
|
73
|
+
const expiryColor = a.expiresInMs < 10 * 60 * 1000 ? "red"
|
|
74
|
+
: a.expiresInMs < 30 * 60 * 1000 ? "yellow"
|
|
75
|
+
: "white";
|
|
76
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: dotColor, children: [" ", dot, " "] }), _jsx(Text, { children: a.id.slice(0, 22).padEnd(22) }), _jsx(Text, { color: statusColor, children: statusLabel }), _jsx(Text, { color: "gray", children: " req " }), _jsx(Text, { color: "white", children: String(a.requestCount).padStart(5) }), _jsx(Text, { color: "gray", children: " err " }), _jsx(Text, { color: a.errorCount > 0 ? "red" : "gray", children: String(a.errorCount).padStart(3) }), _jsx(Text, { color: "gray", children: " expires " }), _jsx(Text, { color: expiryColor, children: expiryLabel.padEnd(10) }), _jsx(Text, { color: "gray", children: " last " }), _jsx(Text, { color: "gray", children: formatAgo(a.lastUsedMs) })] }));
|
|
77
|
+
}
|
|
78
|
+
// ─── Log row ──────────────────────────────────────────────────────────────────
|
|
79
|
+
function LogRow({ log }) {
|
|
80
|
+
const time = new Date(log.ts).toLocaleTimeString("en-GB", { hour12: false });
|
|
81
|
+
const typeColor = log.type === "error" ? "red" : log.type === "refresh" ? "yellow" : "gray";
|
|
82
|
+
const typeIcon = log.type === "error" ? "✗" : log.type === "refresh" ? "↻" : "→";
|
|
83
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [" ", time, " "] }), _jsxs(Text, { color: typeColor, children: [typeIcon, " "] }), _jsx(Text, { color: "cyan", children: log.accountId.slice(0, 22).padEnd(22) }), _jsxs(Text, { color: typeColor, children: [" ", log.type] }), log.details && _jsxs(Text, { color: "gray", children: [" ", log.details] })] }));
|
|
84
|
+
}
|
|
85
|
+
// ─── Formatters ───────────────────────────────────────────────────────────────
|
|
86
|
+
function formatUptime(seconds) {
|
|
87
|
+
const h = Math.floor(seconds / 3_600);
|
|
88
|
+
const m = Math.floor((seconds % 3_600) / 60);
|
|
89
|
+
const s = seconds % 60;
|
|
90
|
+
if (h > 0)
|
|
91
|
+
return `${h}h ${m}m`;
|
|
92
|
+
if (m > 0)
|
|
93
|
+
return `${m}m ${s}s`;
|
|
94
|
+
return `${s}s`;
|
|
95
|
+
}
|
|
96
|
+
function formatMs(ms) {
|
|
97
|
+
const totalMin = Math.round(ms / 60_000);
|
|
98
|
+
if (totalMin >= 60)
|
|
99
|
+
return `${Math.floor(totalMin / 60)}h ${totalMin % 60}m`;
|
|
100
|
+
return `${totalMin}m`;
|
|
101
|
+
}
|
|
102
|
+
function formatAgo(ts) {
|
|
103
|
+
if (!ts)
|
|
104
|
+
return "never";
|
|
105
|
+
const s = Math.round((Date.now() - ts) / 1_000);
|
|
106
|
+
if (s < 60)
|
|
107
|
+
return `${s}s ago`;
|
|
108
|
+
const m = Math.floor(s / 60);
|
|
109
|
+
return `${m}m ago`;
|
|
110
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { CLAUDE_SETTINGS_PATH } from "../config/paths.js";
|
|
4
|
+
/**
|
|
5
|
+
* Write ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN into ~/.claude/settings.json.
|
|
6
|
+
*
|
|
7
|
+
* Rules from official Claude Code docs:
|
|
8
|
+
* - ANTHROPIC_AUTH_TOKEN is sent as "Authorization: Bearer <value>"
|
|
9
|
+
* - Do NOT append /v1 to ANTHROPIC_BASE_URL — Claude Code adds it automatically
|
|
10
|
+
* - Merges with existing settings, preserving all other keys
|
|
11
|
+
*/
|
|
12
|
+
export function writeClaudeSettings(port) {
|
|
13
|
+
const dir = dirname(CLAUDE_SETTINGS_PATH);
|
|
14
|
+
if (!existsSync(dir))
|
|
15
|
+
mkdirSync(dir, { recursive: true });
|
|
16
|
+
let existing = {};
|
|
17
|
+
if (existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
18
|
+
try {
|
|
19
|
+
existing = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
existing = {};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const existingEnv = existing["env"] ?? {};
|
|
26
|
+
const updated = {
|
|
27
|
+
...existing,
|
|
28
|
+
env: {
|
|
29
|
+
...existingEnv,
|
|
30
|
+
// ANTHROPIC_BASE_URL: no trailing /v1 — Claude Code appends it automatically
|
|
31
|
+
ANTHROPIC_BASE_URL: `http://localhost:${port}`,
|
|
32
|
+
// ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY in Claude Code.
|
|
33
|
+
// The proxy replaces this placeholder with the real OAuth token per request.
|
|
34
|
+
ANTHROPIC_AUTH_TOKEN: "proxy-managed",
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(updated, null, 2), "utf-8");
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Remove cc-router settings from ~/.claude/settings.json.
|
|
41
|
+
* Called when uninstalling cc-router so Claude Code goes back to its default auth.
|
|
42
|
+
*/
|
|
43
|
+
export function removeClaudeSettings() {
|
|
44
|
+
if (!existsSync(CLAUDE_SETTINGS_PATH))
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
const existing = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
|
|
48
|
+
const env = existing["env"];
|
|
49
|
+
if (env) {
|
|
50
|
+
delete env["ANTHROPIC_BASE_URL"];
|
|
51
|
+
delete env["ANTHROPIC_AUTH_TOKEN"];
|
|
52
|
+
if (Object.keys(env).length === 0)
|
|
53
|
+
delete existing["env"];
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(existing, null, 2), "utf-8");
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// If we can't parse it, leave it alone
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Read current Claude Code proxy settings (for display) */
|
|
62
|
+
export function readClaudeProxySettings() {
|
|
63
|
+
if (!existsSync(CLAUDE_SETTINGS_PATH))
|
|
64
|
+
return {};
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, "utf-8"));
|
|
67
|
+
const env = raw["env"];
|
|
68
|
+
return {
|
|
69
|
+
baseUrl: env?.["ANTHROPIC_BASE_URL"],
|
|
70
|
+
authToken: env?.["ANTHROPIC_AUTH_TOKEN"],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function detectPlatform() {
|
|
2
|
+
switch (process.platform) {
|
|
3
|
+
case "darwin": return "macos";
|
|
4
|
+
case "win32": return "windows";
|
|
5
|
+
default: return "linux";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export function isWindows() {
|
|
9
|
+
return process.platform === "win32";
|
|
10
|
+
}
|
|
11
|
+
export function isMacos() {
|
|
12
|
+
return process.platform === "darwin";
|
|
13
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* macOS: extract OAuth tokens from the macOS Keychain.
|
|
9
|
+
* Uses execFile (not exec/execSync) — args are passed as an array,
|
|
10
|
+
* preventing any shell injection.
|
|
11
|
+
*/
|
|
12
|
+
export async function extractFromKeychain() {
|
|
13
|
+
try {
|
|
14
|
+
const { stdout } = await execFileAsync("security", [
|
|
15
|
+
"find-generic-password",
|
|
16
|
+
"-s", "Claude Code-credentials",
|
|
17
|
+
"-w",
|
|
18
|
+
]);
|
|
19
|
+
return parseCredentialJson(stdout.trim());
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Linux / Windows: read from ~/.claude/.credentials.json.
|
|
27
|
+
* Claude Code writes credentials here on non-macOS platforms.
|
|
28
|
+
* No shell — pure Node.js file read.
|
|
29
|
+
*/
|
|
30
|
+
export function extractFromCredentialsFile() {
|
|
31
|
+
const credPath = join(os.homedir(), ".claude", ".credentials.json");
|
|
32
|
+
if (!existsSync(credPath))
|
|
33
|
+
return null;
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(readFileSync(credPath, "utf-8"));
|
|
36
|
+
// The file can have two shapes:
|
|
37
|
+
// { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
|
|
38
|
+
// { accessToken, refreshToken, expiresAt, scopes } (direct)
|
|
39
|
+
const oauth = raw.claudeAiOauth ?? raw;
|
|
40
|
+
return parseCredentialJson(oauth);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Parse and normalise either a raw JSON string or an already-parsed object. */
|
|
47
|
+
function parseCredentialJson(raw) {
|
|
48
|
+
try {
|
|
49
|
+
const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
50
|
+
const accessToken = obj["accessToken"];
|
|
51
|
+
const refreshToken = obj["refreshToken"];
|
|
52
|
+
const expiresAt = obj["expiresAt"];
|
|
53
|
+
if (typeof accessToken !== "string" ||
|
|
54
|
+
typeof refreshToken !== "string" ||
|
|
55
|
+
!accessToken.startsWith("sk-ant-")) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const scopes = Array.isArray(obj["scopes"])
|
|
59
|
+
? obj["scopes"]
|
|
60
|
+
: ["user:inference", "user:profile"];
|
|
61
|
+
let expiresAtMs;
|
|
62
|
+
if (typeof expiresAt === "number") {
|
|
63
|
+
expiresAtMs = expiresAt;
|
|
64
|
+
}
|
|
65
|
+
else if (typeof expiresAt === "string") {
|
|
66
|
+
expiresAtMs = new Date(expiresAt).getTime();
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// No expiry info — assume 8h from now (standard OAuth token lifetime)
|
|
70
|
+
expiresAtMs = Date.now() + 8 * 60 * 60 * 1000;
|
|
71
|
+
}
|
|
72
|
+
return { accessToken, refreshToken, expiresAt: expiresAtMs, scopes };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Format a token expiry timestamp as a human-readable string */
|
|
79
|
+
export function formatExpiry(expiresAtMs) {
|
|
80
|
+
const ms = expiresAtMs - Date.now();
|
|
81
|
+
if (ms <= 0)
|
|
82
|
+
return "EXPIRED";
|
|
83
|
+
const h = Math.floor(ms / 3_600_000);
|
|
84
|
+
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
85
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
86
|
+
}
|
|
87
|
+
/** Redact a token for safe display: show first 20 chars + "..." */
|
|
88
|
+
export function redactToken(token) {
|
|
89
|
+
return token.length > 20 ? `${token.slice(0, 20)}...` : token;
|
|
90
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export async function validateToken(accessToken) {
|
|
2
|
+
try {
|
|
3
|
+
const res = await fetch("https://api.anthropic.com/v1/models", {
|
|
4
|
+
headers: {
|
|
5
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
6
|
+
"anthropic-version": "2023-06-01",
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
if (res.ok)
|
|
10
|
+
return { valid: true };
|
|
11
|
+
if (res.status === 401) {
|
|
12
|
+
return { valid: false, reason: "Token invalid or expired (401)" };
|
|
13
|
+
}
|
|
14
|
+
if (res.status === 403) {
|
|
15
|
+
return { valid: false, reason: "Token lacks required scopes (403) — needs user:inference" };
|
|
16
|
+
}
|
|
17
|
+
// Any other non-ok status is unexpected but the token may still work
|
|
18
|
+
return { valid: false, reason: `Unexpected HTTP ${res.status}` };
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
// Network error — can't validate, let user decide
|
|
22
|
+
return { valid: false, reason: `Network error: ${err.message}` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
services:
|
|
2
|
+
cc-router:
|
|
3
|
+
build:
|
|
4
|
+
context: .
|
|
5
|
+
dockerfile: Dockerfile
|
|
6
|
+
ports:
|
|
7
|
+
- "${PORT:-3456}:3456"
|
|
8
|
+
volumes:
|
|
9
|
+
# Mount accounts.json read-write: cc-router must update it when tokens rotate
|
|
10
|
+
- ${HOME}/.cc-router/accounts.json:/app/accounts.json
|
|
11
|
+
environment:
|
|
12
|
+
- PORT=3456
|
|
13
|
+
- LITELLM_URL=http://litellm:4000
|
|
14
|
+
- ACCOUNTS_PATH=/app/accounts.json
|
|
15
|
+
- NODE_ENV=production
|
|
16
|
+
depends_on:
|
|
17
|
+
litellm:
|
|
18
|
+
condition: service_healthy
|
|
19
|
+
restart: unless-stopped
|
|
20
|
+
healthcheck:
|
|
21
|
+
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3456/cc-router/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
|
22
|
+
interval: 30s
|
|
23
|
+
timeout: 10s
|
|
24
|
+
retries: 3
|
|
25
|
+
start_period: 10s
|
|
26
|
+
|
|
27
|
+
litellm:
|
|
28
|
+
# Pin a specific version — avoid main-latest for production.
|
|
29
|
+
# Check releases: https://github.com/BerriAI/litellm/releases
|
|
30
|
+
# WARNING: versions 1.82.7 and 1.82.8 contained malware — never use them.
|
|
31
|
+
image: ghcr.io/berriai/litellm:main-stable
|
|
32
|
+
ports:
|
|
33
|
+
- "${LITELLM_PORT:-4000}:4000"
|
|
34
|
+
volumes:
|
|
35
|
+
- ./litellm-config.yaml:/app/config.yaml:ro
|
|
36
|
+
environment:
|
|
37
|
+
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:-cc-router-local-dev}
|
|
38
|
+
command: ["--config", "/app/config.yaml", "--port", "4000", "--detailed_debug"]
|
|
39
|
+
restart: unless-stopped
|
|
40
|
+
healthcheck:
|
|
41
|
+
test: ["CMD-SHELL", "curl -sf http://localhost:4000/health || exit 1"]
|
|
42
|
+
interval: 15s
|
|
43
|
+
timeout: 5s
|
|
44
|
+
retries: 5
|
|
45
|
+
start_period: 20s
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
model_list:
|
|
2
|
+
# ── Claude 4.6 (latest) ───────────────────────────────────────────────────
|
|
3
|
+
- model_name: claude-opus-4-6
|
|
4
|
+
litellm_params:
|
|
5
|
+
model: anthropic/claude-opus-4-6
|
|
6
|
+
# No api_key — Authorization header is injected by cc-router (OAuth token)
|
|
7
|
+
|
|
8
|
+
- model_name: claude-sonnet-4-6
|
|
9
|
+
litellm_params:
|
|
10
|
+
model: anthropic/claude-sonnet-4-6
|
|
11
|
+
|
|
12
|
+
# ── Claude 4.5 ────────────────────────────────────────────────────────────
|
|
13
|
+
- model_name: claude-sonnet-4-5-20250929
|
|
14
|
+
litellm_params:
|
|
15
|
+
model: anthropic/claude-sonnet-4-5-20250929
|
|
16
|
+
|
|
17
|
+
- model_name: claude-haiku-4-5-20251001
|
|
18
|
+
litellm_params:
|
|
19
|
+
model: anthropic/claude-haiku-4-5-20251001
|
|
20
|
+
|
|
21
|
+
# ── Short-name aliases (Claude Code uses these interchangeably) ───────────
|
|
22
|
+
- model_name: claude-sonnet-4-5
|
|
23
|
+
litellm_params:
|
|
24
|
+
model: anthropic/claude-sonnet-4-5-20250929
|
|
25
|
+
|
|
26
|
+
- model_name: claude-haiku-4-5
|
|
27
|
+
litellm_params:
|
|
28
|
+
model: anthropic/claude-haiku-4-5-20251001
|
|
29
|
+
|
|
30
|
+
general_settings:
|
|
31
|
+
# CRITICAL: forward the Authorization header from cc-router to Anthropic.
|
|
32
|
+
# cc-router injects "Authorization: Bearer <oauth-token>" on every request.
|
|
33
|
+
# Without this setting, LiteLLM would strip it and auth would fail.
|
|
34
|
+
forward_client_headers_to_llm_api: true
|
|
35
|
+
|
|
36
|
+
# Master key for LiteLLM UI and virtual key management.
|
|
37
|
+
# Set LITELLM_MASTER_KEY in your environment or .env file.
|
|
38
|
+
master_key: os.environ/LITELLM_MASTER_KEY
|
|
39
|
+
|
|
40
|
+
litellm_settings:
|
|
41
|
+
# High timeouts for Claude Code long-running requests (thinking, agents)
|
|
42
|
+
request_timeout: 300
|
|
43
|
+
# Drop params not supported by a model silently (avoids errors on older models)
|
|
44
|
+
drop_params: true
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-cc-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-router": "dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/cli/index.ts",
|
|
12
|
+
"start": "node dist/cli/index.js",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"lint": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"anthropic",
|
|
20
|
+
"proxy",
|
|
21
|
+
"oauth",
|
|
22
|
+
"round-robin",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"claude-max"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/VictorMinemu/CC-Router.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/VictorMinemu/CC-Router/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/VictorMinemu/CC-Router#readme",
|
|
35
|
+
"files": [
|
|
36
|
+
"dist/",
|
|
37
|
+
"litellm-config.yaml",
|
|
38
|
+
"docker-compose.yml",
|
|
39
|
+
"Dockerfile",
|
|
40
|
+
"accounts.example.json",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@inquirer/prompts": "^7.0.0",
|
|
46
|
+
"chalk": "^5.3.0",
|
|
47
|
+
"commander": "^12.0.0",
|
|
48
|
+
"express": "^4.21.0",
|
|
49
|
+
"http-proxy-middleware": "^3.0.5",
|
|
50
|
+
"ink": "^5.0.0",
|
|
51
|
+
"react": "^18.3.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/express": "^4.17.21",
|
|
55
|
+
"@types/node": "^20.0.0",
|
|
56
|
+
"@types/react": "^18.3.0",
|
|
57
|
+
"tsx": "^4.19.0",
|
|
58
|
+
"typescript": "^5.6.0",
|
|
59
|
+
"vitest": "^4.1.2"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|