From 722c1e921bed7825db8f0918fc3677e36eaa20a7 Mon Sep 17 00:00:00 2001 From: ci-check Date: Sun, 1 Nov 2026 02:09:50 +0800 Subject: [PATCH 1/2] fix: retain overflow-stated counts as provisional estimator bias (#5373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh overflow-only session (resume-miss sub-session, no usage history) could never publish the calibration ring while every forward kept overflowing: publication needs >=2 agreeing samples on one origin+model slot that any foreign settle clobbers, and the armed prefold comparator could not fit-exit under a clamped arm. Local judgments therefore stayed raw-estimate calibre forever and each forward re-400'd — a permanent loop (incident 2026-10-31, dsh lane). - armOverflowShrink retains clamp(stated/local-est, [0.25, 4]) per origin+model as stats.overflowBias (persisted, validated on restore); a ratio outside the band flags the record 'mismatched' (the stated count describes payload bili does not hold, #3450 shape) and such a record does not steer. - new util.effectiveCalibration picks the factor allowed to steer THIS route: ring k-hat when applicable to the decision origin, else the un-mismatched bias, else absent; all application sites (trigger, preflight mass/target, relay overhead, output clamp x3 lanes) now use it. - the armed preflight comparator exits on the calibrated round text when a factor applies (folds until est*k <= budget instead of burning an unsatisfiable floor comparison); legacy behavior when no factor applies. - ingest/settle/arm now log accept/reject reasons (the learning path was silent before, which made the incident undiagnosable from production logs). - the post-fold summary line quotes savings from its own endpoints instead of the floor-clamped meter ('~0 tokens saved (913329 -> 875788)' can no longer happen); the meter field itself is unchanged (consumed by the #3444 backoff gate). Tests: tests/issue5373-overflow-bias.test.ts (unit gates for effectiveCalibration/calibrationOriginApplies incl. the mismatch guard; e2e mechanism + regression through DeepSeek-shaped overflows with a billing mock). --- src/cache-ledger.ts | 29 ++- src/persist.ts | 28 ++ src/preflight.ts | 34 ++- src/server.ts | 19 +- src/server/prepare-google.ts | 7 +- src/server/prepare-openai.ts | 7 +- src/server/prepare-responses.ts | 7 +- src/server/relay.ts | 48 +++- src/session.ts | 28 +- src/util.ts | 63 +++++ tests/issue5373-overflow-bias.test.ts | 357 ++++++++++++++++++++++++++ 11 files changed, 599 insertions(+), 28 deletions(-) create mode 100644 tests/issue5373-overflow-bias.test.ts diff --git a/src/cache-ledger.ts b/src/cache-ledger.ts index bf6ad6ce62..3492264b62 100644 --- a/src/cache-ledger.ts +++ b/src/cache-ledger.ts @@ -20,7 +20,7 @@ import { METADATA_CANONICAL_SYS_FP, METADATA_FOLD_COVERAGE, METADATA_LAST_RESPON import { clearPostRebuildAnchor, markDirty, type Session } from "./session.js"; import { isForeignMembership } from "./request-membership.js"; import { billInputUsage } from "./billing.js"; -import { normalizeUpstreamOrigin } from "./util.js"; +import { CALIBRATION_CLAMP_MAX, CALIBRATION_CLAMP_MIN, normalizeUpstreamOrigin } from "./util.js"; import { toolFail, toolOk, type ProxyToolResult } from "./proxy-tool-result.js"; // Render window for handleAcpCache's detail:"full" text view (#1489). The @@ -1716,9 +1716,10 @@ export const CALIBRATION_SAMPLE_MAX = 5; // it covers the measured CJK band and stays BELOW CALIBRATION_SAMPLE_MAX (5), // so non-corresponding reports are still rejected before the clamp sees them. // Wild values remain bounded by the evidence gates: ≥2 recent samples agreeing -// within ×2, keyed by route AND model, MIN_ESTIMATE floor. -export const CALIBRATION_CLAMP_MIN = 0.25; -export const CALIBRATION_CLAMP_MAX = 4; +// within ×2, keyed by route AND model, MIN_ESTIMATE floor. Since #5373 the +// constants live in util.ts (the read side needs them too) and are +// re-exported here so existing imports keep resolving. +export { CALIBRATION_CLAMP_MAX, CALIBRATION_CLAMP_MIN } from "./util.js"; // Evidence requirements: ≥2 recent same-route samples agreeing within ×2. // One lucky/degenerate pair must not flip every estimate on the route. export const CALIBRATION_SAMPLE_WINDOW = 3; @@ -1755,7 +1756,16 @@ function ingestCalibrationPair(session: Session, billed: number, upstream: strin const calibPolicy = pendingCalibOrigin === settleOrigin ? session.stats.lastCalibrationPolicy : undefined; const pendingEst = session.stats.lastLocalTextEstimate; const pendingOrigin = normalizeUpstreamOrigin(session.stats.lastLocalTextEstimateOrigin); - if (pendingEst !== undefined && pendingEst >= CALIBRATION_MIN_ESTIMATE && settleOrigin !== undefined && pendingOrigin === settleOrigin) { + if (pendingEst === undefined || pendingEst < CALIBRATION_MIN_ESTIMATE || settleOrigin === undefined || pendingOrigin !== settleOrigin) { + // #5373: this gate used to fail SILENTLY — in production there was no + // trace whether a measurement was ever ingested, which is exactly what + // made the #5373 incident undiagnosable from the log. One debug line + // with the reason keeps healthy sessions quiet but makes every miss + // greppable. + loggerLog("debug", `[${session.id}] calibration sample not ingested: ${pendingEst === undefined ? "no pending local estimate" : pendingEst < CALIBRATION_MIN_ESTIMATE ? `local estimate ${Math.round(pendingEst)} below min ${CALIBRATION_MIN_ESTIMATE}` : settleOrigin === undefined ? "no settle origin" : `origin mismatch (${pendingOrigin ?? "?"} ≠ ${settleOrigin})`}, billed=${billed} (#1933)`); + return; + } + { const rawSample = billed / pendingEst; // #2117 B: the ring is keyed by route AND model — tokenizers bill // differently across models, so a mid-session model switch starts a @@ -1777,6 +1787,7 @@ function ingestCalibrationPair(session: Session, billed: number, upstream: strin if (rawSample >= CALIBRATION_SAMPLE_MIN && rawSample <= CALIBRATION_SAMPLE_MAX) { ring.values.push(rawSample); if (ring.values.length > CALIBRATION_SAMPLE_WINDOW) ring.values.shift(); + loggerLog("debug", `[${session.id}] calibration sample ${rawSample.toFixed(3)} ingested (billed ${billed} / local est ${Math.round(pendingEst)}) — ring [${ring.values.map((v) => v.toFixed(3)).join(", ")}] route=${settleOrigin}${settleModel ? ` model=${settleModel}` : ""} (#1933)`); const spread = Math.max(...ring.values) / Math.min(...ring.values); if (ring.values.length >= 2 && spread <= CALIBRATION_CONSISTENCY_RATIO) { const mean = ring.values.reduce((a, b) => a + b, 0) / ring.values.length; @@ -1786,6 +1797,7 @@ function ingestCalibrationPair(session: Session, billed: number, upstream: strin delete session.stats.calibratedEstimate; delete session.stats.calibratedEstimateOrigin; delete session.stats.calibratedEstimateModel; + loggerLog("info", `[${session.id}] calibration k̂ NOT published — route policy disabled (providers[].calibration.disabled) (#3211)`); } else { const clamped = Math.min(CALIBRATION_CLAMP_MAX, Math.max(CALIBRATION_CLAMP_MIN, mean)); session.stats.calibratedEstimate = calibPolicy?.max !== undefined ? Math.min(clamped, calibPolicy.max) : clamped; @@ -1796,12 +1808,17 @@ function ingestCalibrationPair(session: Session, billed: number, upstream: strin // branch: the disabled path above clears all three fields and // must not re-stamp one of them (#3211 review). session.stats.calibratedEstimateModel = ring.model; + loggerLog("info", `[${session.id}] calibration k̂=${session.stats.calibratedEstimate.toFixed(3)} published (ring [${ring.values.map((v) => v.toFixed(3)).join(", ")}], n=${ring.values.length}) route=${settleOrigin}${ring.model ? ` model=${ring.model}` : ""} (#1933)`); } } else { + const hadPublished = session.stats.calibratedEstimate !== undefined; delete session.stats.calibratedEstimate; delete session.stats.calibratedEstimateOrigin; delete session.stats.calibratedEstimateModel; + if (hadPublished) loggerLog("info", `[${session.id}] calibration k̂ cleared (n=${ring.values.length}, spread ${spread.toFixed(2)} > ${CALIBRATION_CONSISTENCY_RATIO}) — falling back to raw estimate until evidence agrees (#1933)`); } + } else { + loggerLog("debug", `[${session.id}] calibration sample ${rawSample.toFixed(3)} rejected — outside plausibility band [${CALIBRATION_SAMPLE_MIN}, ${CALIBRATION_SAMPLE_MAX}] (billed ${billed} / local est ${Math.round(pendingEst)}) (#1933)`); } session.stats.calibrationRing = ring; } @@ -1824,7 +1841,9 @@ export function settleOverflowCalibration( upstream?: string, ): void { if (!(measured > 0)) return; + const pendingBefore = session.stats.lastLocalTextEstimate; ingestCalibrationPair(session, measured, upstream); + loggerLog("info", `[${session.id}] overflow-stated measurement ingested: upstream stated ${measured} tokens for the rejected payload, local est ${pendingBefore !== undefined ? Math.round(pendingBefore) : "none"} (route=${normalizeUpstreamOrigin(upstream) ?? "?"}) — ring discipline applies (#3900/#5373)`); // The rejected request will never produce a usage report — retire its // pending pair NOW so no later report can mispair against a consumed // estimate (#1933 F1). A same-request refold (#1195) re-runs prepare and diff --git a/src/persist.ts b/src/persist.ts index 718c04c60f..693469e37e 100644 --- a/src/persist.ts +++ b/src/persist.ts @@ -206,6 +206,7 @@ interface PersistedSession { calibratedEstimate?: number; calibratedEstimateOrigin?: string; calibrationRing?: { origin: string; values: number[] }; + overflowBias?: { ratio: number; origin: string; model?: string; measured: number; estimate: number; mismatched?: boolean }; pendingFoldUsage?: boolean; lastLocalTextEstimate?: number; lastLocalTextEstimateOrigin?: string; @@ -4050,6 +4051,32 @@ function restoreBlockContents(entries: Iterable): Ma return blockContents; } +// #5373: same discipline for the provisional overflow-stated bias — every +// field validated, ratio clamped into the published band, non-positive +// measurements dropped. A corrupt entry must degrade to absent (raw estimate), +// never steer a decision off-scale. +function restoreOverflowBias(v: unknown): { ratio: number; origin: string; model?: string; measured: number; estimate: number; mismatched?: boolean } | undefined { + if (!v || typeof v !== "object") return undefined; + const b = v as Record; + if (typeof b.origin !== "string" || b.origin.length === 0) return undefined; + if (typeof b.ratio !== "number" || !Number.isFinite(b.ratio) || b.ratio <= 0) return undefined; + if (typeof b.measured !== "number" || !Number.isFinite(b.measured) || b.measured <= 0) return undefined; + if (typeof b.estimate !== "number" || !Number.isFinite(b.estimate) || b.estimate <= 0) return undefined; + // A stored ratio outside the band can only have come from an old writer; + // re-derive the distrust flag from the clamped value's neighbors so a + // corrupt file never steers decisions off-scale. + const clamped = Math.min(CALIBRATION_CLAMP_MAX, Math.max(CALIBRATION_CLAMP_MIN, b.ratio)); + const mismatched = typeof b.mismatched === "boolean" ? b.mismatched : b.ratio < CALIBRATION_CLAMP_MIN || b.ratio > CALIBRATION_CLAMP_MAX; + return { + ratio: clamped, + origin: b.origin, + ...(typeof b.model === "string" && b.model.length > 0 ? { model: b.model } : {}), + measured: b.measured, + estimate: b.estimate, + ...(mismatched ? { mismatched: true } : {}), + }; +} + /** #4942: the record→legacy-view adapter the sidecar ingest path runs on * every committed record — exported so the hermetic seam suite can build a * sidecar against a stub backend with the SAME adapter the store uses. */ @@ -4116,6 +4143,7 @@ function buildSession(parsed: PersistedSession): Session { calibratedEstimate: restoreCalibratedEstimate(stats.calibratedEstimate), calibratedEstimateOrigin: typeof stats.calibratedEstimateOrigin === "string" && stats.calibratedEstimateOrigin.trim().length > 0 ? stats.calibratedEstimateOrigin : undefined, calibrationRing: restoreCalibrationRing(stats.calibrationRing), + overflowBias: restoreOverflowBias(stats.overflowBias), // Intentionally NOT restored (#2129 triage): pendingFoldUsage is set at // compress execution and consumed only as a log-suffix attribution by the // NEXT usage report (#695) — restoring a stale true would mislabel an diff --git a/src/preflight.ts b/src/preflight.ts index 88b32d94c4..97bd01abdc 100644 --- a/src/preflight.ts +++ b/src/preflight.ts @@ -26,7 +26,7 @@ import { epochConfirmPending } from "./fold-reconcile.js"; import { deadSkipKeys, filterDeadRanges, parseStructuralEmptyVerdict, recordDeadRange } from "./dead-ranges.js"; import { peekRegistryOutputLimit } from "./registry.js"; import { safePrefix } from "./text-safe.js"; -import { applyEstimateCalibration, currentCalibrationFactor } from "./util.js"; +import { applyEstimateCalibration, calibrationOriginApplies, effectiveCalibration } from "./util.js"; import { configuredSummaryPlan, type ConfiguredSummaryPlan } from "./external-summary-runtime.js"; import { messageTimestampSections } from "./external-summary-surface.js"; import { isCodexClient } from "./codex-compact.js"; @@ -1765,13 +1765,18 @@ export async function preflightCompress(deps: PreflightDeps, messages: CoreMessa // kernel's own truncation/absorb behavior stays byte-identical. const imageReserve = deps.imageReserve ?? 0; const wireOverhead = deps.wireOverhead ?? 0; - // #1933 F1: capture k̂ once — it only mutates on usage settlement (outside - // this invocation), while every fit judgment in this loop must stay on - // one consistent scale with the gate that started it. + // #1933 F1: capture k̂ once — it only mutates on usage/overflow settlement + // (outside this invocation), while every fit judgment in this loop must + // stay on one consistent scale with the gate that started it. // #2117 B: model-provenance gate — a factor learned on another model acts // as absent (raw estimate) rather than deciding with a cross-model scale. - const kFactor = currentCalibrationFactor(deps.session.stats, deps.session.metadata?.lastModel); - const kOrigin = deps.session.stats.calibratedEstimateOrigin; + // #5373: effectiveCalibration falls back to the provisional overflow-stated + // bias when the ring has nothing applicable to this route, so an + // overflow-only session folds on the upstream-stated scale from the first + // rejection onward. + const calibration = effectiveCalibration(deps.session.stats, deps.session.metadata?.lastModel, deps.upstreamOrigin); + const kFactor = calibration.k; + const kOrigin = calibration.origin; // #3645: kernel range.tokens are the UNCALIBRATED count, but // result.payloadEstimate (hence every deficit derived from it) sits on the // CALIBRATED scale once a matching-origin k̂ is set (#1933 F1). Folding a @@ -2269,8 +2274,23 @@ export async function preflightCompress(deps: PreflightDeps, messages: CoreMessa // marathon — that burn was driven by a stale USAGE-sourced baseline // measured under a prior wider-window model, which keeps the floorless // roundText path. + // #5373 carve-out of the exception: when a route-calibration factor applies + // to this origin (ring-published k̂ or the provisional overflow-stated bias), + // roundText is ALREADY projected onto the upstream-stated scale above — the + // distrust premise ("the local estimate may not outrank the rejection") is + // exactly the gap the factor closes. Without it, a clamped arm makes the + // floored comparator unsatisfiable (floor == compressionTarget == stated + // budget ⇒ floor−img never < target−img) and the rescue burns the whole + // ladder, forwarding whatever remains over the window again. With the + // factor the calibrated roundText exits early at raw < target/k̂ — the + // stated budget expressed in local units. k̂>1 keeps the raw floor implied + // (calRound1. foldedCalibratedSize = rebuiltCalibrated; - log("info", `[${session.id}] preflight compressed ${result.compressedRanges} range(s), ~${result.savedTokens} tokens saved (${tokenCount} → ${foldedCalibratedSize}) in ${Date.now() - started}ms`); + // Quote savings from THIS line's own endpoints (#5373): result.savedTokens + // is the preflight meter (floored per round by the baseline), which can + // clamp to 0 while the quoted estimates still show real reduction — the + // incident logged "~0 tokens saved (913329 → 875788)". + log("info", `[${session.id}] preflight compressed ${result.compressedRanges} range(s), ~${Math.max(0, tokenCount - foldedCalibratedSize)} tokens saved (${tokenCount} → ${foldedCalibratedSize}) in ${Date.now() - started}ms`); fits = unknownBaseline ? result.fitsWindow : rebuiltCalibrated < limit; // #1820: anchor the meter to the rebuilt payload's measured size — the // rebuild request's own usage report (the only sample that can supersede diff --git a/src/server/prepare-google.ts b/src/server/prepare-google.ts index 501ce87846..d22dcf4ec4 100644 --- a/src/server/prepare-google.ts +++ b/src/server/prepare-google.ts @@ -4,7 +4,7 @@ import { renderNudgeText, viableRanges } from "acp-kernel"; import { coreToGoogle, googleToCore, normalizeGoogleUserPartOrder, type BiliMessage, type GoogleContent, type GoogleRequestBody, type GoogleSystemInstruction, type GoogleTool } from "acp-kernel/wire"; import type { ProxyOptions } from "../config.js"; import { pluginAgentHeader } from "../plugin.js"; -import { currentCalibrationFactor } from "../util.js"; +import { effectiveCalibration } from "../util.js"; import { diagNudge, diagTagSummary, deriveTitle, effectiveTokenCount, imageBillingFor, imageReserveFor, imageTokenCapFor, isAutoInjectedNotification, reapOrphansLogged, resolveKnownOutputCeiling, runNudgeDecision, stripKernelSummaries, usageGradeInputBaseline, type Prepared } from "../server.js"; import { recordNudgeObservation } from "../context-analysis.js"; import { yieldToEventLoop } from "../yield-loop.js"; @@ -397,7 +397,10 @@ export async function prepareGoogle( } const rebuilt: GoogleRequestBody = { ...parsed, contents: rebuiltContents, tools: toolsOut, systemInstruction }; - clampOutgoingOutput(rebuilt as Record, "generationConfig.maxOutputTokens", { systemText: googleClientSystem, tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "google", rebuilt, opts, upstreamOrigin), kFactor: currentCalibrationFactor(session.stats, session.metadata?.lastModel), kOrigin: session.stats.calibratedEstimateOrigin, origin: upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, upstreamOrigin, opts.sessionHeader, model) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); + // #5373: ring k̂ first, provisional overflow-stated bias when the ring has + // nothing applicable to this route. + const calibration = effectiveCalibration(session.stats, session.metadata?.lastModel, upstreamOrigin); + clampOutgoingOutput(rebuilt as Record, "generationConfig.maxOutputTokens", { systemText: googleClientSystem, tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "google", rebuilt, opts, upstreamOrigin), kFactor: calibration.k, kOrigin: calibration.origin, origin: upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, upstreamOrigin, opts.sessionHeader, model) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); // #532: title-gen side requests carry their own tiny system — skip them. if (!isTitleGen && googleOutboundSystem !== undefined) { session.metadata.systemPromptTokens = countSystemAndToolsTokens(googleOutboundSystem, toolsOut); diff --git a/src/server/prepare-openai.ts b/src/server/prepare-openai.ts index 94acf1ed60..49502ac74d 100644 --- a/src/server/prepare-openai.ts +++ b/src/server/prepare-openai.ts @@ -5,7 +5,7 @@ import { coreToOpenai, injectOpenaiSystem, openaiToCore, type BiliMessage, type import type { CompressReasoningConfig } from "../reasoning-drop.js"; import type { ProxyOptions } from "../config.js"; import { effectivePromptCacheRouting } from "../config.js"; -import { currentCalibrationFactor, hardenOpenaiAssistantContent, systemToUser } from "../util.js"; +import { effectiveCalibration, hardenOpenaiAssistantContent, systemToUser } from "../util.js"; import { conversationHeaderSource, resolveRelayAffinityPck } from "../session-id.js"; import { MSG_IDS_BODY_FIELD, applyMsgIdPin, validateMsgIds } from "../msgid-pin.js"; import { pluginAgentHeader } from "../plugin.js"; @@ -572,7 +572,10 @@ export async function prepareOpenai( rebuiltMessages = normalizeStrictEchoReasoning(rebuiltMessages, isStrictReasoningEcho(session, upstreamOrigin, modelIdOf(parsed)), log, sessionId); const rebuilt: OpenAIRequestBody = { ...parsed, messages: rebuiltMessages, tools: toolsOut as OpenAITool[] | undefined }; warnReasoningPairs(rebuiltMessages, log, sessionId); - clampOutgoingOutput(rebuilt as Record, typeof (parsed as Record).max_completion_tokens === "number" ? "max_completion_tokens" : "max_tokens", { systemText: openaiSystemText, tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "openai", rebuilt, opts, billingUpstream ?? upstreamOrigin), kFactor: currentCalibrationFactor(session.stats, session.metadata?.lastModel), kOrigin: session.stats.calibratedEstimateOrigin, origin: billingUpstream ?? upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, billingUpstream ?? upstreamOrigin, opts.sessionHeader, modelIdOf(parsed)) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); + // #5373: ring k̂ first, provisional overflow-stated bias when the ring has + // nothing applicable to this route. + const calibration = effectiveCalibration(session.stats, session.metadata?.lastModel, billingUpstream ?? upstreamOrigin); + clampOutgoingOutput(rebuilt as Record, typeof (parsed as Record).max_completion_tokens === "number" ? "max_completion_tokens" : "max_tokens", { systemText: openaiSystemText, tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "openai", rebuilt, opts, billingUpstream ?? upstreamOrigin), kFactor: calibration.k, kOrigin: calibration.origin, origin: billingUpstream ?? upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, billingUpstream ?? upstreamOrigin, opts.sessionHeader, modelIdOf(parsed)) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); // prompt_cache_retention is an OpenAI-host-only cache directive; the dsh // launcher forces PI_CACHE_RETENTION=long (for the session-id // prompt_cache_key) which makes the client also emit it. Third-party diff --git a/src/server/prepare-responses.ts b/src/server/prepare-responses.ts index 03218acc57..c4203453d8 100644 --- a/src/server/prepare-responses.ts +++ b/src/server/prepare-responses.ts @@ -5,7 +5,7 @@ import { injectResponsesDeveloperMessage, type BiliMessage, type ResponseInputIt import type { CompressReasoningConfig } from "../reasoning-drop.js"; import { resolveAbsorbSettings } from "../compress-settings.js"; import { effectivePromptCacheRouting, resolveCompressProtocol, type CompressSettings, type ProxyOptions } from "../config.js"; -import { currentCalibrationFactor, strippedResponseIdWarning } from "../util.js"; +import { effectiveCalibration, strippedResponseIdWarning } from "../util.js"; import { applyReasoningDrop, diagNudge, diagTagSummary, deriveTitle, effectiveTokenCount, imageBillingFor, imageReserveFor, imageTokenCapFor, isAutoInjectedNotification, planReasoningDrop, reapOrphansLogged, resolveKnownOutputCeiling, runNudgeDecision, stripKernelSummaries, usageGradeInputBaseline, warnResponsesReasoningPairs, type Prepared } from "../server.js"; import { collectSeparatorManifest, repairResponsesAssistantOrdering, type SeparatorSlot } from "./responses-ordering.js"; import { recordNudgeObservation } from "../context-analysis.js"; @@ -627,7 +627,10 @@ reconcileFoldCoverage(session, msgs, { mode: foldMode, sessionId, log, markerStr const rebuilt: ResponsesRequestBody = { ...parsed, input: rebuiltInput, tools: toolsOut }; warnResponsesReasoningPairs(Array.isArray(rebuiltInput) ? rebuiltInput : [], log, sessionId); if (!isCompactionTrigger) { - clampOutgoingOutput(rebuilt as Record, "max_output_tokens", { systemText: (responsesProjection?.systemParts ?? []).join("\n"), tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "responses", rebuilt, opts, billingUpstream ?? upstreamOrigin), kFactor: currentCalibrationFactor(session.stats, session.metadata?.lastModel), kOrigin: session.stats.calibratedEstimateOrigin, origin: billingUpstream ?? upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, billingUpstream ?? upstreamOrigin, opts.sessionHeader, modelIdOf(parsed)) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); + // #5373: ring k̂ first, provisional overflow-stated bias when the ring + // has nothing applicable to this route. + const calibration = effectiveCalibration(session.stats, session.metadata?.lastModel, billingUpstream ?? upstreamOrigin); + clampOutgoingOutput(rebuilt as Record, "max_output_tokens", { systemText: (responsesProjection?.systemParts ?? []).join("\n"), tools: toolsOut, processedMessages, lastInputTokens: session.stats.lastInputTokens, lastInputTokensSource: session.stats.lastInputTokensSource, nativeWindow, headroomWindow: config.modelContextLimit, imageTokens: imageReserveFor(session, "responses", rebuilt, opts, billingUpstream ?? upstreamOrigin), kFactor: calibration.k, kOrigin: calibration.origin, origin: billingUpstream ?? upstreamOrigin, ledgerFloorTokens: dshLedgerFloorTokens(session.metadata), knownOutputCeiling: resolveKnownOutputCeiling(req.headers, parsed as Record, opts.routes, billingUpstream ?? upstreamOrigin, opts.sessionHeader, modelIdOf(parsed)) }, sessionId, log, (c) => { session.stats.lastOutputClamp = { ...c, at: Date.now() }; }); } // Route with the upstream THIS request goes to — session.meta.upstreamOrigin // is first-wins and would keep injecting pck toward a relay we switched diff --git a/src/server/relay.ts b/src/server/relay.ts index 7e34790cf8..b892b0e458 100644 --- a/src/server/relay.ts +++ b/src/server/relay.ts @@ -64,7 +64,7 @@ import { clearUpstreamAlertsForHost, noteCircuitFailFastLog, recordUpstreamAlert import { describeUpstreamFailure, formatUpstreamError, proxyDispatcher, recordUpstreamConnection } from "../upstream-proxy.js"; import { markExpectedFailFast, withUpstreamFailure, type UpstreamFailureKind } from "../upstream-fail.js"; import { noteUpstreamThrottle429, noteUpstreamThrottleResolved, releaseUpstreamThrottleProbe, requestModelFromBody, throttle429ModelScope, type ThrottleLocalReject, upstreamThrottleGate, upstreamThrottlePeekReject } from "../upstream-throttle.js"; -import { applyEstimateCalibration, currentCalibrationFactor, inspectContextOverflow, isMarkerlessOverflowReject, MIN_ASSUMED_WINDOW_TOKENS, normalizeUpstreamOrigin, usageOutputTotal, usageTotals, type ContextOverflowInfo, type WireProtocol } from "../util.js"; +import { applyEstimateCalibration, CALIBRATION_CLAMP_MAX, CALIBRATION_CLAMP_MIN, effectiveCalibration, inspectContextOverflow, isMarkerlessOverflowReject, MIN_ASSUMED_WINDOW_TOKENS, normalizeUpstreamOrigin, usageOutputTotal, usageTotals, type ContextOverflowInfo, type WireProtocol } from "../util.js"; import { billOutputUsage } from "../billing.js"; import { DUMP_PLAINTEXT_BANNER } from "../dump-banner.js"; import { recordPckAblation } from "../pck-probe.js"; @@ -1147,8 +1147,8 @@ async function forwardCaptured( // #4383: the write itself lives in noteOutboundContextEstimates so the // stamp contract (and its "estimate" freshness mark, which // usageAnchorCycleStale reads) is testable. - const kFactor = currentCalibrationFactor(prepared.session.stats, prepared.session.metadata?.lastModel); - const scaledText = applyEstimateCalibration(est.textOverhead, kFactor, prepared.session.stats.calibratedEstimateOrigin, normalizeUpstreamOrigin(upstreamUrl)); + const cal = effectiveCalibration(prepared.session.stats, prepared.session.metadata?.lastModel, upstreamUrl); + const scaledText = applyEstimateCalibration(est.textOverhead, cal.k, cal.origin, normalizeUpstreamOrigin(upstreamUrl)); noteOutboundContextEstimates(prepared.session, { upperBound: est.upperBound, scaledText, @@ -1769,6 +1769,48 @@ async function forwardCaptured( // forever. The refold below re-runs prepare and re-notes a fresh // pair for the folded body (#1195), so retiring the consumed pair // here cannot mispair a later report. + // #5373: retain the overflow's stated count as a PROVISIONAL estimator + // scale for this route. The ring above publishes only from ≥2 agreeing + // samples and is a single slot any other-origin/model settle clobbers, + // so on a fresh overflow-only session (resume-miss sub-session, no usage + // history) it can never publish while every forward keeps overflowing — + // a permanent 400 loop. The first overflow must steer local judgments + // immediately: effectiveCalibration falls back to this when the ring has + // nothing applicable to the decision route. Keep the MAX ratio seen for + // this origin+model (conservative direction — over-folding costs summary + // tokens, never wire integrity); model-gated so a sidecar overflow on + // another model cannot clobber the main lane's evidence. Runs BEFORE + // settleOverflowCalibration retires the pending pair it reads. + if (measured !== undefined && typeof s.stats.lastLocalTextEstimate === "number" && s.stats.lastLocalTextEstimate > 0) { + const biasOrigin = normalizeUpstreamOrigin(upstreamUrl); + const biasModel = typeof s.metadata?.lastModel === "string" && s.metadata.lastModel !== "" ? s.metadata.lastModel : reqModel; + const rawRatio = measured / s.stats.lastLocalTextEstimate; + if (biasOrigin !== undefined && Number.isFinite(rawRatio) && rawRatio > 0) { + const policyOrigin = s.stats.lastCalibrationPolicyOrigin !== undefined ? normalizeUpstreamOrigin(s.stats.lastCalibrationPolicyOrigin) : undefined; + const policy = policyOrigin === biasOrigin ? s.stats.lastCalibrationPolicy : undefined; + if (policy?.disabled !== true) { + // A ratio outside the band means the stated count + // describes ≥4× / ≤¼ of bili's own view of this payload + // — content bili does not hold (#3450 shape). Retain it + // flagged so a later sane measurement can supersede it, + // but effectiveCalibration must not steer off it. + const mismatched = rawRatio < CALIBRATION_CLAMP_MIN || rawRatio > CALIBRATION_CLAMP_MAX; + let ratio = Math.min(CALIBRATION_CLAMP_MAX, Math.max(CALIBRATION_CLAMP_MIN, rawRatio)); + if (policy?.max !== undefined) ratio = Math.min(ratio, policy.max); + const prev = s.stats.overflowBias; + const sameLane = prev !== undefined && normalizeUpstreamOrigin(prev.origin) === biasOrigin + && !(prev.model !== undefined && biasModel !== undefined && prev.model !== biasModel); + // Trust beats distrust: a sane sample replaces a + // mismatched one even when smaller; within one trust + // class keep the MAX (conservative direction). + const replace = !sameLane || (mismatched !== (prev.mismatched === true) ? !mismatched : ratio > prev.ratio); + if (replace) { + s.stats.overflowBias = { ratio, origin: biasOrigin, ...(biasModel !== undefined ? { model: biasModel } : {}), measured, estimate: s.stats.lastLocalTextEstimate, ...(mismatched ? { mismatched: true } : {}) }; + log("info", `[${s.id}] retained overflow-stated bias r=${ratio.toFixed(3)}${mismatched ? ` (raw ${rawRatio.toFixed(2)} outside [${CALIBRATION_CLAMP_MIN}, ${CALIBRATION_CLAMP_MAX}] — stated count does not match bili's view, NOT steering)` : ""} (upstream stated ${measured} vs local est ${Math.round(s.stats.lastLocalTextEstimate)}, model=${biasModel ?? "unknown"}) — steering local judgments on this route until the ring supersedes (#5373)`); + } + } + } + } if (measured !== undefined) settleOverflowCalibration(s, measured, upstreamUrl); } else { // No window number stated — nothing to learn (and #987 diff --git a/src/session.ts b/src/session.ts index 33c9833c52..944f0e86a1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -588,8 +588,29 @@ export type Session = { * the recent admitted raw samples for ONE origin+model (max 3). * Persisted so a restart doesn't re-arm the warmup delay; reset at * native-compaction boundaries with the rest of the baseline stats. */ - calibrationRing?: { origin: string; model?: string; values: number[] }; - /** #1933 F1: pending pairing input — local estimate of the LAST + calibrationRing?: { origin: string; model?: string; values: number[] }; + /** #5373: provisional estimator scale from a SINGLE overflow 400's + * stated counts — ratio = upstream-stated message count ÷ the same + * payload's local estimate at arm time, clamped like k̂. The ring + * above publishes only from ≥2 agreeing samples and is a single slot + * that any other-origin/model settle clobbers; on a fresh + * overflow-only session (resume-miss sub-session, no usage history) + * publication can therefore never happen while every forward keeps + * overflowing — a permanent 400 loop (#5373). This field makes the + * first overflow steer local judgments immediately (effectiveCalib- + * ration falls back to it when the ring has nothing applicable to + * the decision route); a ring-published k̂ supersedes it. Update + * rule: keep the MAX ratio seen for this origin+model (conservative + * direction — over-folding costs summary tokens, never wire + * integrity). Persisted like the ring; reset at compaction + * boundaries with it. */ + /** #5373: the stated count was ≥4× / ≤¼ of bili's own view of the + * same payload — it describes content bili does not hold (host-side + * invisible mass, foreign payload state), so the factor must NOT + * steer local judgments (effectiveCalibration skips it); kept for + * observability and so a later sane measurement can supersede it. */ + overflowBias?: { ratio: number; origin: string; model?: string; measured: number; estimate: number; mismatched?: boolean }; + /** #1933 F1: pending pairing input — local estimate of the LAST * prepared outbound in BILLED caliber (estimateCoreMessages + * system/tools overhead + image reserve, defaultCountTokens rate; * includes host-projected thinking mass since #2407), @@ -1770,6 +1791,9 @@ export function resetSessionCompression(session: Session): void { // #2117 B: same boundary — the factor's model provenance dies with it. delete session.stats.calibratedEstimateModel; delete session.stats.calibrationRing; + // #5373: same boundary — the bias described the pre-compaction payload's + // billing scale; the rebuilt conversation re-measures from zero. + delete session.stats.overflowBias; delete session.stats.lastLocalTextEstimate; delete session.stats.lastLocalTextEstimateOrigin; delete session.stats.lastCalibrationPolicy; diff --git a/src/util.ts b/src/util.ts index 0d8252895a..358fa30a7a 100644 --- a/src/util.ts +++ b/src/util.ts @@ -141,6 +141,69 @@ export function currentCalibrationFactor(stats: { calibratedEstimate?: number; c return k; } +/** #5373: clamp band for estimator scale factors — the ring-published k̂ and + * the provisional overflow bias alike. A factor outside [0.25, 4] describes + * a different billing substrate, not a scale drift, and must not steer local + * judgments. Single home for the band: learning (cache-ledger ingest), read + * (effectiveCalibration) and persistence validation (persist restore). */ +export const CALIBRATION_CLAMP_MIN = 0.25; +export const CALIBRATION_CLAMP_MAX = 4; + +/** #5373: does a factor recorded on `kOrigin` apply to a request now routing + * to `origin`? Both origins known and equal → yes; either unknown or the + * routes differ → no (raw estimate, legacy behavior). The same gate + * applyEstimateCalibration enforces at scale time, extracted so + * effectiveCalibration can pick the right provenance BEFORE scaling. */ +export function calibrationOriginApplies(kOrigin: string | undefined, origin: string | undefined): boolean { + const a = normalizeUpstreamOrigin(origin); + const b = normalizeUpstreamOrigin(kOrigin); + return a !== undefined && b !== undefined && a === b; +} + +// #5373: model-compatibility gate shared by the ring factor and the +// provisional overflow bias — mirrors currentCalibrationFactor's rule: both +// models known (non-empty strings) and different → absent; missing +// information on either side never invalidates the evidence. +function calibrationModelCompatible(recorded: string | undefined, lastModel?: unknown): boolean { + if (recorded === undefined) return true; + if (typeof lastModel === "string" && lastModel !== "" && recorded !== lastModel) return false; + return true; +} + +/** #5373: pick the estimator scale factor allowed to steer local judgments + * on THIS route, with its provenance origin for applyEstimateCalibration. + * A ring-published k̂ applicable to the route wins (unchanged semantics); + * otherwise the provisional overflow bias retained at arm time — on a fresh + * overflow-only session the ring's ≥2-sample publication can never happen + * while every forward keeps overflowing (#5373 permanent 400 loop), so the + * first overflow's stated counts must steer immediately; otherwise absent + * (raw estimate, today's behavior). */ +export function effectiveCalibration( + stats: { + calibratedEstimate?: number; + calibratedEstimateOrigin?: string; + calibratedEstimateModel?: string; + overflowBias?: { ratio: number; origin: string; model?: string; mismatched?: boolean }; + }, + lastModel?: unknown, + origin?: string, +): { k: number | undefined; origin: string | undefined } { + const ringK = currentCalibrationFactor(stats, lastModel); + if (ringK !== undefined && calibrationOriginApplies(stats.calibratedEstimateOrigin, origin)) { + return { k: ringK, origin: stats.calibratedEstimateOrigin }; + } + const bias = stats.overflowBias; + // A band-clamped ratio means the stated count described ≥4× / ≤¼ of bili's + // own view of the payload — content bili does not hold (#3450's declared- + // 1M/stated-904k shape): steering folds off it would skip the burn-and-net + // that at least nets local savings out of the arm, so treat it as absent. + if (bias && bias.mismatched !== true && typeof bias.ratio === "number" && Number.isFinite(bias.ratio) && bias.ratio > 0 + && calibrationOriginApplies(bias.origin, origin) && calibrationModelCompatible(bias.model, lastModel)) { + return { k: Math.min(CALIBRATION_CLAMP_MAX, Math.max(CALIBRATION_CLAMP_MIN, bias.ratio)), origin: bias.origin }; + } + return { k: undefined, origin: undefined }; +} + export type WireProtocol = "anthropic" | "openai" | "responses" | "google"; /** diff --git a/tests/issue5373-overflow-bias.test.ts b/tests/issue5373-overflow-bias.test.ts new file mode 100644 index 0000000000..1db3e6b22d --- /dev/null +++ b/tests/issue5373-overflow-bias.test.ts @@ -0,0 +1,357 @@ +// #5373 regression — DSH lane, 0.1.209: the local estimator under-counts a +// CJK-heavy payload while the declared completion reserve eats part of the +// window, so folding to raw ≤ (window − reserve) still overflows on EVERY +// forward: permanent 400 loop after a resume-miss. The overflow body STATES +// the true counts ("you requested N tokens (M in the messages, K in the +// completion)"). Before the fix those stated counts only seeded the +// calibration ring behind its ≥2-agreeing-samples publication rule — a fresh +// overflow-only session whose conversation keeps growing never gets a factor +// in time (incident: four forwards, sidecar traffic able to clobber the +// single-slot ring, and an armed fold comparator unsatisfiable under a +// clamped arm burning the whole ladder every cycle). The fix retains a +// provisional per-route bias at arm time and lets effectiveCalibration apply +// it (trigger, fold stop, output clamp — including the armed comparator) +// until the ring supersedes. +// +// What this pins (hermetic, deterministic — no timing): +// A. the FIRST overflow retains the stated-calibre bias (ratio ≈ the real +// billing gap) WITHOUT publishing the ring (single-sample discipline); +// B. the NEXT grown turn is judged at stated calibre: its INITIAL forward +// already fits the window instead of re-hitting it (unfixed code +// forwards the raw-estimate payload, which bills over the window). +// The incident's full permanence additionally required interruptions +// (client disconnects mid-ladder, a stuck summarize holding the request for +// minutes) which a hermetic suite cannot reproduce without timing flakiness; +// see the issue thread. The mock bills ALL body strings at RATIO × the local +// estimator formula (the provider tokenizer gap); the rescue response +// carries NO usage (mainSeen gate) so the arm persists across turns on both +// branches — the regime the fix changes. + +import assert from "node:assert/strict"; +import http from "node:http"; +import { once } from "node:events"; +import test from "node:test"; +process.env.NODE_ENV = "test"; +import { defaultConfig } from "acp-kernel"; +import { startServer } from "../src/server.ts"; +import type { ProxyOptions } from "../src/config.ts"; +import { SessionStore, _setStoreForTest } from "../src/persist.ts"; +import { _setForTest as setRegistryForTest } from "../src/registry.ts"; +import { listSessions } from "../src/session.ts"; +import { + CALIBRATION_CLAMP_MAX, + CALIBRATION_CLAMP_MIN, + calibrationOriginApplies, + effectiveCalibration, +} from "../src/util.ts"; + +const WINDOW = 100_000; +const MAX_TOKENS = 20_000; +const TARGET = WINDOW - MAX_TOKENS; // 80,000 — the judging limit bili folds to +const RATIO = 1.3; // upstream billing multiplier vs the local estimate +const HISTORY_TURNS = 80; // ~61k est units of completed CJK turns (~67k incl. overhead) +const GROWTH = 10_000; // per-turn content growth in local estimator units (CJK chars) +// 1×1 PNG — decoded dims feed the pixels billing prior (85 + 170 tiles), +// giving the payload a small positive image reserve. +const PNG_1X1 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +const FILLER = "上下文压缩回归测试语料填充片段"; + +// Same formula family as the local estimator: CJK = 1 token/char, rest /4. +function estUnits(text: string): number { + let cjk = 0; + let rest = 0; + for (const ch of text) (/[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef]/.test(ch) ? cjk++ : rest++); + return cjk + Math.ceil(rest / 4); +} + +// Bills EVERY string in the body at RATIO × the local estimator formula — +// the same basis family as bili's own estimate (messages + system + tools + +// wire residue), scaled up by the provider's tokenizer gap (#5373's core +// asymmetry). Bili's estimate additionally carries overhead ON TOP of the +// string mass, so a calibrated fold-stop (raw×ratio < target) guarantees the +// billed total fits, while a raw-estimate stop overshoots. +function collectStrings(v: unknown, out: string[]): void { + if (typeof v === "string") { out.push(v); return; } + if (Array.isArray(v)) { for (const x of v) collectStrings(x, out); return; } + if (v && typeof v === "object") for (const x of Object.values(v as Record)) collectStrings(x, out); +} + +function billBody(body: unknown): number { + const strs: string[] = []; + collectStrings(body, strs); + return Math.round(strs.reduce((a, s) => a + estUnits(s), 0) * RATIO); +} + +type MockCall = { kind: "main" | "summary"; billed: number }; + +async function startMock(): Promise<{ port: number; close(): Promise; calls: MockCall[] }> { + const calls: MockCall[] = []; + let mainSeen = 0; + const srv = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + let body: unknown = {}; + try { body = JSON.parse(raw); } catch { /* unparseable body bills zero */ } + const b = body as Record; + const billed = billBody(b); + const mt = typeof b.max_tokens === "number" ? b.max_tokens : 0; + const isSummary = Array.isArray(b.messages) && b.messages.length === 1 && b.system !== undefined; + calls.push({ kind: isSummary ? "summary" : "main", billed }); + if (!isSummary && billed + mt > WINDOW) { + res.writeHead(400, { "content-type": "application/json" }); + res.end(JSON.stringify({ + error: { + type: "invalid_request_error", + code: "context_length_exceeded", + message: `This model's maximum context length is ${WINDOW} tokens. However, you requested ${billed + mt} tokens (${billed} in the messages, ${mt} in the completion). Please reduce the length of the inputs or output.`, + }, + })); + return; + } + mainSeen += !isSummary ? 1 : 0; + // Only the FIRST successful main response carries usage: the + // session's overflow arm therefore stays armed (no usage settle + // retires it) and the ring cannot complete from usage — the + // persistent-arm regime the fix must steer. + const withUsage = mainSeen <= 1; + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ + id: "msg_mock", + type: "message", + role: "assistant", + model: typeof b.model === "string" ? b.model : "claude-test", + content: [{ type: "text", text: isSummary + ? "历史对话摘要:多轮上下文压缩回归测试语料已压缩为要点,完整保留了每一轮的关键结论、编号、决定与引用关系,供后续轮次继续引用核对。" + : "好的,收到。" }], + stop_reason: "end_turn", + stop_sequence: null, + ...(withUsage ? { usage: { input_tokens: billed, output_tokens: 5 } } : {}), + })); + }); + }); + srv.listen(0, "127.0.0.1"); + await once(srv, "listening"); + const port = (srv.address() as { port: number }).port; + return { port, calls, close: async () => { await new Promise((r) => srv.close(() => r())); } }; +} + +// HISTORY_TURNS completed CJK turns plus a FINAL unpaired user question +// carrying the image — the live turn is never a fold candidate, so the image +// reserve survives every preflight walk. +function buildHistory(turns: number): Array> { + const msgs: Array> = []; + for (let i = 0; i < turns; i++) { + msgs.push({ role: "user", content: `第${i}轮提问:${FILLER.repeat(25)}` }); + msgs.push({ role: "assistant", content: `第${i}轮回答:${FILLER.repeat(25)}` }); + } + msgs.push({ + role: "user", + content: [ + { type: "text", text: `第${turns}轮提问(附图):${FILLER.repeat(5)}` }, + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG_1X1 } }, + ], + }); + return msgs; +} + +async function sendTurn(url: string, sid: string, model: string, messages: Array>): Promise<{ status: number; text: string }> { + const r = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json", "x-acp-session": sid }, + body: JSON.stringify({ model, max_tokens: MAX_TOKENS, stream: false, messages }), + }); + return { status: r.status, text: await r.text() }; +} + +function baseOptions(upPort: number): ProxyOptions { + return { + port: 0, + host: "127.0.0.1", + upstream: "http://127.0.0.1", + routes: { + [`http://127.0.0.1:${upPort}`]: { models: { "claude-test": { context: WINDOW } } }, + }, + modelContextLimit: WINDOW, + kernelConfig: defaultConfig(WINDOW), + compress: { injectTool: true, injectNudge: true }, + compat: { roles: {} }, + sessionHeader: "x-acp-session", + streamErrorShape: "protocol", + passthroughSource: null, + autoRestartOnUpdate: false, + advisoryCheck: false, + releaseNotesCheck: false, + promptCache: { routing: "auto" }, + log: false, + debug: false, + passthrough: false, + autoUpdate: false, + mitm: { enabled: false, domains: [] }, + } as ProxyOptions; +} + +async function boundPort(proxy: Awaited>): Promise { + const deadline = Date.now() + 10_000; + for (;;) { + const addr = proxy.address(); + if (addr && typeof addr === "object") return addr.port; + if (Date.now() > deadline) throw new Error("proxy never bound a port"); + await new Promise((r) => setTimeout(r, 25)); + } +} + +function statsNow(): Record { + return (listSessions()[0]?.stats ?? {}) as Record; +} + +async function stopAll(proxy: Awaited>, up: Awaited>): Promise { + proxy.close(); + proxy.closeIdleConnections?.(); + await once(proxy, "close"); + await up.close(); +} + +// --- unit: effectiveCalibration gates ------------------------------------- + +test("effectiveCalibration: ring-published k̂ wins when applicable to the route", () => { + const stats = { + calibratedEstimate: 1.2, + calibratedEstimateOrigin: "https://a.com", + overflowBias: { ratio: 1.9, origin: "https://a.com" }, + }; + assert.deepEqual(effectiveCalibration(stats, undefined, "https://a.com/v1/messages"), { k: 1.2, origin: "https://a.com" }); +}); + +test("effectiveCalibration: ring factor for another route falls through to the route's overflow bias", () => { + const stats = { + calibratedEstimate: 1.2, + calibratedEstimateOrigin: "https://a.com", + overflowBias: { ratio: 1.3, origin: "https://b.com" }, + }; + assert.deepEqual(effectiveCalibration(stats, undefined, "https://b.com/v1/messages"), { k: 1.3, origin: "https://b.com" }); +}); + +test("effectiveCalibration: no applicable evidence → absent (raw estimate)", () => { + const stats = { + calibratedEstimate: 1.2, + calibratedEstimateOrigin: "https://a.com", + overflowBias: { ratio: 1.3, origin: "https://b.com" }, + }; + assert.deepEqual(effectiveCalibration(stats, undefined, "https://c.com"), { k: undefined, origin: undefined }); + assert.deepEqual(effectiveCalibration({}, undefined, "https://a.com"), { k: undefined, origin: undefined }); +}); + +test("effectiveCalibration: bias model gate mirrors the ring rule", () => { + const bias = { ratio: 1.3, origin: "https://a.com", model: "m-main" }; + assert.equal(effectiveCalibration({ overflowBias: bias }, "m-other", "https://a.com").k, undefined); + assert.equal(effectiveCalibration({ overflowBias: bias }, "m-main", "https://a.com").k, 1.3); + assert.equal(effectiveCalibration({ overflowBias: bias }, undefined, "https://a.com").k, 1.3); +}); + +test("effectiveCalibration: factors are clamped into the learning band", () => { + const o = "https://a.com"; + assert.equal(effectiveCalibration({ overflowBias: { ratio: 9, origin: o } }, undefined, o).k, CALIBRATION_CLAMP_MAX); + assert.equal(effectiveCalibration({ overflowBias: { ratio: 0.05, origin: o } }, undefined, o).k, CALIBRATION_CLAMP_MIN); + for (const bad of [NaN, -1, Infinity]) { + assert.equal(effectiveCalibration({ overflowBias: { ratio: bad, origin: o } }, undefined, o).k, undefined); + } +}); + +test("effectiveCalibration: a band-clamped (view-mismatched) bias does not steer", () => { + // #3450 shape: upstream stated ≥4× bili's own view of the payload — the + // count describes content bili does not hold; steering folds off it would + // skip the burn-and-net master relied on. Treated as absent. + const o = "https://a.com"; + const mismatched = { ratio: CALIBRATION_CLAMP_MAX, origin: o, mismatched: true }; + assert.deepEqual(effectiveCalibration({ overflowBias: mismatched }, undefined, o), { k: undefined, origin: undefined }); + // A ring-published factor still wins over a mismatched bias (precedence unchanged). + assert.equal(effectiveCalibration({ calibratedEstimate: 1.3, calibratedEstimateOrigin: o, overflowBias: mismatched }, undefined, o).k, 1.3); + // The same numeric ratio WITHOUT the flag steers (the flag is the writer's verdict). + assert.equal(effectiveCalibration({ overflowBias: { ratio: CALIBRATION_CLAMP_MAX, origin: o } }, undefined, o).k, CALIBRATION_CLAMP_MAX); +}); + +test("calibrationOriginApplies: normalized equality only", () => { + assert.ok(calibrationOriginApplies("https://a.com", "https://a.com/v1/messages")); + assert.ok(!calibrationOriginApplies("https://a.com", "https://b.com")); + assert.ok(!calibrationOriginApplies(undefined, "https://a.com")); + assert.ok(!calibrationOriginApplies("https://a.com", undefined)); +}); + +// --- e2e: the real pipeline through DeepSeek-shaped overflows ------------- + +test("#5373 mechanism: the first overflow retains a stated-calibre bias without publishing the ring", { timeout: 240_000 }, async () => { + const up = await startMock(); + const proxy = await startServer(baseOptions(up.port)); + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const port = await boundPort(proxy); + try { + const url = `http://127.0.0.1:${port}/bili/http://127.0.0.1:${up.port}/v1/messages`; + const r = await sendTurn(url, "s5373-mech", "claude-test", buildHistory(HISTORY_TURNS)); + assert.equal(r.status, 400, `first forward must overflow (test precondition), got ${r.status}: ${r.text.slice(0, 200)}`); + const st = statsNow(); + assert.equal(st.lastInputTokensSource, "overflow-arm", "arm not recorded after first overflow"); + const bias = st.overflowBias as { ratio: number; origin: string; model?: string; measured: number; estimate: number } | undefined; + assert.ok(bias, "overflow-stated bias was not retained at arm time"); + assert.ok(bias.ratio > 1.1 && bias.ratio < 1.5, `bias ratio ${bias.ratio} not near RATIO ${RATIO}`); + assert.equal(bias.model, "claude-test"); + assert.ok(bias.measured > 0 && bias.estimate >= 2000, `bias evidence malformed: ${JSON.stringify(bias)}`); + const ring = st.calibrationRing as { values: number[] } | undefined; + assert.ok(ring && ring.values.length === 1, `ring must hold exactly the one overflow sample, got ${JSON.stringify(ring)}`); + assert.equal(st.calibratedEstimate, undefined, "single sample must not publish the ring yet"); + } finally { + await stopAll(proxy, up); + } +}); + +test("#5373 regression: the grown turn after an overflow fits on its initial forward", { timeout: 300_000 }, async () => { + const up = await startMock(); + const proxy = await startServer(baseOptions(up.port)); + _setStoreForTest(new SessionStore({ enabled: false })); + setRegistryForTest({}); + const port = await boundPort(proxy); + try { + const url = `http://127.0.0.1:${port}/bili/http://127.0.0.1:${up.port}/v1/messages`; + const sid = "s5373-steer"; + const messages = buildHistory(HISTORY_TURNS); + const t1 = await sendTurn(url, sid, "claude-test", messages); + assert.equal(t1.status, 400, `first forward must overflow (test precondition), got ${t1.status}: ${t1.text.slice(0, 200)}`); + const st1 = statsNow(); + assert.equal(st1.lastInputTokensSource, "overflow-arm", "first overflow must arm the session (persistent-arm precondition)"); + const bias1 = st1.overflowBias as { ratio: number } | undefined; + assert.ok(bias1 && bias1.ratio > 1.1, `turn-1 bias missing (stated counts never retained): ${JSON.stringify(st1.overflowBias)}`); + + // The conversation keeps growing (dsh appends new user content every + // turn; incident req C→D grew ~50k est). Unfixed code judges this + // turn at raw-estimate calibre: the floor sits a hair below budget + // (arm headroom clamp) and the raw channel is below it too, so no + // preflight fires and the INITIAL forward bills over the window. + // Fixed code judges it at stated calibre (raw × bias ≥ budget) and + // folds before forwarding, so the initial forward already fits. + const mainsBefore = up.calls.filter((c) => c.kind === "main").length; + const sumsBefore = up.calls.filter((c) => c.kind === "summary").length; + messages.push({ role: "user", content: `[g2] ${FILLER.repeat(GROWTH / 15)}` }); + const t2 = await sendTurn(url, sid, "claude-test", messages); + const mainsT2 = up.calls.filter((c) => c.kind === "main").slice(mainsBefore); + const sumsT2 = up.calls.filter((c) => c.kind === "summary").length - sumsBefore; + console.error(`# STEER TRACE: T2=${t2.status} mains=${JSON.stringify(mainsT2.map((c) => c.billed))} summarizeCalls=${sumsT2}`); + assert.equal(t2.status, 200, `grown turn must recover, got ${t2.status}: ${t2.text.slice(0, 200)}`); + for (const m of mainsT2) { + assert.ok(m.billed <= TARGET, + `a grown-turn forward billed ${m.billed} (> judging limit ${TARGET}): the stated counts did not steer the fold target (#5373)`); + } + assert.ok(sumsT2 <= 4, `grown turn burned ${sumsT2} summarize calls: fold work must stay bounded under the arm`); + + // Shared invariant (both branches): by the second turn the learning + // loop has completed — a factor is published and the arm retired by + // the usage settle. + const st2 = statsNow(); + const k2 = st2.calibratedEstimate as number | undefined; + assert.ok(k2 !== undefined && k2 > 1.1 && k2 < 1.45, `expected a published factor after two overflows, got ${JSON.stringify(k2)} (ring=${JSON.stringify(st2.calibrationRing)})`); + assert.equal(st2.lastInputTokensSource, "usage", "successful usage must retire the overflow arm"); + } finally { + await stopAll(proxy, up); + } +}); From 2409888faf2aa57829b1f52d0ebf1127b0d06b6a Mon Sep 17 00:00:00 2001 From: ci-check Date: Wed, 11 Nov 2026 18:29:06 +0800 Subject: [PATCH 2/2] fix: steer provisional overflow bias only for clamped arms (#1195 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unclamped overflow arm (measured count already above the stated ceiling — declared window wider than the provider's real one) keeps master's raw-scale fit judgments in preflight. Applying the provisional bias there declares an unfoldable payload over-limit; the walk books a non-materializable emergency-truncate credit and the #1987 anchor then clobbers the upstream-measured arm baseline (source flips to 'estimate'), breaking the #1195 B passthrough contract. The armed-comparator carve-out gets the same clamped-arm restriction so a ring-published factor cannot take an unclamped arm off its floored path either. --- src/preflight.ts | 36 +++++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/preflight.ts b/src/preflight.ts index 97bd01abdc..fdc032a315 100644 --- a/src/preflight.ts +++ b/src/preflight.ts @@ -26,7 +26,7 @@ import { epochConfirmPending } from "./fold-reconcile.js"; import { deadSkipKeys, filterDeadRanges, parseStructuralEmptyVerdict, recordDeadRange } from "./dead-ranges.js"; import { peekRegistryOutputLimit } from "./registry.js"; import { safePrefix } from "./text-safe.js"; -import { applyEstimateCalibration, calibrationOriginApplies, effectiveCalibration } from "./util.js"; +import { applyEstimateCalibration, calibrationOriginApplies, currentCalibrationFactor, effectiveCalibration } from "./util.js"; import { configuredSummaryPlan, type ConfiguredSummaryPlan } from "./external-summary-runtime.js"; import { messageTimestampSections } from "./external-summary-surface.js"; import { isCodexClient } from "./codex-compact.js"; @@ -1774,7 +1774,26 @@ export async function preflightCompress(deps: PreflightDeps, messages: CoreMessa // bias when the ring has nothing applicable to this route, so an // overflow-only session folds on the upstream-stated scale from the first // rejection onward. - const calibration = effectiveCalibration(deps.session.stats, deps.session.metadata?.lastModel, deps.upstreamOrigin); + let calibration = effectiveCalibration(deps.session.stats, deps.session.metadata?.lastModel, deps.upstreamOrigin); + // #1195 B: an UNCLAMPED overflow arm — the measured count already exceeds + // the stated ceiling (declared window wider than the provider's real one) — + // keeps master's raw-scale fit judgments. The provisional bias there declares + // an unfoldable payload over-limit, the walk books a non-materializable + // emergency-truncate credit, and the #1987 anchor then clobbers the + // upstream-measured arm baseline (source flips to "estimate"). Only the + // ring-published factor (master's established behavior) may steer such a + // session; clamped arms and post-retire sessions steer at stated calibre. + { + const s = deps.session.stats; + const unclampedArm = s.lastInputTokensSource === "overflow-arm" + && !(s.overflowArmTokens !== undefined && s.lastInputTokens < s.overflowArmTokens); + if (unclampedArm) { + const ringK = currentCalibrationFactor(s, deps.session.metadata?.lastModel); + calibration = ringK !== undefined && calibrationOriginApplies(s.calibratedEstimateOrigin, deps.upstreamOrigin) + ? { k: ringK, origin: s.calibratedEstimateOrigin } + : { k: undefined, origin: undefined }; + } + } const kFactor = calibration.k; const kOrigin = calibration.origin; // #3645: kernel range.tokens are the UNCALIBRATED count, but @@ -2288,7 +2307,18 @@ export async function preflightCompress(deps: PreflightDeps, messages: CoreMessa // calibrated clause strictly weaker than the raw one, so behavior only // relaxes in the direction the measurement says is safe. No factor → the // legacy floored path stays byte-identical. - const armedFactorApplies = kFactor !== undefined && calibrationOriginApplies(kOrigin, deps.upstreamOrigin); + // Clamped-arm restriction (#1195 B regression): the carve-out exists for + // CLAMPED arms — the baseline capped by the judging limit below the stated + // ceiling (overflowArmTokens) — where the floored comparator is provably + // unsatisfiable. An unclamped arm whose measured count already exceeds the + // stated ceiling (declared window wider than the provider's real one) has + // no foldable range when the payload is unfoldable; the floorless path + // there books a kernel emergency-truncate credit the rebuild cannot + // materialize, and the #1987 anchor then clobbers the upstream-measured + // arm baseline. Unclamped arms keep the legacy floored passthrough. + const armClamped = deps.session.stats.overflowArmTokens !== undefined + && deps.session.stats.lastInputTokens < deps.session.stats.overflowArmTokens; + const armedFactorApplies = armClamped && kFactor !== undefined && calibrationOriginApplies(kOrigin, deps.upstreamOrigin); const armedBaseline = deps.session.stats.lastInputTokensSource === "overflow-arm"; if (armedBaseline && !armedFactorApplies ? decisionTokens < textTarget : roundText < textTarget) { // #4288: the walk ends in a fit-forward — the caller's gate judges