@timo972/cc-router 0.12.1 → 0.12.2-rc.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -1
  4. package/dist/cli/cmd-accounts.js +116 -65
  5. package/dist/cli/cmd-setup.js +100 -30
  6. package/dist/cli/cmd-status.js +14 -2
  7. package/dist/cli/cmd-telemetry.js +43 -32
  8. package/dist/cli/index.js +15 -1
  9. package/dist/config/directory.js +14 -0
  10. package/dist/config/telemetry.js +192 -41
  11. package/dist/providers/anthropic/usage-refresher.js +35 -1
  12. package/dist/providers/model-discovery.js +17 -11
  13. package/dist/providers/openai/device-oauth.js +88 -31
  14. package/dist/providers/openai/token-refresher.js +12 -2
  15. package/dist/providers/openai/usage-fetch.js +19 -2
  16. package/dist/proxy/anthropic-messages-route.js +144 -5
  17. package/dist/proxy/anthropic-proxy.js +10 -0
  18. package/dist/proxy/anthropic-response-capture.js +6 -19
  19. package/dist/proxy/openai-ingress.js +98 -1
  20. package/dist/proxy/server.js +35 -22
  21. package/dist/proxy/token-refresher.js +12 -2
  22. package/dist/proxy/usage-capture.js +41 -4
  23. package/dist/telemetry/contracts.js +129 -0
  24. package/dist/telemetry/facade.js +654 -0
  25. package/dist/telemetry/otel-exporters.js +289 -0
  26. package/dist/telemetry/posthog-client.js +398 -0
  27. package/dist/telemetry/privacy.js +567 -0
  28. package/dist/telemetry/runtime.js +306 -0
  29. package/dist/telemetry/setup-diagnostics.js +239 -0
  30. package/dist/utils/token-extractor.js +79 -11
  31. package/dist/utils/token-validator.js +25 -9
  32. package/docs/README.md +34 -0
  33. package/docs/telemetry.md +167 -0
  34. package/package.json +12 -2
  35. package/dist/utils/telemetry.js +0 -88
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Validate an OAuth access token against the Anthropic API.
3
+ *
4
+ * Uses GET /v1/models — lightweight call that doesn't create any resources.
5
+ * Required header: anthropic-version (per API spec).
6
+ * Auth: Authorization: Bearer <token> (OAuth tokens use Bearer, not x-api-key).
7
+ */
8
+ import { classifyHttpSetupFailure, classifyNetworkSetupFailure, } from "../telemetry/setup-diagnostics.js";
1
9
  export async function validateToken(accessToken) {
2
10
  try {
3
11
  const res = await fetch("https://api.anthropic.com/v1/models", {
@@ -10,17 +18,25 @@ export async function validateToken(accessToken) {
10
18
  });
11
19
  if (res.ok)
12
20
  return { valid: true };
13
- if (res.status === 401) {
14
- return { valid: false, reason: "Token invalid or expired (401)" };
15
- }
16
- if (res.status === 403) {
17
- return { valid: false, reason: "Token lacks required scopes (403) — needs user:inference" };
18
- }
19
- // Any other non-ok status is unexpected but the token may still work
20
- return { valid: false, reason: `Unexpected HTTP ${res.status}` };
21
+ const reason = res.status === 401
22
+ ? "Token invalid or expired (401)"
23
+ : res.status === 403
24
+ ? "Token lacks required scopes (403) — needs user:inference"
25
+ // Any other non-ok status is unexpected but the token may still work
26
+ : `Unexpected HTTP ${res.status}`;
27
+ return {
28
+ valid: false,
29
+ reason,
30
+ diagnostic: classifyHttpSetupFailure("token_validation", res.status, reason),
31
+ };
21
32
  }
22
33
  catch (err) {
23
34
  // Network error — can't validate, let user decide
24
- return { valid: false, reason: `Network error: ${err.message}` };
35
+ const reason = `Network error: ${err.message}`;
36
+ return {
37
+ valid: false,
38
+ reason,
39
+ diagnostic: classifyNetworkSetupFailure("token_validation", err, reason),
40
+ };
25
41
  }
26
42
  }
package/docs/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # CC-Router documentation
2
+
3
+ Start with the [README](../README.md) for what CC-Router is and a quickstart.
4
+
5
+ ## Setup
6
+
7
+ | Guide | What's in it |
8
+ |---|---|
9
+ | [Installation & deployment](installation.md) | Requirements, per-platform token extraction, run modes, Docker |
10
+ | [CLI reference](cli-reference.md) | Every command and flag |
11
+ | [Codex CLI & OpenAI](codex.md) | Responses endpoint, model prefixes, OpenAI subscription accounts |
12
+ | [Grok / xAI](grok.md) | Adding Grok accounts, and why they are overview-only |
13
+ | [Claude Desktop](claude-desktop.md) | Routing Claude Desktop / Cowork through mitmproxy |
14
+ | [Client mode](client-mode.md) | Connecting another device you own to your router |
15
+ | [LiteLLM](litellm-setup.md) | Optional logging, rate limiting and web dashboard layer |
16
+
17
+ ## Operating it
18
+
19
+ | Guide | What's in it |
20
+ |---|---|
21
+ | [Architecture](architecture.md) | The request path and what each component does |
22
+ | [Session routing](session-routing.md) | How an account is picked, failover behaviour, running it for a team |
23
+ | [Dashboard](dashboard.md) | The live TUI, keybindings, model management, ChatGPT usage resets |
24
+ | [Troubleshooting](troubleshooting.md) | When something doesn't connect |
25
+
26
+ ## Reference
27
+
28
+ | Guide | What's in it |
29
+ |---|---|
30
+ | [OAuth tokens](oauth-tokens.md) | How subscription tokens work and why refresh rotation matters |
31
+ | [Security](security.md) | Token storage, proxy authentication, threat model |
32
+ | [Telemetry](telemetry.md) | Opt-in analytics: what's sent if you enable it |
33
+
34
+ Design specs and implementation plans live under [`superpowers/`](superpowers).
@@ -0,0 +1,167 @@
1
+ # Telemetry
2
+
3
+ CC-Router sends privacy-bounded operational telemetry to the maintainer's
4
+ PostHog EU project: sampled proxy traces, closed-schema diagnostics, a few
5
+ lifecycle events, and sanitized exceptions. This page is the complete public
6
+ inventory of what can leave the machine. The contracts are implemented in
7
+ [`src/telemetry/`](../src/telemetry/) and every outbound record is rebuilt from
8
+ an allowlist immediately before it is exported.
9
+
10
+ ```bash
11
+ cc-router telemetry status # effective state and what is sent
12
+ cc-router telemetry on
13
+ cc-router telemetry off
14
+ ```
15
+
16
+ ## Enablement
17
+
18
+ Telemetry is **on by default for fresh installations**. An existing
19
+ `~/.cc-router/telemetry.json` with `enabled: false` stays off after an upgrade.
20
+ The effective state is:
21
+
22
+ ```text
23
+ persisted enabled AND DO_NOT_TRACK != "1" AND CC_ROUTER_TELEMETRY != "0"
24
+ ```
25
+
26
+ `cc-router telemetry off` persists the choice, emits no opt-out beacon, and
27
+ records a new *consent generation*. A running daemon re-reads the state before
28
+ every capture and every export; once it observes a different generation it
29
+ stops exporting for the rest of its life and discards queued records. An HTTPS
30
+ request already in flight cannot be recalled. Environment variables can only
31
+ turn telemetry off, never on. After `cc-router telemetry on`, restart a daemon
32
+ that started while disabled.
33
+
34
+ ## Destination, identity, sampling
35
+
36
+ The only destination host is `eu.i.posthog.com`:
37
+
38
+ | Signal | Endpoint |
39
+ |---|---|
40
+ | analytics events and sanitized exceptions | `https://eu.i.posthog.com/batch/` |
41
+ | OpenTelemetry traces | `https://eu.i.posthog.com/i/v1/traces` |
42
+ | OpenTelemetry logs | `https://eu.i.posthog.com/i/v1/logs` |
43
+
44
+ PostHog necessarily sees the connection's source IP at the transport layer;
45
+ CC-Router does not put it in the payload, disables GeoIP enrichment, and never
46
+ creates a PostHog Person profile (`$process_person_profile: false`,
47
+ `$geoip_disable: true` on every event).
48
+
49
+ A random installation UUID stored in `~/.cc-router/telemetry.json` is the
50
+ stable PostHog `distinctId` and OpenTelemetry `service.instance.id`. It is not
51
+ derived from any user, account, host, network, or machine identifier.
52
+
53
+ Root traces are head-sampled at 10%; child spans follow their root. Diagnostics,
54
+ warnings, errors, lifecycle events, and exceptions are not sampled. Inbound
55
+ `traceparent`/`tracestate`/`baggage` headers are stripped from proxied requests
56
+ and no trace headers are injected upstream.
57
+
58
+ ## Closed inventory
59
+
60
+ Application code cannot choose an arbitrary event name, log body, operation, or
61
+ property. Values outside the closed enums are rejected before export.
62
+
63
+ ### Analytics events
64
+
65
+ | Event | When | Properties |
66
+ |---|---|---|
67
+ | `app.first_start` | first start of a fresh installation, once | runtime fields |
68
+ | `proxy.started` | each `cc-router start` | runtime fields |
69
+ | `proxy.heartbeat` | hourly while the proxy runs | runtime fields |
70
+ | `account_setup.started` / `stage_completed` / `succeeded` / `cancelled` / `failed` | account setup funnel | setup fields |
71
+
72
+ Runtime fields: application version, OS family, runtime mode
73
+ (`foreground`/`daemon`/`service`), bounded account-pool size. Setup fields add
74
+ provider, method, stage, optional safe reason, optional duration bucket, and the
75
+ attempt's random diagnostic ID.
76
+
77
+ ### Log records
78
+
79
+ - `account.setup.diagnostic` — provider, method, stage, optional reason and
80
+ outcome, exact HTTP status, duration bucket, diagnostic ID.
81
+ - `runtime.failure` — operation, provider, reason, outcome, exact HTTP status,
82
+ bounded attempt / pool-size / concurrency / duration values, optional
83
+ diagnostic ID.
84
+
85
+ Both carry severity (`info`, `warn`, `error`, `fatal`), a timestamp, the
86
+ runtime fields above, and trace/span IDs only when emitted inside a sampled
87
+ trace. Console output and local log files are never forwarded.
88
+
89
+ ### Trace spans
90
+
91
+ Operations: `proxy.request`, `provider.inference`, `oauth.refresh`,
92
+ `provider.usage_refresh`, `model.discovery`.
93
+
94
+ A span may carry only: operation, trace/span/parent IDs, kind, start time,
95
+ duration, status, and these attributes — HTTP method and status code, provider
96
+ (`anthropic`/`openai`/`other`), route (`messages`/`responses`/`other`), model
97
+ family (`fable`/`sonnet`/`opus`/`haiku`/`codex`/`other`), request source
98
+ (`cli`/`desktop`/`api`/`other`), runtime mode, streaming flag, stream outcome,
99
+ outcome (`complete`/`rate_limited`/`timeout`/`upstream_error`/`cancelled`/
100
+ `other`), and bounded attempt, pool-size, concurrency, input-token,
101
+ output-token, and duration values. Span events, links, URLs, and headers are
102
+ not exported.
103
+
104
+ ### Setup funnel
105
+
106
+ Providers `anthropic` (`macos_keychain`, `claude_credentials_file`,
107
+ `manual_token`) and `openai` (`manual_token`, `device_oauth`). Stages:
108
+ `attempt_start`, `credential_source_selection`, `credential_read`,
109
+ `credential_parse`, `token_validation`, `device_code_request`,
110
+ `authorization_polling`, `token_exchange`, `access_token_parse`, `persistence`,
111
+ `success`, `cancellation`, `failure`. Safe reasons: `not_found`,
112
+ `permission_denied`, `malformed_credentials`, `invalid_token`, `unauthorized`,
113
+ `forbidden`, `rate_limited`, `upstream_4xx`, `upstream_5xx`, `timeout`,
114
+ `network_failure`, `unexpected_response_shape`, `persistence_failure`,
115
+ `user_cancelled`, `other`. Duration buckets: `under_1s`, `1s_to_5s`,
116
+ `5s_to_30s`, `30s_to_2m`, `over_2m`.
117
+
118
+ ### Sanitized exceptions
119
+
120
+ An unexpected failure is rebuilt as a *new* `Error` containing only: category
121
+ (`setup`/`runtime`), one safe reason, error kind (`error`, `type_error`,
122
+ `range_error`, `reference_error`, `syntax_error`, `uri_error`, `eval_error`,
123
+ `aggregate_error`, `unexpected_error`), optional system code (`EAI_AGAIN`,
124
+ `ECONNREFUSED`, `ECONNRESET`, `ENETUNREACH`, `ENOTFOUND`, `EPIPE`,
125
+ `ETIMEDOUT`), optional HTTP status, operation, provider, setup stage, runtime
126
+ mode, stack frames normalized to `dist/...` or `node_modules/<package>/...`
127
+ (max 20 frames, 256 chars each), a fingerprint over those safe fields, and a
128
+ fresh random diagnostic ID. The original message, cause chain, custom
129
+ properties, and unrecognized frames are dropped. The diagnostic ID is printed
130
+ next to the detailed local error so an issue report can reference it.
131
+
132
+ A fatal (uncaught) exception cannot be sent by the crashing process. Its
133
+ sanitized record is written to `~/.cc-router/telemetry.json.pending.json`
134
+ (mode 0600, at most 20 records) and sent by the next start only if the
135
+ installation ID and consent generation still match; otherwise it is deleted
136
+ unsent.
137
+
138
+ ### Resource and bounds
139
+
140
+ Resource: `service.name` (`cc-router`), `service.version`,
141
+ `service.instance.id` (installation UUID), `process.runtime.version`, `os.type`
142
+ (`macos`/`linux`/`windows`/`other`), `host.arch` (`arm64`/`x64`/`other`),
143
+ `cc_router.runtime_mode`. Automatic resource detection is off; host, process,
144
+ cloud, and environment metadata is not exported.
145
+
146
+ | Value | Accepted range |
147
+ |---|---:|
148
+ | version strings | 1–64 characters |
149
+ | attempt | 0–100 |
150
+ | account-pool size, concurrency | 0–10,000 |
151
+ | input/output tokens | 0–1,000,000,000 |
152
+ | durations | 0–86,400,000 ms |
153
+ | HTTP status | 100–599 |
154
+
155
+ ## Never sent
156
+
157
+ Prompts, tool calls, message content, request or response bodies; OAuth or
158
+ refresh tokens, cookies, credentials, device or user codes; request or
159
+ response headers, URLs, query strings, peer addresses; account IDs or names,
160
+ Claude session IDs, user IDs, emails, usernames; hostnames, home or working
161
+ directories, absolute paths, command lines, PIDs, environment variables;
162
+ raw exception messages, cause chains, custom error properties, or arbitrary
163
+ attributes — raw, encoded, or hashed.
164
+
165
+ Telemetry failures are swallowed at the telemetry boundary and never change
166
+ proxy responses, streamed bytes, retry behavior, exit codes, or crash
167
+ semantics.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.12.1",
3
+ "version": "0.12.2-rc.0",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,15 +36,25 @@
36
36
  "accounts.example.json",
37
37
  "README.md",
38
38
  "CHANGELOG.md",
39
+ "docs/telemetry.md",
39
40
  "LICENSE"
40
41
  ],
41
42
  "dependencies": {
42
43
  "@inquirer/prompts": "^7.0.0",
44
+ "@opentelemetry/api": "^1.9.1",
45
+ "@opentelemetry/api-logs": "^0.221.0",
46
+ "@opentelemetry/otlp-exporter-base": "^0.221.0",
47
+ "@opentelemetry/otlp-transformer": "^0.221.0",
48
+ "@opentelemetry/resources": "^2.10.0",
49
+ "@opentelemetry/sdk-logs": "^0.221.0",
50
+ "@opentelemetry/sdk-trace-base": "^2.10.0",
51
+ "@opentelemetry/sdk-trace-node": "^2.10.0",
43
52
  "chalk": "^5.3.0",
44
53
  "commander": "^12.0.0",
45
54
  "express": "^4.21.0",
46
55
  "http-proxy-middleware": "^3.0.5",
47
56
  "ink": "^5.0.0",
57
+ "posthog-node": "5.47.3",
48
58
  "react": "^18.3.0",
49
59
  "smol-toml": "^1.8.0"
50
60
  },
@@ -61,7 +71,7 @@
61
71
  "node": ">=22.0.0"
62
72
  },
63
73
  "scripts": {
64
- "build": "tsc",
74
+ "build": "node scripts/clean-dist.mjs && tsc",
65
75
  "dev": "tsx src/cli/index.ts",
66
76
  "start": "node dist/cli/index.js",
67
77
  "test": "vitest run",
@@ -1,88 +0,0 @@
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
- // Aptabase limits locale to 10 characters — truncate extended subtags
22
- const raw = Intl.DateTimeFormat().resolvedOptions().locale;
23
- return raw.length <= 10 ? raw : raw.slice(0, 10);
24
- }
25
- catch {
26
- return process.env["LANG"]?.split(".")[0]?.slice(0, 10) ?? "unknown";
27
- }
28
- }
29
- function getSystemProps() {
30
- return {
31
- isDebug: false,
32
- locale: getLocale(),
33
- osName: getOsName(),
34
- osVersion: os.release(),
35
- appVersion: getCurrentVersion(),
36
- engineName: "node",
37
- engineVersion: process.versions.node,
38
- sdkVersion: `cc-router@${getCurrentVersion()}`,
39
- };
40
- }
41
- // Session ID — use the installId directly so Aptabase always identifies the
42
- // same machine as the same user. Aptabase limits sessionId to 36 characters;
43
- // a standard UUID with dashes is exactly 36, so we pass it through unchanged.
44
- function getSessionId(installId) {
45
- return installId.slice(0, 36);
46
- }
47
- // ─── Public API ──────────────────────────────────────────────────────────────
48
- // Fire-and-forget: never throws, never blocks the caller. If telemetry is
49
- // disabled (env var or opt-out) this is a synchronous no-op.
50
- export async function trackEvent(eventName, props) {
51
- try {
52
- if (!isTelemetryEnabled())
53
- return;
54
- const state = loadTelemetryState();
55
- const body = {
56
- timestamp: new Date().toISOString(),
57
- sessionId: getSessionId(state.installId),
58
- eventName,
59
- systemProps: getSystemProps(),
60
- props: props ?? {},
61
- };
62
- await fetch(APTABASE_ENDPOINT, {
63
- method: "POST",
64
- headers: {
65
- "Content-Type": "application/json",
66
- "App-Key": APTABASE_APP_KEY,
67
- },
68
- body: JSON.stringify(body),
69
- signal: AbortSignal.timeout(TIMEOUT_MS),
70
- });
71
- }
72
- catch {
73
- // Silently swallow — telemetry must never disrupt the proxy
74
- }
75
- }
76
- // Start a heartbeat that fires every hour while the proxy is running.
77
- // Uses .unref() so the timer does not prevent Node from exiting.
78
- export function startHeartbeat(accountCount) {
79
- const startTime = Date.now();
80
- const timer = setInterval(() => {
81
- const uptimeMinutes = Math.floor((Date.now() - startTime) / 60_000);
82
- trackEvent("proxy_heartbeat", {
83
- uptime_minutes: uptimeMinutes,
84
- account_count: accountCount,
85
- });
86
- }, 60 * 60 * 1000);
87
- timer.unref();
88
- }