@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.
- package/CHANGELOG.md +18 -0
- package/Dockerfile +1 -0
- package/README.md +1 -1
- package/dist/cli/cmd-accounts.js +116 -65
- package/dist/cli/cmd-setup.js +100 -30
- package/dist/cli/cmd-status.js +14 -2
- package/dist/cli/cmd-telemetry.js +43 -32
- package/dist/cli/index.js +15 -1
- package/dist/config/directory.js +14 -0
- package/dist/config/telemetry.js +192 -41
- package/dist/providers/anthropic/usage-refresher.js +35 -1
- package/dist/providers/model-discovery.js +17 -11
- package/dist/providers/openai/device-oauth.js +88 -31
- package/dist/providers/openai/token-refresher.js +12 -2
- package/dist/providers/openai/usage-fetch.js +19 -2
- package/dist/proxy/anthropic-messages-route.js +144 -5
- package/dist/proxy/anthropic-proxy.js +10 -0
- package/dist/proxy/anthropic-response-capture.js +6 -19
- package/dist/proxy/openai-ingress.js +98 -1
- package/dist/proxy/server.js +35 -22
- package/dist/proxy/token-refresher.js +12 -2
- package/dist/proxy/usage-capture.js +41 -4
- package/dist/telemetry/contracts.js +129 -0
- package/dist/telemetry/facade.js +654 -0
- package/dist/telemetry/otel-exporters.js +289 -0
- package/dist/telemetry/posthog-client.js +398 -0
- package/dist/telemetry/privacy.js +567 -0
- package/dist/telemetry/runtime.js +306 -0
- package/dist/telemetry/setup-diagnostics.js +239 -0
- package/dist/utils/token-extractor.js +79 -11
- package/dist/utils/token-validator.js +25 -9
- package/docs/README.md +34 -0
- package/docs/telemetry.md +167 -0
- package/package.json +12 -2
- 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 =
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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;
|