@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,6 +1,7 @@
1
1
  import { writeAnthropicAccountsPreservingOtherProviders, serialize } from "../config/manager.js";
2
2
  import { logRefresh } from "./logger.js";
3
3
  import { stats } from "./stats.js";
4
+ import { classifyExpectedRuntimeFailure, httpOutcome, recordRuntimeError, recordUpstreamStatus, withTelemetrySpan, } from "../telemetry/facade.js";
4
5
  /**
5
6
  * Official Claude Code CLI client_id for the OAuth PKCE flow.
6
7
  * Source: extracted from Claude Code auth flow.
@@ -68,7 +69,12 @@ export async function refreshAccountToken(account) {
68
69
  const existing = rawRefreshLocks.get(account);
69
70
  if (existing)
70
71
  return existing;
71
- const promise = _doRefresh(account);
72
+ const promise = withTelemetrySpan("oauth.refresh", { provider: "anthropic" }, async (span) => {
73
+ const refreshed = await _doRefresh(account, span);
74
+ if (!refreshed)
75
+ span.fail();
76
+ return refreshed;
77
+ });
72
78
  rawRefreshLocks.set(account, promise);
73
79
  try {
74
80
  return await promise;
@@ -155,7 +161,7 @@ export function refreshAccountIfCurrent(account, pool, options = {}) {
155
161
  ownedRefreshLocks.set(account, operation);
156
162
  return operation;
157
163
  }
158
- async function _doRefresh(account) {
164
+ async function _doRefresh(account, span) {
159
165
  try {
160
166
  const body = new URLSearchParams({
161
167
  grant_type: "refresh_token",
@@ -169,6 +175,8 @@ async function _doRefresh(account) {
169
175
  });
170
176
  if (!res.ok) {
171
177
  const body = await res.text();
178
+ recordUpstreamStatus("oauth.refresh", "anthropic", res.status);
179
+ span.fail({ httpStatusCode: res.status, outcome: httpOutcome(res.status) });
172
180
  logRefresh(account.id, false);
173
181
  console.error(` Status: ${res.status} — ${body}`);
174
182
  account.consecutiveErrors++;
@@ -199,6 +207,8 @@ async function _doRefresh(account) {
199
207
  return true;
200
208
  }
201
209
  catch (err) {
210
+ recordRuntimeError(err, { operation: "oauth.refresh", provider: "anthropic" });
211
+ span.fail({ outcome: classifyExpectedRuntimeFailure(err) === "timeout" ? "timeout" : "upstream_error" });
202
212
  logRefresh(account.id, false);
203
213
  console.error(` Error:`, err);
204
214
  account.consecutiveErrors++;
@@ -1,4 +1,5 @@
1
1
  import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
2
+ import { MAX_RETAINED_SSE_LINE_BYTES } from "./stream-lifecycle.js";
2
3
  /** Non-streaming bodies are buffered for one parse at end-of-stream; a body
3
4
  * past this size stops being buffered (usage is best-effort diagnostics —
4
5
  * unbounded buffering of a pathological body is not worth it). */
@@ -31,16 +32,44 @@ export function createAnthropicUsageCapture(options) {
31
32
  return;
32
33
  dead = true;
33
34
  decoder?.destroy();
35
+ options.onSettled?.();
34
36
  };
35
37
  // ── SSE: incremental line parsing, stop once both events were seen ────────
36
38
  let lineBuf = "";
39
+ let discardingOversizedLine = false;
37
40
  let gotInput = false;
38
41
  let gotOutput = false;
39
42
  const parseSSEChunk = (text) => {
40
- lineBuf += text;
41
- const lines = lineBuf.split("\n");
43
+ let rest = text;
44
+ if (discardingOversizedLine) {
45
+ const newline = rest.indexOf("\n");
46
+ if (newline === -1)
47
+ return; // still inside the oversized line
48
+ rest = rest.slice(newline + 1);
49
+ discardingOversizedLine = false;
50
+ }
51
+ if (!rest.includes("\n")) {
52
+ // No line boundary yet: retain a bounded partial line and never re-split
53
+ // the accumulated tail (an unterminated tail would otherwise cost
54
+ // quadratic work and unbounded memory).
55
+ if (lineBuf.length + rest.length > MAX_RETAINED_SSE_LINE_BYTES) {
56
+ lineBuf = "";
57
+ discardingOversizedLine = true;
58
+ }
59
+ else {
60
+ lineBuf += rest;
61
+ }
62
+ return;
63
+ }
64
+ const lines = (lineBuf + rest).split("\n");
42
65
  lineBuf = lines.pop() ?? ""; // keep incomplete last line
66
+ if (lineBuf.length > MAX_RETAINED_SSE_LINE_BYTES) {
67
+ lineBuf = "";
68
+ discardingOversizedLine = true;
69
+ }
43
70
  for (const line of lines) {
71
+ if (dead)
72
+ return;
44
73
  if (!line.startsWith("data: "))
45
74
  continue;
46
75
  try {
@@ -53,9 +82,16 @@ export function createAnthropicUsageCapture(options) {
53
82
  options.onOutputUsage(evt.usage);
54
83
  gotOutput = true;
55
84
  }
85
+ if (evt.type === "message_stop") {
86
+ // The terminal event is the last thing of interest on the stream.
87
+ options.onMessageStop?.();
88
+ die();
89
+ return;
90
+ }
56
91
  // Everything of interest has been seen — stop paying for the rest of
57
- // the stream (and free the decompressor's zlib state).
58
- if (gotInput && gotOutput)
92
+ // the stream (and free the decompressor's zlib state), unless the
93
+ // caller also wants the terminal event.
94
+ if (gotInput && gotOutput && !options.onMessageStop)
59
95
  die();
60
96
  }
61
97
  catch { /* partial JSON across chunk boundary — next chunk completes it */ }
@@ -92,6 +128,7 @@ export function createAnthropicUsageCapture(options) {
92
128
  if (isJSON)
93
129
  parseJSONBody();
94
130
  dead = true;
131
+ options.onSettled?.();
95
132
  };
96
133
  if (!decoder) {
97
134
  return {
@@ -0,0 +1,129 @@
1
+ // Every value telemetry may export comes from one of the closed enums below.
2
+ // Anything outside them is dropped by privacy.ts rather than sanitized.
3
+ export const RUNTIME_MODES = ["foreground", "daemon", "service"];
4
+ export const PROVIDERS = ["anthropic", "openai", "other"];
5
+ export const ROUTES = ["messages", "responses", "other"];
6
+ export const REQUEST_SOURCES = ["cli", "desktop", "api", "other"];
7
+ export const MODEL_FAMILIES = ["fable", "sonnet", "opus", "haiku", "codex", "other"];
8
+ export const OPERATIONS = [
9
+ "proxy.request",
10
+ "provider.inference",
11
+ "oauth.refresh",
12
+ "provider.usage_refresh",
13
+ "model.discovery",
14
+ ];
15
+ export const SETUP_METHODS = [
16
+ "macos_keychain",
17
+ "claude_credentials_file",
18
+ "manual_token",
19
+ "device_oauth",
20
+ ];
21
+ export const SETUP_STAGES = [
22
+ "attempt_start",
23
+ "credential_source_selection",
24
+ "credential_read",
25
+ "credential_parse",
26
+ "token_validation",
27
+ "device_code_request",
28
+ "authorization_polling",
29
+ "token_exchange",
30
+ "access_token_parse",
31
+ "persistence",
32
+ "success",
33
+ "cancellation",
34
+ "failure",
35
+ ];
36
+ export const SETUP_REASONS = [
37
+ "not_found",
38
+ "permission_denied",
39
+ "malformed_credentials",
40
+ "invalid_token",
41
+ "unauthorized",
42
+ "forbidden",
43
+ "rate_limited",
44
+ "upstream_4xx",
45
+ "upstream_5xx",
46
+ "timeout",
47
+ "network_failure",
48
+ "unexpected_response_shape",
49
+ "persistence_failure",
50
+ "user_cancelled",
51
+ "other",
52
+ ];
53
+ export const OUTCOMES = [
54
+ "complete",
55
+ "rate_limited",
56
+ "timeout",
57
+ "upstream_error",
58
+ "cancelled",
59
+ "other",
60
+ ];
61
+ export const STREAM_OUTCOMES = [
62
+ "complete",
63
+ "timeout",
64
+ "upstream_error",
65
+ "cancelled",
66
+ "other",
67
+ ];
68
+ export const ERROR_KINDS = [
69
+ "error",
70
+ "type_error",
71
+ "range_error",
72
+ "reference_error",
73
+ "syntax_error",
74
+ "uri_error",
75
+ "eval_error",
76
+ "aggregate_error",
77
+ "unexpected_error",
78
+ ];
79
+ export const SYSTEM_ERROR_CODES = [
80
+ "EAI_AGAIN",
81
+ "ECONNREFUSED",
82
+ "ECONNRESET",
83
+ "ENETUNREACH",
84
+ "ENOTFOUND",
85
+ "EPIPE",
86
+ "ETIMEDOUT",
87
+ ];
88
+ export const SEVERITIES = ["info", "warn", "error", "fatal"];
89
+ export const HTTP_METHODS = ["GET", "POST"];
90
+ export const SPAN_KINDS = ["internal", "server", "client"];
91
+ export const SPAN_STATUS_CODES = ["unset", "ok", "error"];
92
+ export const OS_FAMILIES = ["macos", "linux", "windows", "other"];
93
+ export const CPU_ARCHITECTURES = ["arm64", "x64", "other"];
94
+ export const DURATION_BUCKETS = [
95
+ "under_1s",
96
+ "1s_to_5s",
97
+ "5s_to_30s",
98
+ "30s_to_2m",
99
+ "over_2m",
100
+ ];
101
+ // Manual spans only: cc-router is the sole instrumentation scope.
102
+ export const INSTRUMENTATION_SCOPES = ["cc-router"];
103
+ export const LOG_EVENT_CODES = ["account.setup.diagnostic", "runtime.failure"];
104
+ export const ANALYTICS_EVENT_NAMES = [
105
+ "app.first_start",
106
+ "account_setup.started",
107
+ "account_setup.stage_completed",
108
+ "account_setup.succeeded",
109
+ "account_setup.cancelled",
110
+ "account_setup.failed",
111
+ "proxy.started",
112
+ "proxy.heartbeat",
113
+ ];
114
+ export const MAX_VERSION_LENGTH = 64;
115
+ export const MAX_TIMESTAMP_MS = 8_640_000_000_000_000;
116
+ export const MAX_DURATION_MS = 86_400_000;
117
+ export const MAX_ATTEMPT = 100;
118
+ export const MAX_ACCOUNT_POOL_SIZE = 10_000;
119
+ export const MAX_CONCURRENCY = 10_000;
120
+ export const MAX_TOKEN_COUNT = 1_000_000_000;
121
+ export const MAX_STACK_FRAMES = 20;
122
+ export const MAX_STACK_FRAME_PATH_LENGTH = 256;
123
+ export const POSTHOG_HOST = "https://eu.i.posthog.com";
124
+ export const POSTHOG_INGESTION_HOSTNAME = "eu.i.posthog.com";
125
+ export const POSTHOG_PROJECT_TOKEN = "phc_n7wcYbbfMSkNxRoB8JVd57PYQZf7DNaEGL2kUeUkxwV2";
126
+ export const POSTHOG_REQUEST_TIMEOUT_MS = 2_000;
127
+ export const POSTHOG_FLUSH_INTERVAL_MS = 5_000;
128
+ export const POSTHOG_FLUSH_AT = 20;
129
+ export const POSTHOG_MAX_QUEUE_SIZE = 100;