diff --git a/changelog.d/fixes/6741-compression-preview-token-reconcile.md b/changelog.d/fixes/6741-compression-preview-token-reconcile.md new file mode 100644 index 0000000000..122a354265 --- /dev/null +++ b/changelog.d/fixes/6741-compression-preview-token-reconcile.md @@ -0,0 +1 @@ +- **fix(compression):** `/api/compression/preview`'s top-level `originalTokens`/`compressedTokens` diverged from `engineBreakdown[0]`'s counts for the same single-engine run (tiktoken outer counts vs the `JSON.stringify(...).length/4` estimate per engine), worst on small inputs. A new `reconcileSingleEngineTokens()` overwrites the single-engine breakdown entry with the outer, more accurate figures; multi-step pipeline breakdowns are left untouched ([#6488](https://github.com/diegosouzapw/OmniRoute/issues/6488)). Regression guard: `tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts`. diff --git a/open-sse/services/compression/engineBreakdown.ts b/open-sse/services/compression/engineBreakdown.ts index 99d3bfaf16..4f512ab05f 100644 --- a/open-sse/services/compression/engineBreakdown.ts +++ b/open-sse/services/compression/engineBreakdown.ts @@ -27,3 +27,40 @@ export function ensureEngineBreakdown(stats: CompressionStats): EngineBreakdownE }, ]; } + +/** + * #6488 — Reconcile the single-engine breakdown entry's token counts with the response's + * authoritative outer counts. + * + * The outer `originalTokens`/`compressedTokens` fields (computed by the API route with a real + * tiktoken-based counter over the extracted message text) and each `engineBreakdown[]` entry's + * `originalTokens`/`compressedTokens` (computed internally by `estimateCompressionTokens`, a + * crude `JSON.stringify(requestBody).length / 4` estimate over the whole request-body object) + * use two different, unreconciled token-counting methodologies. They diverge most on + * small/degenerate inputs where JSON structural overhead (braces, quotes, `role`/`content` + * keys) dominates the char count. + * + * When the breakdown has exactly one entry, that entry represents the *same* before/after + * transformation as the overall response (single-engine dispatch, or a 1-step pipeline) — so + * its counts are safe to overwrite with the outer, more accurate figures. Multi-step + * breakdowns are left untouched: each intermediate step legitimately operates on the previous + * step's (already-compressed) output, so its "before" state is not the overall original input + * and reconciling it against the overall counts would be incorrect. + */ +export function reconcileSingleEngineTokens( + breakdown: EngineBreakdownEntry[], + outerOriginalTokens: number, + outerCompressedTokens: number, + outerSavingsPercent: number +): EngineBreakdownEntry[] { + if (breakdown.length !== 1) return breakdown; + const [entry] = breakdown; + return [ + { + ...entry, + originalTokens: outerOriginalTokens, + compressedTokens: outerCompressedTokens, + savingsPercent: outerSavingsPercent, + }, + ]; +} diff --git a/src/app/api/compression/preview/route.ts b/src/app/api/compression/preview/route.ts index cc8fc7e360..c656f0ced3 100644 --- a/src/app/api/compression/preview/route.ts +++ b/src/app/api/compression/preview/route.ts @@ -16,7 +16,10 @@ import { } from "@omniroute/open-sse/services/compression/diffHelper"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { countTextTokens } from "@/shared/utils/tiktokenCounter"; -import { ensureEngineBreakdown } from "@omniroute/open-sse/services/compression/engineBreakdown"; +import { + ensureEngineBreakdown, + reconcileSingleEngineTokens, +} from "@omniroute/open-sse/services/compression/engineBreakdown"; import { summarizeEncoderCandidates } from "@omniroute/open-sse/services/compression/engines/headroom/encoderComparison"; import { DEFAULT_MIN_ROWS } from "@omniroute/open-sse/services/compression/engines/headroom/smartcrusher"; @@ -217,7 +220,14 @@ export async function POST(req: Request) { const tokensSaved = Math.max(0, originalTokens - compressedTokens); const savingsPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0; const techniquesUsed: string[] = result.stats?.techniquesUsed ?? []; - const engineBreakdown = result.stats ? ensureEngineBreakdown(result.stats) : []; + const engineBreakdown = result.stats + ? reconcileSingleEngineTokens( + ensureEngineBreakdown(result.stats), + originalTokens, + compressedTokens, + savingsPct + ) + : []; const diff = buildCompressionPreviewDiff( originalText, compressedText, diff --git a/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts new file mode 100644 index 0000000000..0d7d01da9b --- /dev/null +++ b/tests/unit/compression/preview-outer-engine-token-reconcile-6488.test.ts @@ -0,0 +1,79 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "preview-reconcile-6488-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa"; +delete process.env.INITIAL_PASSWORD; +const core = await import("../../../src/lib/db/core.ts"); +const route = await import("../../../src/app/api/compression/preview/route.ts"); + +function makeReq(body: unknown) { + return new Request("http://localhost/api/compression/preview", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +test.beforeEach(() => core.resetDbInstance()); +test.after(() => { + core.resetDbInstance(); + rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// Regression for #6488: outer originalTokens/compressedTokens (real tiktoken counter over +// extracted message text) and engineBreakdown[].originalTokens/compressedTokens (internal +// JSON.stringify(body).length/4 estimate) used to diverge on small/degenerate input because +// they measured different things. For a single-engine breakdown, the entry represents the +// exact same before/after transformation as the overall response, so it must be reconciled +// to match the outer counts exactly. +test("degenerate input with pipeline=['lite']: engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "user: " }], + pipeline: ["lite"], + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + + const engines = (body.engineBreakdown ?? []).map((e: { engine: string }) => e.engine); + assert.ok( + engines.every((e: string) => e === "lite"), + `expected engineBreakdown to only contain 'lite', got ${JSON.stringify(engines)}` + ); + + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal( + step.originalTokens, + body.originalTokens, + `outer originalTokens=${body.originalTokens} vs engine ${step.engine} originalTokens=${step.originalTokens}` + ); + assert.equal( + step.compressedTokens, + body.compressedTokens, + `outer compressedTokens=${body.compressedTokens} vs engine ${step.engine} compressedTokens=${step.compressedTokens}` + ); +}); + +// Same reconciliation must hold for the single-engine (non-pipeline) dispatch path, where +// engineBreakdown is synthesized by ensureEngineBreakdown from the overall stats. +test("single-engine dispatch (engineId='rtk'): engineBreakdown[0] matches outer token counts", async () => { + const res = await route.POST( + makeReq({ + messages: [{ role: "user", content: "a" }], + engineId: "rtk", + }) + ); + const body = await res.json(); + assert.equal(res.status, 200, `expected 200, got ${res.status}: ${JSON.stringify(body)}`); + assert.equal(body.engineBreakdown.length, 1); + const [step] = body.engineBreakdown; + assert.equal(step.originalTokens, body.originalTokens); + assert.equal(step.compressedTokens, body.compressedTokens); +});