@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,37 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { checkForUpdate, performUpdate, getCurrentVersion, PKG_NAME } from "../utils/self-update.js";
|
|
3
|
+
export function registerUpdate(program) {
|
|
4
|
+
program
|
|
5
|
+
.command("update")
|
|
6
|
+
.description("Check for updates and install the latest version")
|
|
7
|
+
.option("--check", "Only check, don't install")
|
|
8
|
+
.action(async (opts) => {
|
|
9
|
+
console.log(chalk.gray(`Current version: v${getCurrentVersion()}\n`));
|
|
10
|
+
const check = await checkForUpdate(true);
|
|
11
|
+
if (!check.updateAvailable) {
|
|
12
|
+
console.log(chalk.green("✓ Already on the latest version."));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(chalk.cyan("New version available: ") +
|
|
16
|
+
chalk.gray(`v${check.current}`) +
|
|
17
|
+
chalk.cyan(" → ") +
|
|
18
|
+
chalk.green.bold(`v${check.latest}`) +
|
|
19
|
+
chalk.gray(` (${check.diff})`));
|
|
20
|
+
if (opts.check)
|
|
21
|
+
return;
|
|
22
|
+
if (check.diff === "major") {
|
|
23
|
+
console.log(chalk.yellow("\n⚠ Major version update — may contain breaking changes."));
|
|
24
|
+
console.log(chalk.yellow(` Install manually: npm i -g ${PKG_NAME}@${check.latest}`));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const ok = await performUpdate(check.latest);
|
|
28
|
+
if (!ok) {
|
|
29
|
+
console.log(chalk.red("\nUpdate failed. Try manually:"));
|
|
30
|
+
console.log(chalk.cyan(` npm i -g ${PKG_NAME}@${check.latest}`));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
console.log(chalk.green("\n✓ Update complete."));
|
|
34
|
+
console.log(chalk.gray(" Restart the proxy to use the new version:"));
|
|
35
|
+
console.log(chalk.cyan(" cc-router stop && cc-router start"));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
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 { registerConfigure } from "./cmd-configure.js";
|
|
9
|
+
import { registerDocker } from "./cmd-docker.js";
|
|
10
|
+
import { registerUpdate } from "./cmd-update.js";
|
|
11
|
+
import { registerClient } from "./cmd-client.js";
|
|
12
|
+
import { registerTelemetry } from "./cmd-telemetry.js";
|
|
13
|
+
import { registerLogs } from "./cmd-logs.js";
|
|
14
|
+
import { registerModels } from "./cmd-models.js";
|
|
15
|
+
import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
|
|
16
|
+
const program = new Command();
|
|
17
|
+
program
|
|
18
|
+
.name("cc-router")
|
|
19
|
+
.description("Round-robin proxy for Claude Max OAuth tokens.\n" +
|
|
20
|
+
"Distributes Claude Code requests across multiple Claude Max accounts.")
|
|
21
|
+
.version(getCurrentVersion())
|
|
22
|
+
.addHelpText("after", `
|
|
23
|
+
Examples:
|
|
24
|
+
$ cc-router setup # First-time wizard: extract tokens + configure Claude Code
|
|
25
|
+
$ cc-router start # Start proxy (asks preferences on first run, then remembers)
|
|
26
|
+
$ cc-router start --foreground # Start in foreground (this terminal)
|
|
27
|
+
$ cc-router start --reconfigure# Re-ask run preferences
|
|
28
|
+
$ cc-router stop # Stop proxy (offers to remove auto-start / config)
|
|
29
|
+
$ cc-router status # Live dashboard with account stats
|
|
30
|
+
$ cc-router models list # List dynamically discovered provider models
|
|
31
|
+
$ cc-router logs # View proxy logs (background mode)
|
|
32
|
+
$ cc-router accounts list # Show all configured accounts
|
|
33
|
+
$ cc-router revert # Restore Claude Code to normal (remove all proxy config)
|
|
34
|
+
$ cc-router docker up # Full stack: cc-router + LiteLLM in Docker
|
|
35
|
+
$ cc-router client connect <url> # Route Claude Code through a remote CC-Router
|
|
36
|
+
`);
|
|
37
|
+
registerSetup(program);
|
|
38
|
+
registerStart(program);
|
|
39
|
+
registerStop(program);
|
|
40
|
+
registerRevert(program);
|
|
41
|
+
registerStatus(program);
|
|
42
|
+
registerModels(program);
|
|
43
|
+
registerAccounts(program);
|
|
44
|
+
registerConfigure(program);
|
|
45
|
+
registerDocker(program);
|
|
46
|
+
registerUpdate(program);
|
|
47
|
+
registerClient(program);
|
|
48
|
+
registerTelemetry(program);
|
|
49
|
+
registerLogs(program);
|
|
50
|
+
// Background update check — fires on every CLI invocation, uses 6h disk cache
|
|
51
|
+
// so it's essentially free after the first check. Notify on process exit.
|
|
52
|
+
if (!process.env["NO_UPDATE_NOTIFIER"] && !process.env["CI"]) {
|
|
53
|
+
checkForUpdate().then((check) => {
|
|
54
|
+
if (check.updateAvailable) {
|
|
55
|
+
process.on("exit", () => printUpdateBanner(check));
|
|
56
|
+
}
|
|
57
|
+
}).catch(() => { });
|
|
58
|
+
}
|
|
59
|
+
program.parse();
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, chmodSync } from "fs";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { CONFIG_DIR, ACCOUNTS_PATH, CONFIG_PATH } from "./paths.js";
|
|
4
|
+
import { DEFAULT_RATE_LIMITS, ACCOUNT_USER_DEFAULTS, clampPercent } from "../proxy/types.js";
|
|
5
|
+
export const DEFAULT_PROXY_REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
6
|
+
/** Owner-only permissions for files/dirs that hold OAuth tokens or the proxy secret. */
|
|
7
|
+
const SECRET_FILE_MODE = 0o600;
|
|
8
|
+
const SECRET_DIR_MODE = 0o700;
|
|
9
|
+
export function ensureConfigDir() {
|
|
10
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
11
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: SECRET_DIR_MODE });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
// Tighten an existing dir that may predate this hardening. No-op on Windows.
|
|
15
|
+
try {
|
|
16
|
+
chmodSync(CONFIG_DIR, SECRET_DIR_MODE);
|
|
17
|
+
}
|
|
18
|
+
catch { /* best effort */ }
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Atomic + private write for credential files: write tmp as 0600 (umask can
|
|
22
|
+
* clear bits, so chmod defensively), then rename. rename preserves the source
|
|
23
|
+
* inode's mode, so the destination ends up 0600 even if it previously existed
|
|
24
|
+
* world-readable. On Windows `mode` is largely ignored; the file lives under
|
|
25
|
+
* the user profile and is protected by the profile ACL.
|
|
26
|
+
*/
|
|
27
|
+
function writeFileSecureSync(path, data) {
|
|
28
|
+
const tmp = path + ".tmp";
|
|
29
|
+
writeFileSync(tmp, data, { encoding: "utf-8", mode: SECRET_FILE_MODE });
|
|
30
|
+
try {
|
|
31
|
+
chmodSync(tmp, SECRET_FILE_MODE);
|
|
32
|
+
}
|
|
33
|
+
catch { /* best effort */ }
|
|
34
|
+
renameSync(tmp, path);
|
|
35
|
+
try {
|
|
36
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
37
|
+
}
|
|
38
|
+
catch { /* best effort */ }
|
|
39
|
+
}
|
|
40
|
+
export function accountsFileExists(path) {
|
|
41
|
+
return existsSync(path ?? ACCOUNTS_PATH);
|
|
42
|
+
}
|
|
43
|
+
export function readAccountsRaw() {
|
|
44
|
+
return readRawFromPath(ACCOUNTS_PATH);
|
|
45
|
+
}
|
|
46
|
+
function readRawFromPath(path) {
|
|
47
|
+
if (!existsSync(path))
|
|
48
|
+
return [];
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Deserialize Account[] from an explicit file path */
|
|
57
|
+
export function readAccountsFromPath(path) {
|
|
58
|
+
return deserialize(readRawFromPath(path));
|
|
59
|
+
}
|
|
60
|
+
// Escritura atómica: escribe a .tmp y renombra — evita JSON corrupto si el proceso muere mid-write
|
|
61
|
+
export function writeAccountsAtomic(data) {
|
|
62
|
+
ensureConfigDir();
|
|
63
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, data);
|
|
64
|
+
}
|
|
65
|
+
function writeAccountsAtomicToPath(path, data) {
|
|
66
|
+
// accounts.json holds plaintext OAuth access + refresh tokens — owner-only.
|
|
67
|
+
writeFileSecureSync(path, JSON.stringify(data, null, 2));
|
|
68
|
+
}
|
|
69
|
+
export function writeAnthropicAccountsPreservingOtherProviders(data) {
|
|
70
|
+
ensureConfigDir();
|
|
71
|
+
const existing = readAccountsRaw();
|
|
72
|
+
const nonAnthropic = existing.filter(a => a.provider !== undefined && a.provider !== "anthropic_subscription");
|
|
73
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, [...data, ...nonAnthropic]);
|
|
74
|
+
}
|
|
75
|
+
export function upsertAccountRecord(record) {
|
|
76
|
+
ensureConfigDir();
|
|
77
|
+
const existing = readAccountsRaw();
|
|
78
|
+
const next = [
|
|
79
|
+
...existing.filter(a => !(a.id === record.id && a.provider === record.provider)),
|
|
80
|
+
record,
|
|
81
|
+
];
|
|
82
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, next);
|
|
83
|
+
}
|
|
84
|
+
export function removeAccountRecordById(id) {
|
|
85
|
+
ensureConfigDir();
|
|
86
|
+
const existing = readAccountsRaw();
|
|
87
|
+
const removed = existing.find(a => a.id === id) ?? null;
|
|
88
|
+
if (!removed)
|
|
89
|
+
return null;
|
|
90
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, existing.filter(a => a.id !== id));
|
|
91
|
+
return removed;
|
|
92
|
+
}
|
|
93
|
+
function normalizeAccountProvider(record) {
|
|
94
|
+
return record.provider === "openai_subscription"
|
|
95
|
+
? "openai_subscription"
|
|
96
|
+
: "anthropic_subscription";
|
|
97
|
+
}
|
|
98
|
+
export function migrateLegacyAccountProviders(path = ACCOUNTS_PATH) {
|
|
99
|
+
const records = readRawFromPath(path);
|
|
100
|
+
let changed = false;
|
|
101
|
+
const migrated = records.map(record => {
|
|
102
|
+
if (record.provider !== undefined)
|
|
103
|
+
return record;
|
|
104
|
+
changed = true;
|
|
105
|
+
return { ...record, provider: "anthropic_subscription" };
|
|
106
|
+
});
|
|
107
|
+
if (changed)
|
|
108
|
+
writeAccountsAtomicToPath(path, migrated);
|
|
109
|
+
return changed;
|
|
110
|
+
}
|
|
111
|
+
export function setProviderAccountsEnabled(provider, enabled, path = ACCOUNTS_PATH) {
|
|
112
|
+
const records = readRawFromPath(path);
|
|
113
|
+
let changed = 0;
|
|
114
|
+
const next = records.map(record => {
|
|
115
|
+
if (normalizeAccountProvider(record) !== provider)
|
|
116
|
+
return record;
|
|
117
|
+
changed++;
|
|
118
|
+
return { ...record, provider: normalizeAccountProvider(record), enabled };
|
|
119
|
+
});
|
|
120
|
+
if (changed > 0)
|
|
121
|
+
writeAccountsAtomicToPath(path, next);
|
|
122
|
+
return changed;
|
|
123
|
+
}
|
|
124
|
+
/** Deserialize flat AccountRecord[] from the default path into runtime Account[] */
|
|
125
|
+
export function loadAccounts() {
|
|
126
|
+
return deserialize(readAccountsRaw());
|
|
127
|
+
}
|
|
128
|
+
/** Load OpenAI ChatGPT/Codex subscription accounts without mixing them into the Anthropic pool. */
|
|
129
|
+
export function loadOpenAIAccounts(path) {
|
|
130
|
+
const records = readRawFromPath(path ?? ACCOUNTS_PATH);
|
|
131
|
+
return records
|
|
132
|
+
.filter(a => a.provider === "openai_subscription")
|
|
133
|
+
.map(a => ({
|
|
134
|
+
id: a.id,
|
|
135
|
+
provider: "openai_subscription",
|
|
136
|
+
accessToken: a.accessToken,
|
|
137
|
+
refreshToken: a.refreshToken,
|
|
138
|
+
expiresAt: a.expiresAt,
|
|
139
|
+
enabled: a.enabled !== false,
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
export function saveOpenAIAccounts(accounts) {
|
|
143
|
+
ensureConfigDir();
|
|
144
|
+
const existing = readAccountsRaw();
|
|
145
|
+
const nonOpenAI = existing.filter(a => a.provider !== "openai_subscription");
|
|
146
|
+
const records = accounts.map(a => ({
|
|
147
|
+
id: a.id,
|
|
148
|
+
provider: "openai_subscription",
|
|
149
|
+
accessToken: a.accessToken,
|
|
150
|
+
refreshToken: a.refreshToken,
|
|
151
|
+
expiresAt: a.expiresAt,
|
|
152
|
+
scopes: ["openid", "profile", "email", "offline_access"],
|
|
153
|
+
enabled: a.enabled,
|
|
154
|
+
}));
|
|
155
|
+
writeAccountsAtomicToPath(ACCOUNTS_PATH, [...nonOpenAI, ...records]);
|
|
156
|
+
}
|
|
157
|
+
function parseProxyConfig(raw) {
|
|
158
|
+
const parsed = JSON.parse(raw);
|
|
159
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
160
|
+
throw new TypeError(`${CONFIG_PATH} must contain a JSON object`);
|
|
161
|
+
}
|
|
162
|
+
return parsed;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Read config for a read-modify-write operation.
|
|
166
|
+
*
|
|
167
|
+
* Unlike readConfig(), this deliberately propagates read and parse failures so
|
|
168
|
+
* callers cannot replace an unreadable or malformed user config with defaults.
|
|
169
|
+
*/
|
|
170
|
+
export function readConfigStrict() {
|
|
171
|
+
try {
|
|
172
|
+
return parseProxyConfig(readFileSync(CONFIG_PATH, "utf-8"));
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
if (err.code === "ENOENT")
|
|
176
|
+
return {};
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function readConfig() {
|
|
181
|
+
if (!existsSync(CONFIG_PATH))
|
|
182
|
+
return {};
|
|
183
|
+
try {
|
|
184
|
+
return readConfigStrict();
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
console.warn(`Warning: ${CONFIG_PATH} contains invalid JSON: ${err.message}`);
|
|
188
|
+
try {
|
|
189
|
+
const backupPath = CONFIG_PATH + ".bak";
|
|
190
|
+
copyFileSync(CONFIG_PATH, backupPath);
|
|
191
|
+
console.warn(` Backup saved to ${backupPath}`);
|
|
192
|
+
}
|
|
193
|
+
catch { /* best-effort backup */ }
|
|
194
|
+
console.warn(` Using default configuration for this session.`);
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export function getProxyRequestTimeoutMs() {
|
|
199
|
+
const { proxyRequestTimeoutMs, proxyRequesTime } = readConfig();
|
|
200
|
+
const timeoutMs = proxyRequestTimeoutMs ?? proxyRequesTime;
|
|
201
|
+
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
202
|
+
? timeoutMs
|
|
203
|
+
: DEFAULT_PROXY_REQUEST_TIMEOUT_MS;
|
|
204
|
+
}
|
|
205
|
+
function normalizeProxyConfig(cfg) {
|
|
206
|
+
const { proxyRequesTime, ...normalized } = cfg;
|
|
207
|
+
const timeoutMs = normalized.proxyRequestTimeoutMs ?? proxyRequesTime;
|
|
208
|
+
normalized.proxyRequestTimeoutMs =
|
|
209
|
+
typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
210
|
+
? timeoutMs
|
|
211
|
+
: DEFAULT_PROXY_REQUEST_TIMEOUT_MS;
|
|
212
|
+
return normalized;
|
|
213
|
+
}
|
|
214
|
+
export function writeConfig(cfg) {
|
|
215
|
+
ensureConfigDir();
|
|
216
|
+
// config.json holds proxySecret and client.remoteSecret — owner-only.
|
|
217
|
+
writeFileSecureSync(CONFIG_PATH, JSON.stringify(normalizeProxyConfig(cfg), null, 2));
|
|
218
|
+
}
|
|
219
|
+
export function generateProxySecret() {
|
|
220
|
+
return "cc-rtr-" + randomBytes(16).toString("hex");
|
|
221
|
+
}
|
|
222
|
+
// ─── Accounts ─────────────────────────────────────────────────────────────────
|
|
223
|
+
function deserialize(records) {
|
|
224
|
+
return records.filter(a => a.provider === undefined || a.provider === "anthropic_subscription").map(a => ({
|
|
225
|
+
id: a.id,
|
|
226
|
+
tokens: {
|
|
227
|
+
accessToken: a.accessToken,
|
|
228
|
+
refreshToken: a.refreshToken,
|
|
229
|
+
expiresAt: a.expiresAt,
|
|
230
|
+
scopes: a.scopes ?? ["user:inference", "user:profile"],
|
|
231
|
+
},
|
|
232
|
+
healthy: true,
|
|
233
|
+
busy: false,
|
|
234
|
+
requestCount: 0,
|
|
235
|
+
errorCount: 0,
|
|
236
|
+
lastUsed: 0,
|
|
237
|
+
lastRefresh: 0,
|
|
238
|
+
consecutiveErrors: 0,
|
|
239
|
+
rateLimits: { ...DEFAULT_RATE_LIMITS },
|
|
240
|
+
enabled: a.enabled !== false, // default true
|
|
241
|
+
sessionLimitPercent: a.sessionLimitPercent !== undefined
|
|
242
|
+
? clampPercent(a.sessionLimitPercent)
|
|
243
|
+
: ACCOUNT_USER_DEFAULTS.sessionLimitPercent,
|
|
244
|
+
weeklyLimitPercent: a.weeklyLimitPercent !== undefined
|
|
245
|
+
? clampPercent(a.weeklyLimitPercent)
|
|
246
|
+
: ACCOUNT_USER_DEFAULTS.weeklyLimitPercent,
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
/** Serialize runtime Account[] back to the flat on-disk AccountRecord[] shape. */
|
|
250
|
+
export function serialize(accounts) {
|
|
251
|
+
return accounts.map(a => ({
|
|
252
|
+
id: a.id,
|
|
253
|
+
provider: "anthropic_subscription",
|
|
254
|
+
accessToken: a.tokens.accessToken,
|
|
255
|
+
refreshToken: a.tokens.refreshToken,
|
|
256
|
+
expiresAt: a.tokens.expiresAt,
|
|
257
|
+
scopes: a.tokens.scopes,
|
|
258
|
+
enabled: a.enabled,
|
|
259
|
+
sessionLimitPercent: a.sessionLimitPercent,
|
|
260
|
+
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
261
|
+
}));
|
|
262
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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"];
|
|
12
|
+
// Proxy-level config (password, future settings) — separate from accounts.json
|
|
13
|
+
export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
|
|
14
|
+
path.join(CONFIG_DIR, "config.json");
|
|
15
|
+
// Anonymous telemetry state — install id + opt-in flag
|
|
16
|
+
export const TELEMETRY_PATH = process.env["TELEMETRY_PATH"] ??
|
|
17
|
+
path.join(CONFIG_DIR, "telemetry.json");
|
|
18
|
+
// Daemon PID file — written by daemon process, read by stop/status
|
|
19
|
+
export const PID_PATH = path.join(CONFIG_DIR, "cc-router.pid");
|
|
20
|
+
// Log file — daemon stdout/stderr redirect here
|
|
21
|
+
export const LOG_PATH = path.join(CONFIG_DIR, "cc-router.log");
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { TELEMETRY_PATH } from "./paths.js";
|
|
4
|
+
import { ensureConfigDir } from "./manager.js";
|
|
5
|
+
function defaultState() {
|
|
6
|
+
return {
|
|
7
|
+
// Opt-in: telemetry stays off until the user explicitly enables it with
|
|
8
|
+
// `cc-router telemetry on`. Nothing is sent on first run.
|
|
9
|
+
enabled: false,
|
|
10
|
+
installId: randomUUID(),
|
|
11
|
+
firstRunAt: new Date().toISOString(),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// Read the telemetry state, creating and persisting a fresh one on first run.
|
|
15
|
+
// Malformed files are treated as missing so a corrupted file can't crash the CLI.
|
|
16
|
+
export function loadTelemetryState() {
|
|
17
|
+
if (!existsSync(TELEMETRY_PATH)) {
|
|
18
|
+
const state = defaultState();
|
|
19
|
+
writeTelemetryState(state);
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(TELEMETRY_PATH, "utf-8"));
|
|
24
|
+
// Fill any missing fields to keep the file forward-compatible. Missing
|
|
25
|
+
// `enabled` defaults to OFF (opt-in).
|
|
26
|
+
const state = {
|
|
27
|
+
enabled: raw.enabled ?? false,
|
|
28
|
+
installId: raw.installId ?? randomUUID(),
|
|
29
|
+
firstRunAt: raw.firstRunAt ?? new Date().toISOString(),
|
|
30
|
+
};
|
|
31
|
+
if (!raw.installId) {
|
|
32
|
+
writeTelemetryState(state);
|
|
33
|
+
}
|
|
34
|
+
return state;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
const state = defaultState();
|
|
38
|
+
writeTelemetryState(state);
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Atomic write: .tmp + rename, same pattern as writeAccountsAtomic
|
|
43
|
+
export function writeTelemetryState(state) {
|
|
44
|
+
ensureConfigDir();
|
|
45
|
+
const tmp = TELEMETRY_PATH + ".tmp";
|
|
46
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
|
|
47
|
+
renameSync(tmp, TELEMETRY_PATH);
|
|
48
|
+
}
|
|
49
|
+
// Returns true only if the user has not opted out through any mechanism:
|
|
50
|
+
// - DO_NOT_TRACK=1 (de-facto standard)
|
|
51
|
+
// - CC_ROUTER_TELEMETRY=0 (project-specific override)
|
|
52
|
+
// - `cc-router telemetry off` (persisted enabled: false)
|
|
53
|
+
export function isTelemetryEnabled() {
|
|
54
|
+
if (process.env["DO_NOT_TRACK"] === "1")
|
|
55
|
+
return false;
|
|
56
|
+
if (process.env["CC_ROUTER_TELEMETRY"] === "0")
|
|
57
|
+
return false;
|
|
58
|
+
try {
|
|
59
|
+
return loadTelemetryState().enabled;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { openSync, closeSync } from "fs";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import { LOG_PATH, PROXY_PORT } from "../config/paths.js";
|
|
7
|
+
import { ensureConfigDir } from "../config/manager.js";
|
|
8
|
+
import { writePid, getRunningPid, isProcessAlive, removePid, isProxyRunning } from "./pid.js";
|
|
9
|
+
import { isWindows } from "../utils/platform.js";
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = dirname(__filename);
|
|
12
|
+
const CLI_ENTRY = join(__dirname, "..", "cli", "index.js");
|
|
13
|
+
/** Launch cc-router as a detached background process. */
|
|
14
|
+
export async function launchDaemon(opts = {}) {
|
|
15
|
+
const port = opts.port ?? PROXY_PORT;
|
|
16
|
+
// Already running?
|
|
17
|
+
if (await isProxyRunning(port)) {
|
|
18
|
+
console.log(chalk.green(`✓ CC-Router is already running on port ${port}`));
|
|
19
|
+
console.log(chalk.gray(` Logs: cc-router logs | Stop: cc-router stop`));
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
ensureConfigDir();
|
|
23
|
+
// Build args
|
|
24
|
+
const args = [CLI_ENTRY, "start", "--foreground", "--port", String(port)];
|
|
25
|
+
if (opts.litellmUrl)
|
|
26
|
+
args.push("--litellm", opts.litellmUrl);
|
|
27
|
+
if (opts.accountsPath)
|
|
28
|
+
args.push("--accounts", opts.accountsPath);
|
|
29
|
+
// Build env
|
|
30
|
+
const env = { ...process.env, CC_ROUTER_DAEMON: "1" };
|
|
31
|
+
if (opts.serverMode)
|
|
32
|
+
env["HOST"] = "0.0.0.0";
|
|
33
|
+
// Open log file (append mode) for stdout+stderr redirection
|
|
34
|
+
let logFd;
|
|
35
|
+
try {
|
|
36
|
+
logFd = openSync(LOG_PATH, "a");
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
console.error(chalk.red(`✗ Cannot open log file: ${LOG_PATH}`));
|
|
40
|
+
console.error(chalk.gray(` ${err.message}`));
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const child = spawn(process.execPath, args, {
|
|
44
|
+
detached: true,
|
|
45
|
+
stdio: ["ignore", logFd, logFd],
|
|
46
|
+
env,
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
});
|
|
49
|
+
if (!child.pid) {
|
|
50
|
+
console.error(chalk.red("✗ Failed to start background process"));
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
writePid(child.pid);
|
|
54
|
+
child.unref();
|
|
55
|
+
closeSync(logFd);
|
|
56
|
+
// Wait for health endpoint to respond
|
|
57
|
+
console.log(chalk.gray(" Starting CC-Router in background..."));
|
|
58
|
+
const healthy = await waitForHealth(port, 5_000);
|
|
59
|
+
if (healthy) {
|
|
60
|
+
console.log(chalk.green(`✓ CC-Router running in background on port ${port}`));
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(chalk.yellow(`⚠ Process started (PID ${child.pid}) but not yet responding.`));
|
|
65
|
+
console.log(chalk.gray(` Check logs: cc-router logs`));
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** Stop the cc-router daemon process. */
|
|
70
|
+
export async function stopDaemon(port = PROXY_PORT) {
|
|
71
|
+
// Try PID-based stop first
|
|
72
|
+
const pid = getRunningPid();
|
|
73
|
+
if (pid !== null) {
|
|
74
|
+
try {
|
|
75
|
+
process.kill(pid, "SIGTERM");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Already dead
|
|
79
|
+
removePid();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
// Wait for graceful shutdown (up to 5s)
|
|
83
|
+
const died = await waitForDeath(pid, 5_000);
|
|
84
|
+
if (!died) {
|
|
85
|
+
// Force kill
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, "SIGKILL");
|
|
88
|
+
}
|
|
89
|
+
catch { /* already dead */ }
|
|
90
|
+
// Verify it actually died
|
|
91
|
+
await new Promise(r => setTimeout(r, 500));
|
|
92
|
+
if (isProcessAlive(pid)) {
|
|
93
|
+
console.log(chalk.yellow(` ⚠ Could not kill process ${pid}`));
|
|
94
|
+
return false; // don't remove PID file — process is still alive
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
removePid();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
// Fallback: kill by port (handles foreground processes or legacy PM2)
|
|
101
|
+
return killByPort(port);
|
|
102
|
+
}
|
|
103
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
104
|
+
async function waitForHealth(port, timeoutMs) {
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
while (Date.now() - start < timeoutMs) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(`http://localhost:${port}/cc-router/health`, {
|
|
109
|
+
signal: AbortSignal.timeout(500),
|
|
110
|
+
});
|
|
111
|
+
if (res.ok)
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch { /* not ready yet */ }
|
|
115
|
+
await sleep(300);
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
async function waitForDeath(pid, timeoutMs) {
|
|
120
|
+
const start = Date.now();
|
|
121
|
+
while (Date.now() - start < timeoutMs) {
|
|
122
|
+
if (!isProcessAlive(pid))
|
|
123
|
+
return true;
|
|
124
|
+
await sleep(200);
|
|
125
|
+
}
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
async function killByPort(port) {
|
|
129
|
+
const { execFile } = await import("child_process");
|
|
130
|
+
const { promisify } = await import("util");
|
|
131
|
+
const execFileAsync = promisify(execFile);
|
|
132
|
+
try {
|
|
133
|
+
if (isWindows()) {
|
|
134
|
+
const { stdout } = await execFileAsync("netstat", ["-ano"]);
|
|
135
|
+
const match = stdout
|
|
136
|
+
.split("\n")
|
|
137
|
+
.find(line => line.includes(`:${port}`) && line.includes("LISTENING"));
|
|
138
|
+
if (!match)
|
|
139
|
+
return false;
|
|
140
|
+
const pid = match.trim().split(/\s+/).at(-1);
|
|
141
|
+
if (!pid || isNaN(Number(pid)))
|
|
142
|
+
return false;
|
|
143
|
+
await execFileAsync("taskkill", ["/PID", pid, "/F"]);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
|
|
148
|
+
const pids = stdout.trim().split("\n").filter(Boolean);
|
|
149
|
+
if (pids.length === 0)
|
|
150
|
+
return false;
|
|
151
|
+
for (const p of pids) {
|
|
152
|
+
await execFileAsync("kill", ["-TERM", p]);
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function sleep(ms) {
|
|
162
|
+
return new Promise(r => setTimeout(r, ms));
|
|
163
|
+
}
|