Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ export function estimateCoreMessages(messages: CoreMessage[]): number {
return tokens;
}

// #554: side requests bypass the pipeline (#388) and are forwarded VERBATIM,
// so their fit decision must be made on the RAW client body — the kernel view
// is empty on that path (processedMessages: []). Walks every string leaf of
// the parsed body through the same CJK-aware defaultCountTokens; binary-
// carrying fields (base64 image data, data-URLs) are excluded because
// imageTokensInParsedBody charges those separately. Slightly overcounts (ids,
// roles, structural strings) — a conservative bias is right for a guard that
// fails closed.
const NON_TEXT_BODY_KEYS = new Set(["data", "url", "b64_json", "file_data"]);

export function estimateRawBodyTokens(parsed: unknown): number {
let tokens = 0;
const walk = (value: unknown, key?: string): void => {
if (typeof value === "string") {
if (!key || !NON_TEXT_BODY_KEYS.has(key)) tokens += defaultCountTokens(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) walk(item, key);
return;
}
if (value && typeof value === "object") {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) walk(v, k);
}
};
walk(parsed);
return tokens;
}

function rangeChars(messages: CoreMessage[], startIdx: number, endIdx: number): number {
let chars = 0;
for (let i = startIdx; i <= endIdx && i < messages.length; i++) {
Expand Down
69 changes: 66 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { getSession, listSessions, type Session, initSessions, markDirty, flushA
import { COMPRESS_TOOL, ACP_TOOLS_ANTHROPIC, ACP_TOOLS_OPENAI, ACP_TOOLS_RESPONSES, ACP_READONLY_TOOLS_RESPONSES, COMPRESS_TOOL_NAME, buildCompressSystemPrompt, buildCompressHybridSystemPrompt, withStagedCompressGuidance } from "./compress-tool.js";
import { rewriteJsonResponse, type RewriteCtx } from "./stream.js";
import { applyRanges } from "./stream.js";
import { preflightCompress, estimateCoreMessages } from "./preflight.js";
import { preflightCompress, estimateCoreMessages, estimateRawBodyTokens } from "./preflight.js";
import { imageTokensInRawBody, imageTokensInParsedBody } from "./image-tokens.js";
import { renderUI, handleConfigGet, handleConfigPut } from "./web/index.js";
import { reapOrphanBlocks } from "./orphan-gc.js";
Expand Down Expand Up @@ -670,6 +670,38 @@ export function restoreOutputBudget(
}
}

// defaultCountTokens counts CJK per-char but real tokenizers encode CJK at
// ~0.6-0.75 tokens/char, so CJK-heavy raw bodies over-estimate by up to ~1.6x.
// Everywhere else that bias is safe (it only compresses earlier); here it
// would hard-deny a payload that really fits (retryable: false), so tolerate
// 15% over the window — borderline payloads forward, and a real overflow 400
// still teaches the learned limit.
const SIDE_REQUEST_GUARD_TOLERANCE = 1.15;

/** #554: side requests are forwarded VERBATIM (no pipeline, #388), so a payload
* over the upstream window is a guaranteed 400 that the learned self-heal can
* never fix from this path — the gate fires before the self-heal read and
* never consults reqConfig.modelContextLimit. Block here instead of forwarding:
* estimate the RAW body (CJK-aware text + image tokens) against the effective
* window = resolved ∩ learned (learned only ever shrinks) minus the output
* reservation on wires where output counts against the window. blocked=false
* with limit<=0 means "window unknown — forward as before". blocked requires
* estimate >= limit x SIDE_REQUEST_GUARD_TOLERANCE (estimator bias). */
export function sideRequestGuard(
parsed: unknown,
protocol: WireProtocol,
modelContextLimit: number,
learnedLimit: number | undefined,
): { blocked: boolean; estimate: number; limit: number } {
let limit = modelContextLimit;
if (typeof learnedLimit === "number" && learnedLimit > 0 && learnedLimit < limit) limit = learnedLimit;
const field = outputBudgetField(parsed);
const maxOut = field ? ((parsed as Record<string, unknown>)[field] as number) : 0;
if (limit > 0 && shouldReserveOutputHeadroom(protocol)) limit = reserveOutputHeadroom(limit, maxOut);
const estimate = estimateRawBodyTokens(parsed) + imageTokensInParsedBody(protocol, parsed);
return { blocked: limit > 0 && estimate >= limit * SIDE_REQUEST_GUARD_TOLERANCE, estimate, limit };
}

function isTrustedAdminOrigin(origin: string | undefined, host: string | undefined, trustedHosts: Set<string>): boolean {
// Host must be one of OUR listen identities regardless of whether an
// Origin header is present. A same-origin browser GET/fetch (the DNS
Expand Down Expand Up @@ -1344,6 +1376,32 @@ async function handle(
// preflight / fake-completion retry / the loop / usage sniffing are all
// skipped. processedMessages stays empty so the loop can never engage.
if (!countTokens && !responsesCompact && protocol !== null && isSideRequest(parsed)) {
// #554: the passthrough below skips EVERY input-side guard by design
// (#388) — a full-history side request over the window is a
// guaranteed upstream 400 (and title-gen/probe clients re-issue it,
// hammering the upstream). Gate on the raw body estimate and fail
// fast locally instead of forwarding (#301 precedent).
const reqModel = (parsed as { model?: string }).model;
const learnedMap = session.metadata.learnedContextLimits as Record<string, number> | undefined;
const learnedLimit =
(reqModel && learnedMap ? learnedMap[reqModel] : undefined) ??
(session.metadata.learnedContextLimit as number | undefined);
const guard = sideRequestGuard(parsed, protocol, reqConfig.modelContextLimit, learnedLimit);
if (guard.blocked) {
log("warn", `[${session.id}] side request (~${guard.estimate} tokens) ≥ effective window ${guard.limit} (model=${reqModel ?? "?"}) — NOT forwarded: guaranteed upstream 400 (side requests bypass preflight by design, #388)`);
if (!res.headersSent && !res.writableEnded && !res.destroyed) {
res.writeHead(413, { "content-type": "application/json" });
res.end(JSON.stringify({
error: {
type: "server_error",
code: "side_request_payload_too_large",
message: `side request payload ~${guard.estimate} tokens reaches the effective context window ${guard.limit} (model=${reqModel ?? "unknown"}); NOT forwarded — side requests (max_tokens<=${SIDE_REQUEST_MAX_TOKENS}) bypass compression by design (#388). Shrink the conversation or raise the model's context window.`,
retryable: false,
},
}));
}
return;
}
log("info", `[${session.id}] side request (max_tokens<=${SIDE_REQUEST_MAX_TOKENS}) → passthrough + tag strip only, kernel state untouched`);
const sidePrepared: Prepared = {
body: bodyBuffer,
Expand Down Expand Up @@ -2818,9 +2876,10 @@ async function forward(
// limit from another model must not cap this one).
let reqModel: string | undefined;
let rejectedImageTokens = 0;
let parsedBody: Record<string, unknown> | undefined;
try {
const rawBody = typeof prepared.body === "string" ? prepared.body : prepared.body.toString("utf8");
const parsedBody = JSON.parse(rawBody) as Record<string, unknown>;
parsedBody = JSON.parse(rawBody) as Record<string, unknown>;
reqModel = typeof parsedBody.model === "string" ? parsedBody.model : undefined;
// #488: the rejected payload's size must include its images (they were forwarded verbatim).
rejectedImageTokens = imageTokensInParsedBody(prepared.protocol, parsedBody);
Expand All @@ -2844,7 +2903,11 @@ async function forward(
// so the next turn re-centers the limit and the pre-flight
// compresses below it. Only shrink, never grow: a previously
// learned (smaller) value is the tighter bound.
const payloadEstimate = estimateCoreMessages(prepared.processedMessages) + rejectedImageTokens;
// #554: side passthrough has an EMPTY kernel view (processedMessages=[]),
// so estimate from the raw body — exactly what was forwarded verbatim.
const payloadEstimate = (prepared.processedMessages.length > 0
? estimateCoreMessages(prepared.processedMessages)
: estimateRawBodyTokens(parsedBody)) + rejectedImageTokens;
const prev = (reqModel ? learnedMap[reqModel] : undefined) ?? (s.metadata.learnedContextLimit as number | undefined);
if (payloadEstimate >= 1000 && (prev === undefined || payloadEstimate < prev)) {
if (reqModel) learnedMap[reqModel] = payloadEstimate;
Expand Down
8 changes: 8 additions & 0 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ const CONTEXT_OVERFLOW_PATTERNS: RegExp[] = [
/prompt_is_too_long/i,
/request_too_large/i,
/token limit exceeded/i,
// #554: llama.cpp-family "exceed_context_size_error (A / B > W)" — carried by
// side requests that bypass preflight; without it the learned channel learns nothing.
/exceed[_\s]?context[_\s]?size/i,
];

function toTokenNumber(s: string): number | undefined {
Expand All @@ -136,6 +139,11 @@ function parseOverflowWindow(text: string): number | undefined {
// "130000 tokens > 128000 maximum" (Anthropic) → the maximum, not the total.
let m = text.match(/>\s*(\d[\d,]*)\s*maximum/i);
if (m) return toTokenNumber(m[1]);
// #554: "exceed_context_size_error (198,277 / 198,661 > 150,528)" (llama.cpp
// family) — A/B are payload sizes; only the number after ">" inside the
// parens is the limit.
m = text.match(/\(\s*\d[\d,]*\s*\/\s*\d[\d,]*\s*>\s*(\d[\d,]+)\s*\)/);
if (m) return toTokenNumber(m[1]);
m =
text.match(/maximum context length is (\d[\d,]*)/i) ??
text.match(/maximum context length of (\d[\d,]*)/i) ??
Expand Down
Loading
Loading