diff --git a/CHANGELOG.md b/CHANGELOG.md index 543fe5429b..c262fadcf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/app/api/v1/messages/count_tokens/route.ts b/src/app/api/v1/messages/count_tokens/route.ts index df37669ba5..6a92bebed5 100644 --- a/src/app/api/v1/messages/count_tokens/route.ts +++ b/src/app/api/v1/messages/count_tokens/route.ts @@ -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( diff --git a/tests/unit/messages-count-tokens-route.test.ts b/tests/unit/messages-count-tokens-route.test.ts index ffcdcdd461..a066e9cb63 100644 --- a/tests/unit/messages-count-tokens-route.test.ts +++ b/tests/unit/messages-count-tokens-route.test.ts @@ -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 {