fix(api): count tool_use/tool_result/thinking blocks in count_tokens estimate (port from 9router#2337) (#6221)

fix(api): count tool_use/tool_result/thinking blocks in count_tokens estimate (#6221, port from 9router#2337). TDD-covered (6/6); typed the test casts to clear no-new-eslint. Reds are pre-existing base-red drift on release/v3.8.45 (dast-smoke #6228; executor-kiro.test.ts eslint anys; changelog/package.json version drift) — none introduced by this PR. Thanks @luweiCN. Integrated into release/v3.8.45.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-05 04:20:54 -03:00
committed by GitHub
parent 9b986fa220
commit 9827ae6137
3 changed files with 143 additions and 7 deletions

View File

@@ -8,6 +8,8 @@
- **fix(mitm):** the macOS MITM-cert install check now matches the system keychain again. `security find-certificate -a -Z` prints the SHA-1 as a colon-less hex string, but the installed-check compared it against `getCertFingerprint()`'s colon-separated form, so the substring match never hit — the cert was reported as not-installed and re-prompted for the sudo install on every run. Fingerprints are now normalized (colons stripped, upper-cased) on both sides via the extracted `macCertOutputHasFingerprint` helper. Regression guard: `tests/unit/mitm-cert-mac-fingerprint.test.ts`. ([#6204](https://github.com/diegosouzapw/OmniRoute/pull/6204), closes [#6134](https://github.com/diegosouzapw/OmniRoute/issues/6134) — thanks @rianonehub)
- **fix(api):** `/v1/messages/count_tokens` now counts `tool_use`, `tool_result` and `thinking` content blocks (and array-form `system` prompts) in the local-estimation path, instead of only `text`. Real agentic conversations keep ~95% of their tokens inside tool results; the previous estimate returned near-zero for them, which silently broke Claude Code's auto-compaction (context grew past the window with no compaction until the upstream API rejected the request). The real provider-side count path is unchanged. Regression guard: `tests/unit/messages-count-tokens-route.test.ts`. ([#6221](https://github.com/diegosouzapw/OmniRoute/pull/6221) — thanks @luweiCN)
---
## [3.8.43] — 2026-07-02

View File

@@ -99,6 +99,59 @@ export async function POST(request) {
}
}
function safeStringify(value) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
// Estimate tokens for a single Anthropic content block. Real agentic
// conversations carry most of their tokens in `tool_use` inputs, `tool_result`
// content, and `thinking` blocks — counting only `text` (as before) reported
// near-zero for those messages and silently broke Claude Code's auto-compaction
// (#2337). Image / redacted_thinking blocks are not text-estimable and count 0.
function estimateContentBlockTokens(part) {
if (!part || typeof part !== "object") return 0;
let tokens = 0;
switch (part.type) {
case "text":
if (typeof part.text === "string") tokens += countTextTokens(part.text);
break;
case "tool_use":
if (typeof part.name === "string") tokens += countTextTokens(part.name);
if (part.input !== undefined) tokens += countTextTokens(safeStringify(part.input));
break;
case "tool_result":
tokens += estimateToolResultTokens(part.content);
break;
case "thinking":
if (typeof part.thinking === "string") tokens += countTextTokens(part.thinking);
break;
default:
break;
}
return tokens;
}
// A `tool_result` content can be a plain string or an array of nested blocks
// (text / image). Count string content and nested text blocks.
function estimateToolResultTokens(content) {
if (typeof content === "string") return countTextTokens(content);
if (Array.isArray(content)) {
let tokens = 0;
for (const block of content) {
if (block?.type === "text" && typeof block.text === "string") {
tokens += countTextTokens(block.text);
}
}
return tokens;
}
return 0;
}
function buildEstimatedCountResponse(body) {
const messages = Array.isArray(body?.messages) ? body.messages : [];
let inputTokens = 0;
@@ -111,15 +164,19 @@ function buildEstimatedCountResponse(body) {
if (Array.isArray(msg?.content)) {
for (const part of msg.content) {
if (part?.type === "text" && typeof part.text === "string") {
inputTokens += countTextTokens(part.text);
}
inputTokens += estimateContentBlockTokens(part);
}
}
}
if (typeof body?.system === "string") {
inputTokens += countTextTokens(body.system);
} else if (Array.isArray(body?.system)) {
for (const block of body.system) {
if (block?.type === "text" && typeof block.text === "string") {
inputTokens += countTextTokens(block.text);
}
}
}
return new Response(

View File

@@ -11,6 +11,13 @@ const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const { POST } = await import("../../src/app/api/v1/messages/count_tokens/route.ts");
type CountTokensResponse = {
input_tokens: number;
source: string;
provider?: string;
model?: string;
};
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -68,7 +75,7 @@ test("messages/count_tokens uses real provider count when Claude-compatible upst
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = (await response.json()) as CountTokensResponse;
assert.equal(body.input_tokens, 321);
assert.equal(body.source, "provider");
assert.equal(body.provider, "anthropic");
@@ -96,7 +103,7 @@ test("messages/count_tokens falls back to estimate when model is missing", async
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = (await response.json()) as CountTokensResponse;
assert.equal(body.input_tokens, 4); // tiktoken: "abcd"=1 + "12345678"=3
assert.equal(body.source, "local");
});
@@ -108,11 +115,81 @@ test("count_tokens fallback uses exact tiktoken count with source=local", async
body: JSON.stringify({ messages: [{ role: "user", content: "hello world" }] }),
});
const res = await POST(req);
const json = (await res.json()) as any;
const json = (await res.json()) as CountTokensResponse;
assert.equal(json.source, "local");
assert.equal(json.input_tokens, 2); // exact cl100k_base count, not Math.ceil(11/4)=3
});
test("count_tokens estimate counts tool_use / tool_result / thinking blocks (not just text) — #2337", async () => {
// Real agentic conversations carry ~95% of their tokens inside tool_use inputs
// and tool_result content. The estimation path used to only sum `text` blocks,
// returning input_tokens: 0 for the shape below, which silently broke Claude
// Code's auto-compaction. Every non-text block below must contribute tokens.
const response = await POST(
new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: [
{
role: "assistant",
content: [
{
type: "thinking",
thinking: "The user wants me to read a file, let me call the Read tool.",
},
{
type: "tool_use",
id: "toolu_01",
name: "Read",
input: { file_path: "/tmp/a.txt", limit: 200 },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01",
content: "line1 line2 line3 some file content here",
},
],
},
],
}),
})
);
assert.equal(response.status, 200);
const body = (await response.json()) as CountTokensResponse;
assert.equal(body.source, "local");
// Before the fix this was 0 (only `text` blocks were counted).
assert.ok(
body.input_tokens > 0,
`expected tool/thinking blocks to contribute tokens, got ${body.input_tokens}`
);
});
test("count_tokens estimate counts array-form system prompt blocks — #2337", async () => {
const response = await POST(
new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
system: [{ type: "text", text: "You are a helpful coding assistant." }],
messages: [{ role: "user", content: "hi" }],
}),
})
);
assert.equal(response.status, 200);
const body = (await response.json()) as CountTokensResponse;
assert.equal(body.source, "local");
// Array-form `system` used to count as 0 (only string system was summed).
assert.ok(body.input_tokens > 1, `expected system blocks counted, got ${body.input_tokens}`);
});
test("messages/count_tokens falls back to estimate when real upstream count fails", async () => {
await seedConnection("anthropic", { apiKey: "sk-ant-fallback" });
@@ -132,7 +209,7 @@ test("messages/count_tokens falls back to estimate when real upstream count fail
);
assert.equal(response.status, 200);
const body = (await response.json()) as any;
const body = (await response.json()) as CountTokensResponse;
assert.equal(body.input_tokens, 1); // tiktoken: "abcd"=1
assert.equal(body.source, "local");
} finally {