mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
Train 1D: merge via --admin on .113 validation
Squash merge from local merge-train (Hard Rule owner-approved). Tip 029cdf4215cf465f0e1716ac9f84a84692b1e881 validated on 192.168.0.113: 26631/26653 pass.
This commit is contained in:
committed by
GitHub
parent
899d19881f
commit
f9899c57ca
@@ -305,7 +305,11 @@ export function compressContext(
|
||||
const targetTokens = Math.max(0, maxTokens - reserveTokens);
|
||||
|
||||
let messages = [...body.messages];
|
||||
let currentTokens = estimateTokens(JSON.stringify(messages));
|
||||
// #8594: pass the structured messages array directly — estimateTokens walks it for
|
||||
// inline base64 image blocks (#8368) and substitutes a bounded per-image estimate.
|
||||
// JSON.stringify()-ing first forces the char/4 text path and mis-measures a ~500KB
|
||||
// image as ~125k tokens, triggering needless compression / context loss.
|
||||
let currentTokens = estimateTokens(messages);
|
||||
const stats = { original: currentTokens, layers: [] as { name: string; tokens: number }[] };
|
||||
|
||||
// Already fits
|
||||
@@ -315,7 +319,7 @@ export function compressContext(
|
||||
|
||||
// Layer 1: Trim tool_result/tool messages
|
||||
messages = trimToolMessages(messages, 2000); // Max 2000 chars per tool result
|
||||
currentTokens = estimateTokens(JSON.stringify(messages));
|
||||
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
|
||||
stats.layers.push({ name: "trim_tools", tokens: currentTokens });
|
||||
|
||||
if (currentTokens <= targetTokens) {
|
||||
@@ -328,7 +332,7 @@ export function compressContext(
|
||||
|
||||
// Layer 2: Compress structured thinking blocks (remove from non-last assistant messages)
|
||||
messages = compressThinking(messages);
|
||||
currentTokens = estimateTokens(JSON.stringify(messages));
|
||||
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
|
||||
stats.layers.push({ name: "compress_thinking", tokens: currentTokens });
|
||||
|
||||
if (currentTokens <= targetTokens) {
|
||||
@@ -341,7 +345,7 @@ export function compressContext(
|
||||
|
||||
// Layer 3: Aggressive purification — drop oldest messages keeping system + last N pairs
|
||||
messages = purifyHistory(messages, targetTokens);
|
||||
currentTokens = estimateTokens(JSON.stringify(messages));
|
||||
currentTokens = estimateTokens(messages); // #8594: object-path keeps the #8368 image estimate
|
||||
stats.layers.push({ name: "purify_history", tokens: currentTokens });
|
||||
|
||||
return {
|
||||
@@ -427,7 +431,9 @@ function purifyHistory(messages: Record<string, unknown>[], targetTokens: number
|
||||
// orphan tool_results that Claude rejects ("tool_result without preceding tool_use").
|
||||
candidate = fixToolPairs(candidate);
|
||||
candidate = stripTrailingAssistantOrphanToolUse(candidate);
|
||||
const tokens = estimateTokens(JSON.stringify(candidate));
|
||||
// #8594: measure the candidate structure directly so image-bearing turns are not
|
||||
// over-counted and pruned during the binary search.
|
||||
const tokens = estimateTokens(candidate);
|
||||
if (tokens <= targetTokens) break;
|
||||
keep = Math.max(2, Math.floor(keep * 0.7)); // Drop 30% each iteration
|
||||
}
|
||||
|
||||
94
tests/unit/8594-compress-image-token-stringify.test.ts
Normal file
94
tests/unit/8594-compress-image-token-stringify.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { compressContext } from "../../open-sse/services/contextManager.ts";
|
||||
|
||||
// #8594 — six production call sites (compressContext layers + purifyHistory binary
|
||||
// search + the combo.ts fallback threshold) pre-JSON.stringify the messages before
|
||||
// handing them to estimateTokens(). Stringifying forces the char/4 text path and
|
||||
// undoes the #8368 inline-base64-image bounded estimate, so an image-bearing request
|
||||
// that fits comfortably is mis-measured as ~100× larger and compressed needlessly.
|
||||
|
||||
function makeFakePngBase64(approxBytes: number): string {
|
||||
return Buffer.alloc(approxBytes, 65).toString("base64");
|
||||
}
|
||||
|
||||
test("#8594: compressContext does NOT compress a within-limit inline-image request", () => {
|
||||
// ~500KB image => ~666KB base64. As raw text that is ~166k tokens (over the limit);
|
||||
// as a bounded image estimate it is ~1.2k tokens (well under). maxTokens=20000 sits
|
||||
// between the two, so the bug (stringify) trips compression and the fix does not.
|
||||
const base64 = makeFakePngBase64(500_000);
|
||||
const body = {
|
||||
model: "gpt-image-vision",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful vision assistant." },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image?" },
|
||||
{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
|
||||
|
||||
assert.equal(
|
||||
result.compressed,
|
||||
false,
|
||||
`BUG #8594 reproduced: within-limit image request was compressed. ` +
|
||||
`stats.original=${(result.stats as { original?: number }).original} tokens ` +
|
||||
`(should be a bounded ~1.2k image estimate, not the ~166k base64-as-text value)`
|
||||
);
|
||||
assert.ok(
|
||||
(result.stats as { original: number }).original < 5000,
|
||||
`expected bounded image-token estimate (<5000), got ${(result.stats as { original: number }).original}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#8594: purifyHistory keeps all image-bearing turns when they fit within the limit", () => {
|
||||
// Multiple image messages that, measured correctly, fit under the limit. The bug
|
||||
// makes the Layer-3 binary search over-estimate and prune turns; the fix preserves them.
|
||||
const base64 = makeFakePngBase64(200_000);
|
||||
const imageTurn = (n: number) => ({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: `Image number ${n}` },
|
||||
{ type: "image_url", image_url: { url: `data:image/png;base64,${base64}` } },
|
||||
],
|
||||
});
|
||||
const body = {
|
||||
model: "gpt-image-vision",
|
||||
messages: [
|
||||
{ role: "system", content: "vision" },
|
||||
imageTurn(1),
|
||||
{ role: "assistant", content: "ok 1" },
|
||||
imageTurn(2),
|
||||
{ role: "assistant", content: "ok 2" },
|
||||
imageTurn(3),
|
||||
{ role: "assistant", content: "ok 3" },
|
||||
],
|
||||
};
|
||||
const originalCount = body.messages.length;
|
||||
|
||||
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
|
||||
|
||||
assert.equal(result.compressed, false, "within-limit image history must not be compressed");
|
||||
assert.equal(
|
||||
(result.body as { messages: unknown[] }).messages.length,
|
||||
originalCount,
|
||||
"all image-bearing turns must be retained when they fit"
|
||||
);
|
||||
});
|
||||
|
||||
test("#8594: control — an oversized text request is still compressed (no regression)", () => {
|
||||
const body = {
|
||||
model: "gpt-text",
|
||||
messages: [
|
||||
{ role: "system", content: "s" },
|
||||
{ role: "user", content: "a".repeat(200_000) }, // ~50k tokens, over a 20k limit
|
||||
],
|
||||
};
|
||||
const result = compressContext(body, { maxTokens: 20000, reserveTokens: 0 });
|
||||
assert.equal(result.compressed, true, "oversized text must still trigger compression");
|
||||
});
|
||||
Reference in New Issue
Block a user