@timo972/cc-router 0.10.0-rc.4 → 0.10.0-rc.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/CHANGELOG.md CHANGED
@@ -55,6 +55,16 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
55
55
 
56
56
  ### Fixed
57
57
 
58
+ - Anthropic activity rows show their cache rate and token counts. The proxy
59
+ captures usage by passively parsing the response body, but skipped any
60
+ compressed response — and since the proxy is byte-transparent, the client's
61
+ own `accept-encoding` makes upstream compress essentially every response, so
62
+ no `/messages` row ever carried token fields while Codex rows (whose relay
63
+ decompresses anyway) did. The capture now decompresses its own copy of the
64
+ stream (gzip/brotli/deflate) purely for parsing, stops paying for the
65
+ stream once both usage events have been seen, and remains strictly
66
+ best-effort: a corrupt or unsupported coding ends the capture, never the
67
+ response.
58
68
  - `cc-router stop` no longer terminates itself — or your editor sessions —
59
69
  when it falls back to killing by port. The fallback listed every process
60
70
  with a socket on the proxy port (`lsof -ti :port` reports both ends of
@@ -26,6 +26,7 @@ import { SessionRouter } from "./session-router.js";
26
26
  import { createAnthropicProxy } from "./anthropic-proxy.js";
27
27
  import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
28
28
  import { OpenAIUsageRefresher } from "../providers/openai/usage-fetch.js";
29
+ import { createAnthropicUsageCapture } from "./usage-capture.js";
29
30
  import { canUseExtraUsage } from "../providers/anthropic/usage.js";
30
31
  import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
31
32
  import { persistProviderEnabledState } from "./provider-routing.js";
@@ -1045,8 +1046,11 @@ export async function startServer(opts = {}) {
1045
1046
  // message_start → input_tokens, cache_read/creation_input_tokens
1046
1047
  // message_delta → output_tokens
1047
1048
  // Non-streaming JSON carries all fields in a single usage object.
1048
- // We use incremental line parsing (not buffering) so we can capture
1049
- // both events without holding the full stream in memory.
1049
+ // The proxy is byte-transparent and the client's accept-encoding makes
1050
+ // upstream compress, so the capture decompresses its own copy of the
1051
+ // stream (see usage-capture.ts) — previously compressed responses were
1052
+ // skipped, which in practice was EVERY response: no cache rate or
1053
+ // token counts ever appeared on Anthropic activity rows.
1050
1054
  const contentType = String(proxyRes.headers["content-type"] ?? "");
1051
1055
  const encoding = String(proxyRes.headers["content-encoding"] ?? "");
1052
1056
  const isCompressed = /gzip|br|deflate/.test(encoding);
@@ -1054,51 +1058,17 @@ export async function startServer(opts = {}) {
1054
1058
  entry.streamLifecycle = streamTracker.state;
1055
1059
  streamTracker.attach(proxyRes, response);
1056
1060
  proxyRes.on("data", (chunk) => streamTracker.observeChunk(chunk));
1057
- if (!isCompressed && (contentType.includes("text/event-stream") || contentType.includes("application/json"))) {
1058
- const isSSE = contentType.includes("text/event-stream");
1059
- if (isSSE) {
1060
- let lineBuf = "";
1061
- let gotInput = false;
1062
- let gotOutput = false;
1063
- proxyRes.on("data", (chunk) => {
1064
- if (gotInput && gotOutput)
1065
- return;
1066
- lineBuf += chunk.toString("utf8");
1067
- const lines = lineBuf.split("\n");
1068
- lineBuf = lines.pop() ?? ""; // keep incomplete last line
1069
- for (const line of lines) {
1070
- if (!line.startsWith("data: "))
1071
- continue;
1072
- try {
1073
- const evt = JSON.parse(line.slice(6));
1074
- if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
1075
- applyInputUsage(entry, evt.message.usage);
1076
- gotInput = true;
1077
- }
1078
- if (!gotOutput && evt.type === "message_delta" && evt.usage) {
1079
- applyOutputUsage(entry, evt.usage);
1080
- gotOutput = true;
1081
- }
1082
- }
1083
- catch { /* partial JSON across chunk boundary — next chunk will complete it */ }
1084
- }
1085
- });
1086
- }
1087
- else {
1088
- // Non-streaming JSON: buffer full body then parse once
1089
- let buf = "";
1090
- proxyRes.on("data", (chunk) => { buf += chunk.toString("utf8"); });
1091
- proxyRes.on("end", () => {
1092
- try {
1093
- const body = JSON.parse(buf);
1094
- if (body.usage) {
1095
- applyInputUsage(entry, body.usage);
1096
- applyOutputUsage(entry, body.usage);
1097
- }
1098
- }
1099
- catch { /* ignore */ }
1100
- });
1101
- }
1061
+ const usageCapture = createAnthropicUsageCapture({
1062
+ contentType,
1063
+ contentEncoding: encoding,
1064
+ // Mutates the already-logged entry in place; the dashboard picks the
1065
+ // values up on its next poll.
1066
+ onInputUsage: (usage) => applyInputUsage(entry, usage),
1067
+ onOutputUsage: (usage) => applyOutputUsage(entry, usage),
1068
+ });
1069
+ if (usageCapture) {
1070
+ proxyRes.on("data", (chunk) => usageCapture.write(chunk));
1071
+ proxyRes.on("end", () => usageCapture.end());
1102
1072
  }
1103
1073
  },
1104
1074
  error: (err, _req, res) => {
@@ -0,0 +1,119 @@
1
+ import { createBrotliDecompress, createGunzip, createInflate } from "node:zlib";
2
+ /** Non-streaming bodies are buffered for one parse at end-of-stream; a body
3
+ * past this size stops being buffered (usage is best-effort diagnostics —
4
+ * unbounded buffering of a pathological body is not worth it). */
5
+ const MAX_JSON_BODY_BYTES = 20 * 1024 * 1024;
6
+ function createDecoder(contentEncoding) {
7
+ const encoding = contentEncoding.trim().toLowerCase();
8
+ // `identity` and absent mean the bytes are already readable.
9
+ if (encoding === "" || encoding === "identity")
10
+ return null;
11
+ if (encoding === "gzip" || encoding === "x-gzip")
12
+ return createGunzip();
13
+ if (encoding === "br")
14
+ return createBrotliDecompress();
15
+ if (encoding === "deflate")
16
+ return createInflate();
17
+ // Multi-codings ("gzip, br") and unknown codings are not worth chasing.
18
+ return undefined;
19
+ }
20
+ export function createAnthropicUsageCapture(options) {
21
+ const isSSE = options.contentType.includes("text/event-stream");
22
+ const isJSON = options.contentType.includes("application/json");
23
+ if (!isSSE && !isJSON)
24
+ return null;
25
+ const decoder = createDecoder(options.contentEncoding);
26
+ if (decoder === undefined)
27
+ return null;
28
+ let dead = false;
29
+ const die = () => {
30
+ if (dead)
31
+ return;
32
+ dead = true;
33
+ decoder?.destroy();
34
+ };
35
+ // ── SSE: incremental line parsing, stop once both events were seen ────────
36
+ let lineBuf = "";
37
+ let gotInput = false;
38
+ let gotOutput = false;
39
+ const parseSSEChunk = (text) => {
40
+ lineBuf += text;
41
+ const lines = lineBuf.split("\n");
42
+ lineBuf = lines.pop() ?? ""; // keep incomplete last line
43
+ for (const line of lines) {
44
+ if (!line.startsWith("data: "))
45
+ continue;
46
+ try {
47
+ const evt = JSON.parse(line.slice(6));
48
+ if (!gotInput && evt.type === "message_start" && evt.message?.usage) {
49
+ options.onInputUsage(evt.message.usage);
50
+ gotInput = true;
51
+ }
52
+ if (!gotOutput && evt.type === "message_delta" && evt.usage) {
53
+ options.onOutputUsage(evt.usage);
54
+ gotOutput = true;
55
+ }
56
+ // 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)
59
+ die();
60
+ }
61
+ catch { /* partial JSON across chunk boundary — next chunk completes it */ }
62
+ }
63
+ };
64
+ // ── Non-streaming JSON: buffer, parse once at end ─────────────────────────
65
+ let jsonBuf = "";
66
+ const parseJSONBody = () => {
67
+ try {
68
+ const body = JSON.parse(jsonBuf);
69
+ if (body.usage) {
70
+ options.onInputUsage(body.usage);
71
+ options.onOutputUsage(body.usage);
72
+ }
73
+ }
74
+ catch { /* not a JSON body after all */ }
75
+ };
76
+ const consume = (chunk) => {
77
+ if (dead)
78
+ return;
79
+ if (isSSE) {
80
+ parseSSEChunk(chunk.toString("utf8"));
81
+ return;
82
+ }
83
+ if (jsonBuf.length + chunk.length > MAX_JSON_BODY_BYTES) {
84
+ die();
85
+ return;
86
+ }
87
+ jsonBuf += chunk.toString("utf8");
88
+ };
89
+ const finish = () => {
90
+ if (dead)
91
+ return;
92
+ if (isJSON)
93
+ parseJSONBody();
94
+ dead = true;
95
+ };
96
+ if (!decoder) {
97
+ return {
98
+ write: (chunk) => consume(chunk),
99
+ end: () => finish(),
100
+ };
101
+ }
102
+ decoder.on("data", (chunk) => consume(chunk));
103
+ decoder.on("end", () => finish());
104
+ // Corrupt or truncated compressed data — the capture just stops; the
105
+ // proxied bytes were never ours to begin with.
106
+ decoder.on("error", () => die());
107
+ return {
108
+ write: (chunk) => {
109
+ if (dead)
110
+ return;
111
+ decoder.write(chunk);
112
+ },
113
+ end: () => {
114
+ if (dead)
115
+ return;
116
+ decoder.end();
117
+ },
118
+ };
119
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.10.0-rc.4",
3
+ "version": "0.10.0-rc.5",
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": {