mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
9b986fa220
commit
9827ae6137
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user