fix(types): validate chat context estimation inputs (#9084)

Validated in local merge-train (devbox-vm-06-dev002) @ combined-tip (FAST gates: typecheck/complexity/cognitive/changelog/vitest — only pre-existing audit.test.ts flake). Evidence: /home/diegosouzapw/dev/proxys/OmniRoute/.claude/worktrees/merge-train-20260805-222248-suite.log
This commit is contained in:
backryun
2026-08-06 10:29:25 +09:00
committed by GitHub
parent 589c383859
commit eda45bf46e
4 changed files with 71 additions and 22 deletions

View File

@@ -1,6 +1,7 @@
import { injectMemoryAndSkills } from "./chatCore/memorySkillsInjection.ts";
import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
@@ -1822,27 +1823,6 @@ export async function handleChatCore({
// filtering is advisory and may preserve an all-incompatible pool; this is the
// hard boundary that prevents a too-large prompt (or a negative token budget)
// from reaching an OpenAI-compatible upstream such as NVIDIA NIM.
const estimateFinalInputTokens = (requestBody: Record<string, unknown> | null | undefined) => {
const adapted = requestBody
? adaptBodyForCompression(requestBody as Record<string, unknown>).body
: null;
const messages =
adapted?.messages ||
requestBody?.contents ||
requestBody?.request?.contents ||
(Array.isArray(requestBody?.input)
? requestBody.input
: requestBody?.input && typeof requestBody.input === "object"
? requestBody.input
: []);
return (
estimateTokens(messages) +
(Array.isArray(requestBody?.tools) ? estimateTokens(requestBody.tools) : 0) +
estimateTokens(requestBody?.system) +
estimateTokens(requestBody?.instructions)
);
};
let finalEstimatedInputTokens = estimateFinalInputTokens(body as Record<string, unknown>);
// Reuse the already-resolved `contextLimit` (may have been narrowed to the
// per-target combo window above, resolveComboContextLimit) instead of a bare

View File

@@ -0,0 +1,29 @@
import { adaptBodyForCompression } from "../../services/compression/bodyAdapter.ts";
import { estimateTokens } from "../../services/contextManager.ts";
type JsonRecord = Record<string, unknown>;
function asJsonRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
export function estimateFinalInputTokens(requestBody: JsonRecord | null | undefined): number {
const adapted = requestBody ? adaptBodyForCompression(requestBody).body : null;
const nestedRequest = asJsonRecord(requestBody?.request);
const messages =
adapted?.messages ||
requestBody?.contents ||
nestedRequest?.contents ||
(Array.isArray(requestBody?.input)
? requestBody.input
: requestBody?.input && typeof requestBody.input === "object"
? requestBody.input
: []);
return (
estimateTokens(messages) +
(Array.isArray(requestBody?.tools) ? estimateTokens(requestBody.tools) : 0) +
estimateTokens(requestBody?.system) +
estimateTokens(requestBody?.instructions)
);
}

View File

@@ -254,7 +254,7 @@ function extractImageTokens(node: unknown, seen: Set<unknown>): { node: unknown;
* budget instead of measuring its base64 payload as raw text, then the
* remainder of the structure is measured normally via the char/4 heuristic.
*/
export function estimateTokens(text: string | object | null | undefined): number {
export function estimateTokens(text: unknown): number {
if (!text) return 0;
if (typeof text === "string") {
return Math.ceil(text.length / CHARS_PER_TOKEN);

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
import { estimateFinalInputTokens } from "../../open-sse/handlers/chatCore/contextEstimation.ts";
import { estimateTokens } from "../../open-sse/services/contextManager.ts";
test("estimateFinalInputTokens counts nested request contents and auxiliary fields", () => {
const contents = [{ role: "user", parts: [{ text: "nested Gemini request" }] }];
const tools = [{ name: "lookup", description: "find a record" }];
const body = {
request: { contents },
tools,
system: "system prompt",
instructions: 42,
};
assert.equal(
estimateFinalInputTokens(body),
estimateTokens(contents) +
estimateTokens(tools) +
estimateTokens(body.system) +
estimateTokens(body.instructions)
);
});
test("estimateFinalInputTokens ignores a malformed nested request and uses input", () => {
const input = [{ role: "user", content: "fallback input" }];
const body = {
request: "not an object",
input,
};
assert.equal(estimateFinalInputTokens(body), estimateTokens(input));
});
test("estimateTokens accepts the unknown JSON values it already serializes", () => {
assert.equal(estimateTokens(true), 1);
assert.equal(estimateTokens(42), 1);
assert.equal(estimateTokens(undefined), 0);
});