Files
OmniRoute/tests/unit/chatcore-combo-context-limit-8378.test.ts
RCrushMe e73c5a6c23 [BUG] enforceOutputTokenBudget ignores combo-resolved context limit, silently truncates responses (#8378)
* fix(chatcore): use combo-resolved context limit in enforceOutputTokenBudget

Line 1801 called getTokenLimit() directly, ignoring the contextLimit
variable that was already resolved with combo overrides (e.g. user-set
201320 for nvidia/z-ai/glm-5.2). This caused enforceOutputTokenBudget
to use the fallback 128K default, capping max_tokens to near-zero and
silently truncating responses.

Fix: use the existing contextLimit variable instead of re-resolving.

* fix(chatcore): hoist combo-resolved contextLimit so the output-token budget honors it

contextLimit (including the combo override from resolveComboContextLimit())
was declared inside the proactive-compression `if` block and never survived
to the final enforceOutputTokenBudget() call further down in
handleChatCore(), which referenced an out-of-scope `contextLimit` — a
ReferenceError on every request. Hoist the declaration to function scope so
the combo-resolved context limit is what the output-token budget actually
enforces.

Adds a regression test that drives handleChatCore() end-to-end with a combo
whose resolved context limit differs from the plain per-target
getTokenLimit() lookup, since output-token-budget.test.ts only exercises
enforceOutputTokenBudget() directly and cannot catch this class of bug.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: TonPro <hello@tonpro.fu>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-26 12:11:27 -03:00

146 lines
5.3 KiB
TypeScript

// Regression for #8378: handleChatCore resolves a combo-specific context limit
// (resolveComboContextLimit(), which can differ from the plain
// getTokenLimit(provider, model) lookup when the target's own limit isn't
// "specific" and a sibling combo target has a smaller known window) but that
// value was declared *inside* the proactive-compression `if` block and never
// survived to the final enforceOutputTokenBudget() call further down in
// handleChatCore — so the combo override was silently discarded and the
// naive per-target lookup won instead. output-token-budget.test.ts only
// exercises enforceOutputTokenBudget() directly and can never catch this
// class of bug because it never calls handleChatCore().
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-combo-ctxlimit-8378-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const combosDb = await import("../../src/lib/db/combos.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
// Sibling target's provider name is unusual enough it can never collide with a
// real provider registered in open-sse/config/providerRegistry.ts, and the env
// override key is derived from it: CONTEXT_LENGTH_<PROVIDER-UPPERCASED>.
const MAIN_PROVIDER = "combo8378-mainprov";
const MAIN_MODEL = "combo8378-mainmodel";
const SIBLING_PROVIDER = "combo8378-sibprov";
const SIBLING_MODEL = "combo8378-sibmodel";
const SIBLING_LIMIT_ENV = "CONTEXT_LENGTH_COMBO8378_SIBPROV";
const SIBLING_LIMIT = 5000;
const COMBO_NAME = "combo8378-test-combo";
const originalFetch = globalThis.fetch;
const originalSiblingEnv = process.env[SIBLING_LIMIT_ENV];
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.before(async () => {
await resetStorage();
// Forces getTokenLimit(SIBLING_PROVIDER, ...) to a small, distinctive value
// via the env-override branch (highest priority in resolveTokenLimit()), so
// resolveComboContextLimit()'s combo-min fallback has a real, known-smaller
// value to fall back to for the (unregistered) MAIN_PROVIDER target.
process.env[SIBLING_LIMIT_ENV] = String(SIBLING_LIMIT);
await combosDb.createCombo({
name: COMBO_NAME,
models: [
`${MAIN_PROVIDER}/${MAIN_MODEL}`,
`${SIBLING_PROVIDER}/${SIBLING_MODEL}`,
],
});
// Defensive: nothing in the expected (fixed) code path should ever reach
// the network for this test — the request is rejected locally for
// exceeding the combo-resolved context window — but stub fetch anyway so a
// regression that silently lets the request through can't make a real
// outbound call.
globalThis.fetch = async () =>
new Response(JSON.stringify({ choices: [{ message: { content: "unexpected" } }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
test.after(() => {
globalThis.fetch = originalFetch;
if (originalSiblingEnv === undefined) {
delete process.env[SIBLING_LIMIT_ENV];
} else {
process.env[SIBLING_LIMIT_ENV] = originalSiblingEnv;
}
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#8378: enforceOutputTokenBudget honors the combo-resolved context limit, not the plain per-target lookup", async () => {
// ~5,600 estimated tokens (estimateTokens ~= chars/4): comfortably above the
// combo-resolved limit (5,000, source="combo-min") but far below the naive
// getTokenLimit(MAIN_PROVIDER, MAIN_MODEL) fallback (128,000, the generic
// DEFAULT_LIMITS.default an unregistered provider/model resolves to). Only
// the combo-resolved limit rejects this request.
const longContent = "x".repeat(22_500);
const result = await handleChatCore({
body: {
model: MAIN_MODEL,
messages: [{ role: "user", content: longContent }],
stream: false,
},
modelInfo: {
provider: MAIN_PROVIDER,
model: MAIN_MODEL,
extendedContext: false,
},
credentials: {
apiKey: "sk-test",
providerSpecificData: {
baseUrl: "https://combo8378.example.test",
},
},
clientRawRequest: {
endpoint: "/v1/chat/completions",
body: {
model: MAIN_MODEL,
messages: [{ role: "user", content: longContent }],
stream: false,
},
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
isCombo: true,
comboName: COMBO_NAME,
log: {
debug() {},
info() {},
warn() {},
error() {},
},
});
assert.equal(
result.success,
false,
"expected the request to be rejected against the combo-resolved 5,000-token limit"
);
const failure = result as { success: false; error: string; rawMessage?: string };
const message = failure.rawMessage ?? failure.error;
assert.match(
message,
/limit 5000\b/,
`expected the rejection to cite the combo-resolved limit (5000); got: ${message}`
);
assert.doesNotMatch(
message,
/limit 128000\b/,
"the naive per-target getTokenLimit() fallback (128000) must not win over the combo override"
);
});