ai-cc-router 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -7,6 +7,21 @@ Distribute Claude Code requests across N subscriptions to multiply your throughp
7
7
  [![npm](https://img.shields.io/npm/v/ai-cc-router)](https://www.npmjs.com/package/ai-cc-router)
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
9
 
10
+ ### Features
11
+
12
+ - **Round-robin token rotation** — distribute requests across 2-20 Claude Max accounts automatically
13
+ - **Transparent proxy** — Claude Code works normally; streaming, thinking, tool use, prompt caching all pass through
14
+ - **Automatic token refresh** — OAuth tokens are refreshed before they expire, saved atomically to disk
15
+ - **Rate limit awareness** — detects 429/529 responses and coolsdown accounts; picks the least-loaded one
16
+ - **Client mode** — connect to a remote CC-Router from any machine with one command (`cc-router client connect <url>`)
17
+ - **Claude Desktop support** — route Cowork / Agent-mode traffic through CC-Router via mitmproxy interception (macOS, Windows, Linux)
18
+ - **Guided setup wizard** — interactive `cc-router setup` extracts tokens from Keychain or credentials file, configures everything
19
+ - **Live dashboard** — real-time terminal UI showing account health, request counts, token usage, recent activity
20
+ - **Proxy authentication** — optional Bearer / x-api-key secret for internet-exposed deployments
21
+ - **Auto-update** — patch/minor releases install automatically (opt-out available)
22
+ - **Multiple deployment modes** — foreground, PM2 daemon, system service, Docker Compose (with LiteLLM)
23
+ - **Cross-platform** — macOS, Linux, Windows; Node.js 20+
24
+
10
25
  ---
11
26
 
12
27
  > **Warning**
@@ -498,12 +513,47 @@ Press `q` to quit. Run with `--json` for non-interactive output.
498
513
  - The file is excluded by `.gitignore`
499
514
  - Writes are atomic (write to `.tmp`, then rename) — no corruption on crash
500
515
  - Keychain reads use `execFile` with a fixed argument array — no shell injection
501
- - No telemetry, no external logging
516
+ - Anonymous opt-out telemetry via [Aptabase](https://aptabase.com) (see [Telemetry](#telemetry) below)
502
517
 
503
518
  See [docs/security.md](docs/security.md) for details.
504
519
 
505
520
  ---
506
521
 
522
+ ## Telemetry
523
+
524
+ CC-Router sends a handful of anonymous lifecycle events to [Aptabase](https://aptabase.com) (privacy-first, open source, EU-hosted). The goal is simple: know how many people use the project, which versions are live, and roughly how many instances are running — so we can prioritize fixes and features.
525
+
526
+ **What we send** — the entire payload lives in [`src/utils/telemetry.ts`](src/utils/telemetry.ts), audit it yourself:
527
+
528
+ | Event | When | Custom props |
529
+ | -------------------- | ------------------------------------------------ | ---------------------------------------- |
530
+ | `app_started` | First proxy start after install | `first_run: true` |
531
+ | `setup_completed` | Setup wizard finishes successfully | `account_count` |
532
+ | `proxy_started` | Each `cc-router start` | `account_count`, `mode` |
533
+ | `proxy_heartbeat` | Every 6h while the proxy is running | `uptime_hours`, `account_count` |
534
+ | `telemetry_disabled` | When you run `cc-router telemetry off` | — |
535
+
536
+ Plus anonymous system props with every event: `appVersion`, `osName` (macOS/Linux/Windows), `osVersion`, `locale`, `engineVersion` (Node), and an anonymous `installId` (random UUID generated on first run, stored in `~/.cc-router/telemetry.json`).
537
+
538
+ **What we never send**: IPs, OAuth tokens, account names, request content, prompts, responses, URLs, hostnames, usernames, file paths — nothing that could identify you or your usage patterns.
539
+
540
+ **Disable it** — three ways, any one works:
541
+
542
+ ```bash
543
+ # 1. Persistent opt-out (recommended)
544
+ cc-router telemetry off
545
+
546
+ # 2. Respect the de-facto standard (honored by many OSS tools)
547
+ export DO_NOT_TRACK=1
548
+
549
+ # 3. Project-specific override
550
+ export CC_ROUTER_TELEMETRY=0
551
+ ```
552
+
553
+ Check status anytime: `cc-router telemetry status`.
554
+
555
+ ---
556
+
507
557
  ## Disclaimer
508
558
 
509
559
  > CC-Router uses the OAuth tokens of your own Claude Max subscriptions.
@@ -204,7 +204,11 @@ export function registerClient(program) {
204
204
  const status = log.statusCode ?? 0;
205
205
  const statusColor = status >= 500 || status === 0 ? chalk.red : status >= 400 ? chalk.yellow : chalk.green;
206
206
  const duration = log.durationMs ? ` ${chalk.gray(log.durationMs + "ms")}` : "";
207
- console.log(` ${chalk.gray(formatTime(log.ts))} ${log.accountId.padEnd(18)} ` +
207
+ const src = log.source === "cli" ? chalk.blue("cli")
208
+ : log.source === "desktop" ? chalk.magenta("dsk")
209
+ : log.source === "api" ? chalk.gray("api")
210
+ : chalk.gray(" ");
211
+ console.log(` ${chalk.gray(formatTime(log.ts))} ${src} ${log.accountId.padEnd(18)} ` +
208
212
  `${(log.method ?? "?").padEnd(5)} ${(log.path ?? "?").padEnd(22)} ` +
209
213
  `${statusColor(String(status))}${duration}`);
210
214
  }
@@ -13,6 +13,8 @@ import { DEFAULT_RATE_LIMITS } from "../proxy/types.js";
13
13
  import { existsSync } from "fs";
14
14
  import { checkMitmproxyInstalled, isCaCertInstalled, generateCaCert, installCaCert, writeAddonScript, getNetworkExtensionStatus, openNetworkExtensionSettings, } from "../interceptor/mitmproxy-manager.js";
15
15
  import { printDesktopSupportExplainer, printNetworkExtensionInstructions } from "./cmd-client.js";
16
+ import { loadTelemetryState, writeTelemetryState } from "../config/telemetry.js";
17
+ import { trackEvent } from "../utils/telemetry.js";
16
18
  const execFileAsync = promisify(execFile);
17
19
  // ─── Public registration ──────────────────────────────────────────────────────
18
20
  export function registerSetup(program) {
@@ -212,9 +214,38 @@ async function runSetupWizard({ addMode }) {
212
214
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Saving\n${"━".repeat(40)}\n`));
213
215
  saveAccounts(merged);
214
216
  console.log(chalk.green(` ✓ ${merged.length} account(s) saved to ~/.cc-router/accounts.json`));
217
+ showTelemetryDisclosureIfNeeded();
218
+ void trackEvent("setup_completed", { account_count: merged.length });
215
219
  // ─── Post-setup interactive flow ─────────────────────────────────────────
216
220
  await runPostSetupFlow(merged.length);
217
221
  }
222
+ // Anonymous telemetry disclosure, shown exactly once after a successful setup.
223
+ // Controlled by telemetry.disclosureShown in ~/.cc-router/telemetry.json.
224
+ function showTelemetryDisclosureIfNeeded() {
225
+ try {
226
+ const state = loadTelemetryState();
227
+ if (state.disclosureShown)
228
+ return;
229
+ console.log();
230
+ console.log(chalk.dim("─".repeat(60)));
231
+ console.log(chalk.bold(" Anonymous usage analytics"));
232
+ console.log();
233
+ console.log(" CC-Router sends anonymous lifecycle events (version, OS,");
234
+ console.log(" startup, heartbeat) to help us understand usage and prioritize");
235
+ console.log(" improvements. No IPs, no tokens, no prompts, no request content.");
236
+ console.log();
237
+ console.log(` Disable: ${chalk.cyan("cc-router telemetry off")}`);
238
+ console.log(` Or set: ${chalk.cyan("DO_NOT_TRACK=1")} | ${chalk.cyan("CC_ROUTER_TELEMETRY=0")}`);
239
+ console.log(` Source: ${chalk.dim("src/utils/telemetry.ts")}`);
240
+ console.log(chalk.dim("─".repeat(60)));
241
+ console.log();
242
+ state.disclosureShown = true;
243
+ writeTelemetryState(state);
244
+ }
245
+ catch {
246
+ // never block setup on telemetry errors
247
+ }
248
+ }
218
249
  // ─── Post-setup interactive flow ─────────────────────────────────────────────
219
250
  async function runPostSetupFlow(accountCount) {
220
251
  console.log(chalk.bold(`\n${"━".repeat(40)}\n Configure this machine\n${"━".repeat(40)}\n`));
@@ -0,0 +1,60 @@
1
+ import chalk from "chalk";
2
+ import { loadTelemetryState, writeTelemetryState, isTelemetryEnabled } from "../config/telemetry.js";
3
+ import { trackEvent } from "../utils/telemetry.js";
4
+ export function registerTelemetry(program) {
5
+ program
6
+ .command("telemetry [action]")
7
+ .description("Manage anonymous usage analytics: on, off, status (default: status)")
8
+ .action(async (action) => {
9
+ const resolved = action ?? "status";
10
+ if (resolved === "status") {
11
+ showStatus();
12
+ return;
13
+ }
14
+ if (resolved === "on") {
15
+ const state = loadTelemetryState();
16
+ state.enabled = true;
17
+ writeTelemetryState(state);
18
+ console.log(chalk.green("Telemetry enabled."));
19
+ console.log(chalk.dim(`Install ID: ${state.installId}`));
20
+ return;
21
+ }
22
+ if (resolved === "off") {
23
+ // Send one last event so we know about opt-out rates
24
+ await trackEvent("telemetry_disabled");
25
+ const state = loadTelemetryState();
26
+ state.enabled = false;
27
+ writeTelemetryState(state);
28
+ console.log(chalk.yellow("Telemetry disabled. No data will be sent."));
29
+ console.log(chalk.dim("Re-enable anytime with: cc-router telemetry on"));
30
+ return;
31
+ }
32
+ console.error(chalk.red(`Unknown action "${resolved}". Use: on, off, status`));
33
+ process.exitCode = 1;
34
+ });
35
+ }
36
+ function showStatus() {
37
+ const state = loadTelemetryState();
38
+ const envDisabled = process.env["DO_NOT_TRACK"] === "1" || process.env["CC_ROUTER_TELEMETRY"] === "0";
39
+ console.log(chalk.bold("Telemetry"));
40
+ console.log();
41
+ if (envDisabled) {
42
+ console.log(` Status: ${chalk.yellow("disabled")} (by environment variable)`);
43
+ }
44
+ else if (state.enabled) {
45
+ console.log(` Status: ${chalk.green("enabled")}`);
46
+ }
47
+ else {
48
+ console.log(` Status: ${chalk.yellow("disabled")}`);
49
+ }
50
+ console.log(` Active: ${isTelemetryEnabled() ? chalk.green("yes") : chalk.yellow("no")}`);
51
+ console.log(` Install ID: ${chalk.dim(state.installId)}`);
52
+ console.log(` Since: ${chalk.dim(state.firstRunAt)}`);
53
+ console.log();
54
+ console.log(chalk.dim(" What we send: version, OS, locale, lifecycle events (start, heartbeat)"));
55
+ console.log(chalk.dim(" What we DON'T: IPs, tokens, prompts, request content, account names"));
56
+ console.log(chalk.dim(" Source code: src/utils/telemetry.ts"));
57
+ console.log();
58
+ console.log(chalk.dim(" Disable: cc-router telemetry off"));
59
+ console.log(chalk.dim(" Or set: DO_NOT_TRACK=1 | CC_ROUTER_TELEMETRY=0"));
60
+ }
package/dist/cli/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerConfigure } from "./cmd-configure.js";
10
10
  import { registerDocker } from "./cmd-docker.js";
11
11
  import { registerUpdate } from "./cmd-update.js";
12
12
  import { registerClient } from "./cmd-client.js";
13
+ import { registerTelemetry } from "./cmd-telemetry.js";
13
14
  import { getCurrentVersion, checkForUpdate, printUpdateBanner } from "../utils/self-update.js";
14
15
  const program = new Command();
15
16
  program
@@ -42,6 +43,7 @@ registerConfigure(program);
42
43
  registerDocker(program);
43
44
  registerUpdate(program);
44
45
  registerClient(program);
46
+ registerTelemetry(program);
45
47
  // Background update check — fires on every CLI invocation, uses 6h disk cache
46
48
  // so it's essentially free after the first check. Notify on process exit.
47
49
  if (!process.env["NO_UPDATE_NOTIFIER"] && !process.env["CI"]) {
@@ -12,3 +12,6 @@ export const LITELLM_URL = process.env["LITELLM_URL"];
12
12
  // Proxy-level config (password, future settings) — separate from accounts.json
13
13
  export const CONFIG_PATH = process.env["CONFIG_PATH"] ??
14
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");
@@ -0,0 +1,63 @@
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
+ enabled: true,
8
+ installId: randomUUID(),
9
+ firstRunAt: new Date().toISOString(),
10
+ disclosureShown: false,
11
+ };
12
+ }
13
+ // Read the telemetry state, creating and persisting a fresh one on first run.
14
+ // Malformed files are treated as missing so a corrupted file can't crash the CLI.
15
+ export function loadTelemetryState() {
16
+ if (!existsSync(TELEMETRY_PATH)) {
17
+ const state = defaultState();
18
+ writeTelemetryState(state);
19
+ return state;
20
+ }
21
+ try {
22
+ const raw = JSON.parse(readFileSync(TELEMETRY_PATH, "utf-8"));
23
+ // Fill any missing fields to keep the file forward-compatible
24
+ const state = {
25
+ enabled: raw.enabled ?? true,
26
+ installId: raw.installId ?? randomUUID(),
27
+ firstRunAt: raw.firstRunAt ?? new Date().toISOString(),
28
+ disclosureShown: raw.disclosureShown ?? false,
29
+ };
30
+ if (!raw.installId || raw.disclosureShown === undefined) {
31
+ writeTelemetryState(state);
32
+ }
33
+ return state;
34
+ }
35
+ catch {
36
+ const state = defaultState();
37
+ writeTelemetryState(state);
38
+ return state;
39
+ }
40
+ }
41
+ // Atomic write: .tmp + rename, same pattern as writeAccountsAtomic
42
+ export function writeTelemetryState(state) {
43
+ ensureConfigDir();
44
+ const tmp = TELEMETRY_PATH + ".tmp";
45
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf-8");
46
+ renameSync(tmp, TELEMETRY_PATH);
47
+ }
48
+ // Returns true only if the user has not opted out through any mechanism:
49
+ // - DO_NOT_TRACK=1 (de-facto standard)
50
+ // - CC_ROUTER_TELEMETRY=0 (project-specific override)
51
+ // - `cc-router telemetry off` (persisted enabled: false)
52
+ export function isTelemetryEnabled() {
53
+ if (process.env["DO_NOT_TRACK"] === "1")
54
+ return false;
55
+ if (process.env["CC_ROUTER_TELEMETRY"] === "0")
56
+ return false;
57
+ try {
58
+ return loadTelemetryState().enabled;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
@@ -6,6 +6,8 @@ import { TokenPool } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountToken, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
7
  import { loadAccounts, accountsFileExists, readAccountsFromPath, readConfig } from "../config/manager.js";
8
8
  import { checkForUpdate, performUpdate, restartSelf } from "../utils/self-update.js";
9
+ import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
+ import { loadTelemetryState } from "../config/telemetry.js";
9
11
  import { logRoute, logError, logStartup } from "./logger.js";
10
12
  import { stats } from "./stats.js";
11
13
  import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
@@ -317,6 +319,11 @@ export async function startServer(opts = {}) {
317
319
  }
318
320
  req._ccAccount = account;
319
321
  req._startTime = Date.now();
322
+ const source = req.headers["x-claude-code-session-id"]
323
+ ? "cli"
324
+ : req.headers["x-api-key"]
325
+ ? "desktop"
326
+ : "api";
320
327
  req._pendingLog = {
321
328
  ts: Date.now(),
322
329
  accountId: account.id,
@@ -324,6 +331,7 @@ export async function startServer(opts = {}) {
324
331
  type: "route",
325
332
  method: req.method,
326
333
  path: req.path,
334
+ source,
327
335
  };
328
336
  stats.totalRequests++;
329
337
  logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - Date.now()) / 60_000));
@@ -377,5 +385,22 @@ export async function startServer(opts = {}) {
377
385
  logStartup(port, host, mode, target, accounts.length);
378
386
  if (autoUpdate)
379
387
  console.log(chalk.gray(" Auto-update: enabled (patch/minor)"));
388
+ // Anonymous telemetry — fire-and-forget, never blocks proxy startup
389
+ try {
390
+ const telemetryState = loadTelemetryState();
391
+ // First-run detection: if the install is brand new, emit app_started too
392
+ const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
393
+ if (firstRunAge < 5 * 60 * 1000) {
394
+ void trackEvent("app_started", { first_run: true });
395
+ }
396
+ void trackEvent("proxy_started", {
397
+ account_count: accounts.length,
398
+ mode,
399
+ });
400
+ startHeartbeat(accounts.length);
401
+ }
402
+ catch {
403
+ // never let telemetry break the proxy
404
+ }
380
405
  });
381
406
  }
@@ -146,6 +146,13 @@ function LogRow({ log, selected }) {
146
146
  : "gray";
147
147
  const bg = selected ? "white" : undefined;
148
148
  const fg = (c) => selected ? "black" : c;
149
+ const sourceLabel = log.source === "cli" ? "cli"
150
+ : log.source === "desktop" ? "dsk"
151
+ : log.source === "api" ? "api"
152
+ : " ";
153
+ const sourceColor = log.source === "cli" ? "blue"
154
+ : log.source === "desktop" ? "magenta"
155
+ : "gray";
149
156
  // Per-request token stats
150
157
  const inputTok = (log.cacheReadTokens ?? 0) + (log.cacheCreationTokens ?? 0) + (log.inputTokens ?? 0);
151
158
  const outputTok = log.outputTokens ?? 0;
@@ -154,7 +161,7 @@ function LogRow({ log, selected }) {
154
161
  : cacheHitPct >= 70 ? "green"
155
162
  : cacheHitPct >= 30 ? "yellow"
156
163
  : "red";
157
- return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
164
+ return (_jsxs(Box, { children: [_jsxs(Text, { backgroundColor: bg, color: fg(undefined), children: [selected ? "▶" : " ", " ", time, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [typeIcon, " "] }), _jsxs(Text, { backgroundColor: bg, color: fg(sourceColor), children: [sourceLabel, " "] }), _jsx(Text, { backgroundColor: bg, color: fg("cyan"), children: log.accountId.slice(0, 22).padEnd(22) }), log.method && log.path
158
165
  ? _jsxs(Text, { backgroundColor: bg, color: fg("white"), children: [" ", log.method, " ", log.path.padEnd(14)] })
159
166
  : _jsxs(Text, { backgroundColor: bg, color: fg(typeColor), children: [" ", log.type.padEnd(9)] }), log.statusCode !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg(statusColor), children: [" ", log.statusCode] })), log.durationMs !== undefined && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.durationMs, "ms"] })), cacheHitPct !== null && (_jsxs(Text, { backgroundColor: bg, color: fg(cacheColor), children: [" \u2191", cacheHitPct, "%"] })), (inputTok > 0 || outputTok > 0) && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", fmtTok(inputTok), "\u2191 ", fmtTok(outputTok), "\u2193"] })), log.details && (_jsxs(Text, { backgroundColor: bg, color: fg("gray"), children: [" ", log.details] }))] }));
160
167
  }
@@ -174,7 +181,7 @@ function DetailPanel({ log }) {
174
181
  : log.statusCode >= 500 ? "red"
175
182
  : log.statusCode >= 400 ? "yellow"
176
183
  : "green";
177
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
184
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, color: isError ? "red" : "cyan", children: " DETAILS " }), _jsxs(Box, { marginTop: 1, flexDirection: "column", gap: 0, children: [_jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Time", value: time }), _jsx(Field, { label: "Account", value: log.accountId })] }), _jsxs(Box, { gap: 2, children: [_jsx(Field, { label: "Method", value: log.method ?? "—" }), _jsx(Field, { label: "Path", value: log.path ?? "—" })] }), _jsxs(Box, { gap: 2, children: [_jsx(FieldColored, { label: "Status", value: statusLabel, color: statusColor }), _jsx(Field, { label: "Duration", value: log.durationMs !== undefined ? `${log.durationMs}ms` : "—" }), _jsx(Field, { label: "Type", value: log.type }), _jsx(Field, { label: "Source", value: sourceFullLabel(log.source) })] }), log.details && (_jsx(Box, { children: _jsx(Field, { label: "Details", value: log.details }) })), log.cacheReadTokens !== undefined && (_jsx(Box, { gap: 2, children: _jsx(CacheBreakdown, { read: log.cacheReadTokens, created: log.cacheCreationTokens ?? 0, input: log.inputTokens ?? 0, output: log.outputTokens ?? 0 }) }))] })] }));
178
185
  }
179
186
  function Field({ label, value }) {
180
187
  return (_jsxs(Box, { children: [_jsxs(Text, { color: "gray", children: [label, ": "] }), _jsx(Text, { color: "white", children: value })] }));
@@ -215,6 +222,16 @@ function fmtTok(n) {
215
222
  return `${(n / 1_000).toFixed(1)}k`;
216
223
  return String(n);
217
224
  }
225
+ // ─── Source label ─────────────────────────────────────────────────────────────
226
+ function sourceFullLabel(source) {
227
+ if (source === "cli")
228
+ return "Claude Code";
229
+ if (source === "desktop")
230
+ return "Claude Desktop";
231
+ if (source === "api")
232
+ return "API";
233
+ return "—";
234
+ }
218
235
  // ─── HTTP status text ─────────────────────────────────────────────────────────
219
236
  function httpStatusText(code) {
220
237
  const map = {
@@ -0,0 +1,86 @@
1
+ import os from "os";
2
+ import { isTelemetryEnabled, loadTelemetryState } from "../config/telemetry.js";
3
+ import { detectPlatform } from "./platform.js";
4
+ import { getCurrentVersion } from "./self-update.js";
5
+ // ─── Aptabase configuration ──────────────────────────────────────────────────
6
+ // Aptabase is a privacy-first, open source analytics service.
7
+ // The full payload we send is documented below — search for "trackEvent" calls
8
+ // in the codebase to audit every event. Nothing here contains PII.
9
+ const APTABASE_APP_KEY = "A-EU-1060569594";
10
+ const APTABASE_ENDPOINT = "https://eu.aptabase.com/api/v0/event";
11
+ const TIMEOUT_MS = 3_000;
12
+ function getOsName() {
13
+ switch (detectPlatform()) {
14
+ case "macos": return "macOS";
15
+ case "linux": return "Linux";
16
+ case "windows": return "Windows";
17
+ }
18
+ }
19
+ function getLocale() {
20
+ try {
21
+ return Intl.DateTimeFormat().resolvedOptions().locale;
22
+ }
23
+ catch {
24
+ return process.env["LANG"]?.split(".")[0] ?? "unknown";
25
+ }
26
+ }
27
+ function getSystemProps() {
28
+ return {
29
+ isDebug: false,
30
+ locale: getLocale(),
31
+ osName: getOsName(),
32
+ osVersion: os.release(),
33
+ appVersion: getCurrentVersion(),
34
+ engineName: "node",
35
+ engineVersion: process.versions.node,
36
+ sdkVersion: `cc-router@${getCurrentVersion()}`,
37
+ };
38
+ }
39
+ // Session ID: <installId>-<epoch-hours>. This groups events that belong to the
40
+ // same "session" (proxy run) without leaking any timing precision finer than 1h.
41
+ function getSessionId(installId) {
42
+ const epochHours = Math.floor(Date.now() / 3_600_000);
43
+ return `${installId}-${epochHours}`;
44
+ }
45
+ // ─── Public API ──────────────────────────────────────────────────────────────
46
+ // Fire-and-forget: never throws, never blocks the caller. If telemetry is
47
+ // disabled (env var or opt-out) this is a synchronous no-op.
48
+ export async function trackEvent(eventName, props) {
49
+ try {
50
+ if (!isTelemetryEnabled())
51
+ return;
52
+ const state = loadTelemetryState();
53
+ const body = {
54
+ timestamp: new Date().toISOString(),
55
+ sessionId: getSessionId(state.installId),
56
+ eventName,
57
+ systemProps: getSystemProps(),
58
+ props: props ?? {},
59
+ };
60
+ await fetch(APTABASE_ENDPOINT, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "application/json",
64
+ "App-Key": APTABASE_APP_KEY,
65
+ },
66
+ body: JSON.stringify(body),
67
+ signal: AbortSignal.timeout(TIMEOUT_MS),
68
+ });
69
+ }
70
+ catch {
71
+ // Silently swallow — telemetry must never disrupt the proxy
72
+ }
73
+ }
74
+ // Start a heartbeat that fires every 6 hours while the proxy is running.
75
+ // Uses .unref() so the timer does not prevent Node from exiting.
76
+ export function startHeartbeat(accountCount) {
77
+ const startTime = Date.now();
78
+ const timer = setInterval(() => {
79
+ const uptimeHours = Math.floor((Date.now() - startTime) / 3_600_000);
80
+ trackEvent("proxy_heartbeat", {
81
+ uptime_hours: uptimeHours,
82
+ account_count: accountCount,
83
+ });
84
+ }, 6 * 60 * 60 * 1000);
85
+ timer.unref();
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cc-router",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Round-robin proxy for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {