Skip to content

fix(sse): estimate tokens from the object and count JSON length without building it (#7847) - #8558

Merged
diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.49from
MumuTW:fix/7847-structural-json-size
Jul 26, 2026
Merged

diegosouzapw merged 1 commit into
cheaper-inference:release/v3.8.49from
MumuTW:fix/7847-structural-json-size

Conversation

@MumuTW

@MumuTW MumuTW commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

⚠️ Stacked on #8555. This branch is cut from refactor/combo-error-classifiers, so the diff currently shows two commits — #8555s extraction plus this fix. GitHub will not let a fork PR target another fork branch, so the base is release/v3.8.49. Merge #8555 first and this collapses to a single commit. It genuinely depends on that PR — see "Why stacked".

Two changes, one root cause: several paths built a full JSON string only to read its .length, and one of them silently changed the answer.

1. Correctness — the combo fallback trigger over-counted inline images by 40x

estimateTokens(JSON.stringify(attemptBody)) takes 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:

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

That falsely tripped fallback compression on requests nowhere near the context window. It is the same class #8368/#8401 fixed on the request path — this call site was missed, because it stringifies before handing the value over and so never reaches extractImageTokens.

Passing the object routes through the image-aware path. Text-only bodies are unaffected — verified identical and pinned by a test, so this is a no-op for the common request.

2. Allocation — jsonLength()

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

Exactness is not optional — every consumer feeds a threshold, and an approximation would silently shift routing and timeout decisions. So it is property-tested against JSON.stringify over 4000 generated structures covering escaping, lone surrogates, omitted values (undefined/functions/symbols, which behave differently in objects vs arrays), 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 multi-megabyte 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:

BEFORE JSON.stringify().length   3.1 MiB   (0.15 MiB/call)
AFTER  jsonLength()              0.5 MiB   (0.026 MiB/call)

About 6x less churn. The correctness fix above is the larger deliverable here — and the #8549 benchmark row for this mechanism measures a held string, so it overstates the cost. Worth correcting when that PR is reviewed.

Why stacked

combo.ts is frozen at 3642 lines and the gate is shrink-only. This PR adds one line there — and lint-stageds Prettier then reflows an unrelated over-long line (+4) during pre-commit, pushing it to 3645. #8555 takes the file to 3610, which is exactly the headroom this needs. Rebasing was the honest fix; trimming the comment until it fit would not have survived the formatter anyway.

Verification

check result
json-size-exactness (property + edge cases) 9/9 pass
combo-fallback-token-estimate-7847 (image over-count guard) 4/4 pass
regression sweep, 466 files (contextManager / estimateTokens / streamReadiness / combo) pass, except the two base-reds below
typecheck:core, check:cycles, check-file-size, check:any-budget:t11 clean

Confirmed red on upstream/release/v3.8.49 with these files untouched, so not from this PR: live repo: no NEW unexported db modules beyond the frozen allowlist and getTokenLimit: uses GPT-5.5 Codex model context.

Not converted

open-sse/utils/stream.ts:694 does Buffer.byteLength(JSON.stringify(body), "utf8") for a payload-size metric — that needs UTF-8 bytes, not characters, so it wants a jsonByteLength variant rather than this one. Left for a follow-up rather than silently changing what the metric means.

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Verified locally by checking out your branch in an isolated worktree on top of release/v3.8.49 (tip 3070925).

Stacking confirmed directly from the commits, not just the PR description: 7172e2513f is exactly #8555's sole commit, and 362c4bd400 is this PR's actual delta (the estimateTokens object-vs-string fix, jsonLength(), and the streamReadinessPolicy conversion). I evaluated only the latter.

  • json-size-exactness.test.ts: 9/9 pass, including the 4000-structure property test against JSON.stringify.
  • combo-fallback-token-estimate-7847.test.ts: 4/4 pass — reproduced the string-path over-count vs the object-path charge on the 200 KB inline-image case, and confirmed text-only bodies are unaffected.
  • Related regression sweep (stream-readiness-policy, stream-readiness, combo-stream-readiness-fallback, chatcore-compression-combo-predicates, combo-param-validation-fallback-4519 — covering the re-exported classifier surface): 53/53 pass.
  • typecheck:core, check:cycles, check:any-budget:t11 (combo.ts: 0 any / budget 0), and eslint on all 7 touched files: all clean.
  • check:file-size: reproduced the same 2 violations (providers/page.tsx, tokenHealthCheck.ts) on a bare release/v3.8.49 checkout with nothing of yours applied — confirmed pre-existing, not from this diff. combo.ts itself sits at 3611 lines (baseline 3642) with both stacked commits applied, no gate pressure.
  • check-db-rules "no NEW unexported db modules" (compressionDetailNormalizers) — also reproduces identically on bare release/v3.8.49, confirmed base-red, agreed with your table.
  • One correction: getTokenLimit: uses GPT-5.5 Codex model context passed for me on both your branch and the bare tip — it isn't currently red on either side, so that one line in the verification table doesn't need the base-red carve-out (worth double-checking on your end in case it's flaky rather than fixed).
  • stream.ts:694 — agreed, that needs actual UTF-8 byte length via Buffer.byteLength, correctly left as a follow-up rather than folded in here.

Given the stacking, the plan is straightforward: land #8555 first, then rebase this branch — 7172e2513f drops out as a duplicate of the now-merged commit and only 362c4bd400 remains to apply. No code changes needed on your side, just that sequencing.

…ut building it (cheaper-inference#7847)

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 cheaper-inference#8368/cheaper-inference#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 cheaper-inference#8549 benchmark row for this
mechanism measures a HELD string and therefore overstates it; the correctness fix above is
the larger deliverable here.
@MumuTW
MumuTW force-pushed the fix/7847-structural-json-size branch from 362c4bd to 78b559b Compare July 25, 2026 14:37
@MumuTW

MumuTW commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Rebased as planned, but without needing #8555 to merge first. #8548 is the extraction design we're going with (agreed with your read — no deliberate reason for the separate module, and comboPredicates.ts already has mutation coverage wired), so I rebased with --onto 4053e2314 7172e2513, which drops the errorClassification commit entirely. This PR is now a single independent commit: the estimateTokens object fix, jsonLength(), and the streamReadinessPolicy conversion.

Verified on the rebased head:

On your correction: agreed, getTokenLimit: uses GPT-5.5 Codex model context isn't red on either side — that row shouldn't have carried the base-red carve-out. Most likely flaky when I measured it rather than genuinely failing, so I've dropped the claim rather than defend it.

stream.ts:694 / Buffer.byteLength stays a follow-up as agreed.

@MumuTW

MumuTW commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Correction to my comment above: Fast Quality Gates will still be red after the rebase, and it isn't this PR. #8561 fixed check:file-size and thereby unmasked a fifth base-red gate behind it in the same job — check:complexity-ratchets (complexity 2169 > baseline 2130, cognitiveComplexity 956 > 951).

I measured it on pristine detached checkouts with an empty working tree: 4053e2314 (current tip) and 30709255c (the base you reviewed against) both report 2169 / 956 — identical to this PR's head, so it predates #8561 and is not caused by anything here. It was hidden because check:file-size ran earlier in the same bash -e job and short-circuited it. Full measurement table in my comment on #8546.

The gate is in quality.yml's fast-gates job, so it's red for every PR against release/v3.8.49 regardless of content. Everything else on this PR is green and verified locally.

@diegosouzapw
diegosouzapw merged commit 1cfbfc0 into cheaper-inference:release/v3.8.49 Jul 26, 2026
5 checks passed
@diegosouzapw

Copy link
Copy Markdown
Collaborator

Thanks @MumuTW — merged into release/v3.8.49 via the local merge-train (validated as one combined tree: full test:unit + test:vitest 274/274 on the 32-core box, tip d4b9ce6016). Your commit keeps its authorship. 🚀

HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…ut building it (cheaper-inference#7847) (cheaper-inference#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 cheaper-inference#8368/cheaper-inference#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 cheaper-inference#8549 benchmark row for this
mechanism measures a HELD string and therefore overstates it; the correctness fix above is
the larger deliverable here.
@MumuTW
MumuTW deleted the fix/7847-structural-json-size branch September 5, 2026 10:19
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…ut building it (cheaper-inference#7847) (cheaper-inference#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 cheaper-inference#8368/cheaper-inference#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 cheaper-inference#8549 benchmark row for this
mechanism measures a HELD string and therefore overstates it; the correctness fix above is
the larger deliverable here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants