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.
@@ -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
+ }