Files
OmniRoute/tests/unit/combo-fallback-token-estimate-7847.test.ts
MumuTW 1cfbfc044a fix(sse): estimate tokens from the object and count JSON length without building it (#7847) (#8558)
Two changes with one root cause: several hot paths built a full JSON string only to read
its .length, and one of them silently changed the answer.

1. CORRECTNESS -- combo's fallback-compression trigger

   estimateTokens(JSON.stringify(attemptBody)) took the STRING branch of estimateTokens,
   which is ceil(length / CHARS_PER_TOKEN) over the raw JSON. An inline base64 image is
   then charged as if every character of the data URL were prose. Measured on a 200 KB
   inline image:

     via string (before)  50,039 tokens
     via object (after)    1,231 tokens

   a 40x over-count, tripping fallback compression on requests nowhere near the context
   window. This is the same class #8368/#8401 fixed on the request path; the combo call
   site was missed. Passing the object routes through extractImageTokens, which charges
   images structurally. Text-only bodies are unaffected -- verified identical, and pinned
   by a test.

2. ALLOCATION -- jsonLength()

   Adds an exact serialized-length walker: same O(n) scan, no string. Used by
   estimateTokens' object branch and by streamReadinessPolicy (which runs on every
   streaming request and only ever used .length).

   Exactness matters because every consumer feeds a threshold, so this is property-tested
   against JSON.stringify over 4000 generated structures covering escaping, lone
   surrogates, omitted values, non-finite numbers, toJSON, Date, Map, cycles and BigInt.
   Anything outside the plain-JSON subset falls back to JSON.stringify for THAT SUBTREE
   only, so an exotic leaf never forces the message history back onto the allocating path.

Honest scoping of the memory win: the string was always transient, and V8 collects it
efficiently, so this is not 3 MiB of retained heap. Measured allocation churn over 20 calls
on a 3.06 MiB body: 3.1 MiB -> 0.5 MiB, about 6x less. The #8549 benchmark row for this
mechanism measures a HELD string and therefore overstates it; the correctness fix above is
the larger deliverable here.
2026-07-26 03:52:38 -03:00

93 lines
3.5 KiB
TypeScript

// combo's fallback-compression trigger must estimate tokens from the request OBJECT (#7847).
//
// It used to call `estimateTokens(JSON.stringify(attemptBody))`, which takes the string branch of
// estimateTokens — `ceil(length / CHARS_PER_TOKEN)` over the raw JSON. An inline base64 image is
// then charged as if every character of the data URL were prose, the same over-count #8368/#8401
// fixed on the request path. On a 200 KB inline image that read ~50k tokens instead of ~1.2k,
// tripping fallback compression on a request nowhere near the context window.
//
// Passing the object instead routes through extractImageTokens, which charges images structurally.
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-estimate-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
const { estimateTokens } = await import("../../open-sse/services/contextManager.ts");
const core = await import("../../src/lib/db/core.ts");
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = ORIGINAL_DATA_DIR;
});
const imageBody = (base64Chars: number) => ({
model: "claude-opus-5",
messages: [
{
role: "user",
content: [
{ type: "text", text: "describe this screenshot" },
{
type: "image_url",
image_url: { url: `data:image/png;base64,${"A".repeat(base64Chars)}` },
},
],
},
],
});
const textBody = () => ({
model: "claude-opus-5",
max_tokens: 100,
messages: [{ role: "user", content: "hello world ".repeat(500) }],
tools: [{ type: "function", function: { name: "t", description: "does a thing" } }],
});
test("an inline image is charged structurally, not as raw data-URL text", () => {
const small = estimateTokens(imageBody(10_000));
const large = estimateTokens(imageBody(200_000));
// A 20x larger base64 payload must not cost 20x the tokens — the image is charged as an image.
assert.ok(
large < small * 2,
`estimate scaled with the base64 length (${small} -> ${large}); the data URL is being counted as text`
);
assert.ok(large < 10_000, `expected a bounded image charge, got ${large} tokens`);
});
test("the string path is what over-counts — this is why the call site must pass the object", () => {
const body = imageBody(200_000);
const viaObject = estimateTokens(body);
const viaString = estimateTokens(JSON.stringify(body));
assert.ok(
viaString > viaObject * 10,
`expected the string path to over-count heavily (object=${viaObject}, string=${viaString}) — ` +
"if this ever stops being true, the regression guard below is measuring nothing"
);
});
test("text-only bodies are unaffected: object and string paths agree", () => {
const body = textBody();
assert.equal(
estimateTokens(body),
estimateTokens(JSON.stringify(body)),
"the switch to the object path must be a no-op for the common text-only request"
);
});
test("estimateTokens still handles the plain shapes", () => {
assert.equal(estimateTokens(null), 0);
assert.equal(estimateTokens(undefined), 0);
assert.equal(estimateTokens(""), 0);
assert.equal(estimateTokens("abcd"), 1);
assert.ok(estimateTokens({ a: "x".repeat(400) }) > 0);
});