@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 +10 -0
- package/dist/proxy/server.js +17 -47
- package/dist/proxy/usage-capture.js +119 -0
- package/package.json +1 -1
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
|
package/dist/proxy/server.js
CHANGED
|
@@ -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
|
-
//
|
|
1049
|
-
//
|
|
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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
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