diff --git a/CONFIGURATION.md b/CONFIGURATION.md index a09f5f8d..946da782 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -421,7 +421,7 @@ Environment variables take precedence over the config file. They are useful for | `ACP_LOG` | Set to `0` to disable request logging. | | `ACP_AUTO_UPDATE` | Set to `0` to disable auto-update checks. | | `ACP_UPDATE_TAG` | Dist-tag channel the auto-updater follows (default `latest`, e.g. `dev`). File-config key: `updateTag`. A `pr-N` preview tag is only followed when explicitly configured. | -| `BILI_HOST_USAGE_CREDIT` | `#408` host-usage backfill mode (file-config key: `hostUsageCredit`). `auto` (default) = the uncompressed-baseline backfill is armed for plain proxy clients (the bili-launched pi/omp extensions are exempted — their host-side compaction is cancelled, so the baseline drives nothing there). `off` = never backfill — the usage reported to the host is the actually-forwarded (folded) request, matching `[acp-usage] input=`. Use `off` for plain anthropic proxy clients (e.g. ZCode) whose UI would otherwise show the cumulative, drifting baseline as inflated context (#648). | +| ~~`BILI_HOST_USAGE_CREDIT`~~ / ~~`hostUsageCredit`~~ | **Removed in #660.** Used to select the host-facing usage mode. The #408 uncompressed-baseline backfill is gone entirely — every host now reports the actually-forwarded (post-fold) request as provider-measured (matches `[acp-usage] input=`). Old values left in env or the config file are ignored; remove them. See the "Bug history lesson" section of PR #691. | | `ACP_PROVIDERS` | Path to an external `providers.json` (legacy / shared file). | | `BILI_REPLAY_RETRY_BASE_MS` | Base backoff delay (ms) for acp-loop replay retries after a transient upstream rejection (default `1500`; set `0` to disable the delay). See #189. | | `BILI_REPLAY_RETRY_MAX` | Total attempts for acp-loop replay retries (default `3`; set `1` to disable retries entirely — legacy fail-fast behavior). See #189. | diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index b31421c3..1cfced12 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -419,6 +419,7 @@ | `ACP_LOG` | 设为 `0` 关闭请求日志。 | | `ACP_AUTO_UPDATE` | 设为 `0` 禁用自动更新检查。 | | `ACP_UPDATE_TAG` | 自动更新跟随的 dist-tag 通道(默认 `latest`,如 `dev`)。文件配置键:`updateTag`。`pr-N` 预览 tag 仅在显式配置时才会被跟随。 | +| ~~`BILI_HOST_USAGE_CREDIT`~~ / ~~`hostUsageCredit`~~ | **#660 已移除。** 曾用于选择宿主可见的用量模式。#408 的未折叠基线回补(backfill)已整体删除 —— 所有宿主现在统一上报“实际转发(后折叠)请求”的 provider 实测用量,与 `[acp-usage] input=` 一致。遗留该环境变量 / 配置键的旧值会被忽略,请删除。教训详见 PR #691 的 “Bug 历史教训” 一节。 | | `ACP_PROVIDERS` | 指向外部 `providers.json` 的路径(旧版 / 共享文件)。 | | `BILI_REPLAY_RETRY_BASE_MS` | acp-loop 回放重试的基础退避延迟(毫秒):上游瞬时拒绝后重试(默认 `1500`;设 `0` 关闭延迟)。见 #189。 | | `BILI_REPLAY_RETRY_MAX` | acp-loop 回放重试的总次数(默认 `3`;设 `1` 彻底关闭重试 —— 旧版 fail-fast 行为)。见 #189。 | diff --git a/src/config.ts b/src/config.ts index e2c6e2a5..670ea1c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -316,15 +316,6 @@ export type ProxyOptions = { autoUpdate: boolean; /** Dist-tag channel the auto-updater follows (default "latest"). */ updateTag: string; - /** #408 host-usage backfill mode. "auto" (default) = the uncompressed- - * baseline backfill is armed for plain proxy clients (the bili-launched - * pi/omp extensions are exempted — their host compaction is cancelled, so - * the baseline drives nothing on the host side). "off" = never backfill — - * the usage reported to the host is the actually-forwarded (folded) - * request, matching [acp-usage] input= (#648: plain anthropic proxy - * clients like ZCode otherwise show a cumulative, drifting baseline that - * overstates real context pressure). */ - hostUsageCredit: "auto" | "off"; logFile?: string; /** MITM transparent-proxy mode. When enabled, an HTTP CONNECT handler is * attached so clients that only know how to set HTTP_PROXY (ZCode with a @@ -462,7 +453,6 @@ export function loadOptions(env: NodeJS.ProcessEnv = process.env): ProxyOptions passthrough: passthrough.enabled, passthroughSource: passthrough.source, autoUpdate: (env.ACP_AUTO_UPDATE ?? (fileConfig.autoUpdate === false ? "0" : "1")) !== "0", - hostUsageCredit: parseHostUsageCredit(env.BILI_HOST_USAGE_CREDIT ?? fileConfig.hostUsageCredit), updateTag: (env.ACP_UPDATE_TAG ?? fileConfig.updateTag ?? "latest").trim() || "latest", logFile: env.ACP_LOG_FILE !== undefined ? (env.ACP_LOG_FILE || undefined) : fileConfig.logFile, mitm: { @@ -497,7 +487,6 @@ type FileConfig = { autoUpdate?: boolean; /** Dist-tag channel the auto-updater follows (default "latest"). */ updateTag?: string; - hostUsageCredit?: "auto" | "off"; upstreamProxy?: string; upstreamProxyMode?: string; logFile?: string; @@ -609,10 +598,6 @@ export function parseUpstreamProxyMode(value: string | undefined): UpstreamProxy return value === "manual" || value === "auto" ? value : "direct"; } -export function parseHostUsageCredit(value: string | undefined): "auto" | "off" { - return value === "off" ? "off" : "auto"; -} - export function parseCompressSettings(v: unknown): (CompressSettings & { injectTool?: boolean; injectNudge?: boolean }) | undefined { if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; const obj = v as Record; diff --git a/src/exit-matrix.ts b/src/exit-matrix.ts index 42eddbaf..114b7f7e 100644 --- a/src/exit-matrix.ts +++ b/src/exit-matrix.ts @@ -1,5 +1,4 @@ import { emitStreamError, emitPreflightError } from "./stream-error.js"; -import { backfillHostUsage } from "./util.js"; import { pipePluginChatWithStrip, pipePluginResponsesWithStrip, pipePluginJson } from "./plugin.js"; import { startServer } from "./server.js"; @@ -66,7 +65,7 @@ export const ERROR_DELIVERY: Record = { "plugin-json": { implementer: [pipePluginJson], contract: "upstream non-2xx/fetch failure → `{error: formatUpstreamError}` JSON; client abort mid-body → clean end, timer cleared", - coveredBy: ["tests/issue411-abort-usage.test.ts", "tests/host-usage-backfill.test.ts"], + coveredBy: ["tests/issue411-abort-usage.test.ts", "tests/host-usage-postfold.test.ts"], }, }; @@ -108,48 +107,48 @@ export const ABORT_PROPAGATION: Record = { }, }; -export const HOST_USAGE_BACKFILL: Record = { +export const HOST_USAGE_PASSTHROUGH: Record = { "proxy-openai-sse": { - implementer: [backfillHostUsage], - contract: "usage frame from the upstream terminal event is credited to the session (input/cached/output), fallback samples on truncation", - coveredBy: ["tests/host-usage-backfill.test.ts"], + implementer: [startServer], + contract: "provider-measured (post-fold) usage credited to the internal ledger and forwarded to the host verbatim — no baseline backfill (#660)", + coveredBy: ["tests/host-usage-postfold.test.ts"], }, "proxy-anthropic-sse": { - implementer: [backfillHostUsage], - contract: "message_start/message_delta usage frames credited", - coveredBy: ["tests/host-usage-backfill.test.ts"], + implementer: [startServer], + contract: "message_start/message_delta usage frames credited, forwarded verbatim", + coveredBy: ["tests/host-usage-postfold.test.ts"], }, "proxy-responses-sse": { - implementer: [backfillHostUsage], - contract: "response.completed usage frame credited (issue #589 frame shape)", - coveredBy: ["tests/issue589-usage-frame.test.ts", "tests/host-usage-backfill.test.ts"], + implementer: [startServer], + contract: "response.completed usage frame credited (issue #589 frame shape), forwarded verbatim", + coveredBy: ["tests/issue589-usage-frame.test.ts", "tests/host-usage-postfold.test.ts"], }, "proxy-json": { - implementer: [backfillHostUsage], - contract: "non-stream usage object credited; no session → skipped (title-gen must not clobber lastInputTokens)", - coveredBy: ["tests/host-usage-backfill.test.ts"], + implementer: [startServer], + contract: "non-stream usage object credited; no session → skipped (title-gen must not clobber lastInputTokens); forwarded verbatim", + coveredBy: ["tests/host-usage-postfold.test.ts"], }, "plugin-chat-sse": { - implementer: [pipePluginChatWithStrip, backfillHostUsage], - contract: "sniffed usage credited unless the call is session-less (#460 title-gen skip)", - coveredBy: ["tests/host-usage-backfill.test.ts", "tests/issue411-abort-usage.test.ts"], + implementer: [pipePluginChatWithStrip], + contract: "sniffed usage credited unless the call is session-less (#460 title-gen skip); stream bytes untouched", + coveredBy: ["tests/host-usage-postfold.test.ts", "tests/issue411-abort-usage.test.ts"], }, "plugin-responses-sse": { - implementer: [pipePluginResponsesWithStrip, backfillHostUsage], + implementer: [pipePluginResponsesWithStrip], contract: "sniffed usage credited; verbatim variant skips accounting by design", - coveredBy: ["tests/host-usage-backfill.test.ts"], + coveredBy: ["tests/host-usage-postfold.test.ts"], }, "plugin-json": { - implementer: [pipePluginJson, backfillHostUsage], - contract: "JSON usage object credited", - coveredBy: ["tests/host-usage-backfill.test.ts"], + implementer: [pipePluginJson], + contract: "JSON usage object credited; response body forwarded verbatim", + coveredBy: ["tests/host-usage-postfold.test.ts"], }, }; export const EXIT_CONCERNS = { errorDelivery: ERROR_DELIVERY, abortPropagation: ABORT_PROPAGATION, - hostUsageBackfill: HOST_USAGE_BACKFILL, + hostUsagePassthrough: HOST_USAGE_PASSTHROUGH, } as const; export type ExitConcernId = keyof typeof EXIT_CONCERNS; diff --git a/src/loop/adapter-anthropic.ts b/src/loop/adapter-anthropic.ts index dd13f09a..a813d04b 100644 --- a/src/loop/adapter-anthropic.ts +++ b/src/loop/adapter-anthropic.ts @@ -118,7 +118,7 @@ function buildTextDeltaEvent(index: number, text: string): Buffer { ); } -export function createAnthropicAdapter(requestBody: Record, originalSystem?: AnthropicRequestBody["system"], hostCredit = 0): CompressLoopAdapter { +export function createAnthropicAdapter(requestBody: Record, originalSystem?: AnthropicRequestBody["system"]): CompressLoopAdapter { const model = (requestBody.model as string) ?? undefined; let messageId: string | undefined; let clientIndex = 0; @@ -248,26 +248,10 @@ export function createAnthropicAdapter(requestBody: Record, ori if (typeof u.input_tokens === "number") roundInput = u.input_tokens; if (typeof u.cache_read_input_tokens === "number") roundCached = u.cache_read_input_tokens; if (round === 1) { - // #408: this raw message_start (post-fold input_tokens) - // reaches the host verbatim — add the prepare-time - // credit back so the host anchors on the uncompressed - // baseline. - let chunk = rawBuf; - if (hostCredit > 0 && typeof u.input_tokens === "number") { - const patched = structuredClone(data); - const pmsg = patched["message"] as Record | undefined; - const pu = (pmsg?.["usage"] ?? {}) as Record; - if (typeof pu.input_tokens === "number") { - pu.input_tokens += hostCredit; - const out = eventStr - .split("\n") - .map((l) => (l.startsWith("data:") ? `data: ${JSON.stringify(patched)}` : l)) - .join("\n"); - chunk = Buffer.from(out + "\n\n", "utf8"); - } - } + // The raw message_start (with the provider's measured + // usage) reaches the host verbatim — no rewriting. messageStartForwarded = true; - yield { kind: "meta", chunk, firstRoundOnly: true } as ParsedStreamEvent; + yield { kind: "meta", chunk: rawBuf, firstRoundOnly: true } as ParsedStreamEvent; } } else if (type === "ping") { yield { kind: "meta", chunk: rawBuf } as ParsedStreamEvent; diff --git a/src/loop/adapter-openai.ts b/src/loop/adapter-openai.ts index 8f5f60da..13b2d788 100644 --- a/src/loop/adapter-openai.ts +++ b/src/loop/adapter-openai.ts @@ -116,28 +116,7 @@ function stripFinishReasonChunk(buf: Buffer): Buffer { } } -function patchUsageChunk(eventStr: string, parsed: Record, u: Record, hostCredit: number): Buffer { - const pu = typeof u.prompt_tokens === "number" ? u.prompt_tokens : undefined; - const tu = typeof u.total_tokens === "number" ? u.total_tokens : undefined; - if (hostCredit > 0 && (pu !== undefined || tu !== undefined)) { - const patched = { - ...parsed, - usage: { - ...u, - ...(pu !== undefined ? { prompt_tokens: pu + hostCredit } : {}), - ...(tu !== undefined ? { total_tokens: tu + hostCredit } : {}), - }, - }; - const out = eventStr - .split("\n") - .map((l) => (l.startsWith("data:") ? `data: ${JSON.stringify(patched)}` : l)) - .join("\n"); - return Buffer.from(out + "\n\n", "utf8"); - } - return Buffer.from(eventStr + "\n\n", "utf8"); -} - -export function createOpenaiAdapter(requestBody: Record, clientSystem?: string, hostCredit = 0, absorbName?: string): CompressLoopAdapter { +export function createOpenaiAdapter(requestBody: Record, clientSystem?: string, absorbName?: string): CompressLoopAdapter { const model = (requestBody.model as string) ?? "unknown"; let responseId = `chatcmpl-proxy-${Date.now()}`; let toolIndex = 0; @@ -352,10 +331,10 @@ export function createOpenaiAdapter(requestBody: Record, client } as ParsedStreamEvent; // #589: include_usage clients (dsh, OpenAI SDK) read usage // from this trailing empty-choices frame; raw tool-call rounds - // must forward it (with the prepare-time credit), not swallow - // it into the internal ledger. + // must forward it verbatim, not swallow it into the internal + // ledger. if (sawRealToolCall) { - yield { kind: "meta", chunk: patchUsageChunk(eventStr, parsed, u, hostCredit) } as ParsedStreamEvent; + yield { kind: "meta", chunk: rawBuf } as ParsedStreamEvent; } } continue; @@ -376,11 +355,9 @@ export function createOpenaiAdapter(requestBody: Record, client cachedTokens: typeof pd?.cached_tokens === "number" ? pd.cached_tokens : undefined, } as ParsedStreamEvent; if (sawRealToolCall) { - // #408: this raw finish chunk (with the provider's - // post-fold usage) reaches the host verbatim — add the - // prepare-time credit back so the host anchors on the - // uncompressed baseline. - const chunk = patchUsageChunk(eventStr, parsed, u ?? {}, hostCredit); + // The raw finish chunk (provider-measured usage) reaches + // the host verbatim — no rewriting. + const chunk = rawBuf; // This verbatim chunk IS the round's authoritative completion // (suppressCompletion); write it once and never fall through // to the text/reasoning branches (which would re-emit the same diff --git a/src/loop/core.ts b/src/loop/core.ts index fab8c27d..f65f45db 100644 --- a/src/loop/core.ts +++ b/src/loop/core.ts @@ -177,9 +177,6 @@ function recordUsage( // re-sends the unfolded history, so its usage report over-reports the // context the NEXT request will actually carry (see stream.ts applyRanges). ctx.session.stats.lastInputTokens = Math.max(0, total - (ctx.session.stats.compressCreditTokens ?? 0)); - // #408: remember the input-side total reported to the host AFTER the - // prepare-time fold backfill (uncompressed baseline), for the /acp panel. - ctx.session.hostContextTokens = total + (ctx.session.hostCreditTokens ?? 0); if (typeof cached === "number") { ctx.session.stats.cachedTokens += cached; ctx.session.stats.cacheSamples += 1; @@ -362,16 +359,6 @@ export async function* runCompressLoop( ) { recordUsage(ctx, usage, round); } - // #408: the provider measured the FOLDED (post-compress) view; add - // the prepare-time credit back so the completion event the host - // anchors on reports the uncompressed baseline. recordUsage above - // already ran on the un-backfilled numbers (internal ledger stays - // post-fold). - const hostCredit = ctx.session.hostCreditTokens ?? 0; - if (hostCredit > 0 && typeof usage.inputTokens === "number") { - usage.inputTokens += hostCredit; - } - let resolvedText = assistantText; let allCalls = calls; if (ctx.textProtocol && assistantText.length > 0 && adapter.extractTextTriggers) { diff --git a/src/loop/index.ts b/src/loop/index.ts index f41cf473..c8af79e2 100644 --- a/src/loop/index.ts +++ b/src/loop/index.ts @@ -25,11 +25,10 @@ export function pickAdapter( responsesProjection?: ResponsesProjection, anthropicSystem?: AnthropicRequestBody["system"], openaiSystem?: string, - hostCredit = 0, absorbName?: string, ): CompressLoopAdapter { if (protocol === "responses") return createResponsesAdapter(textProtocol, responsesProjection, absorbName); - if (protocol === "openai") return createOpenaiAdapter(requestBody, openaiSystem, hostCredit, absorbName); - if (protocol === "anthropic") return createAnthropicAdapter(requestBody, anthropicSystem, hostCredit); + if (protocol === "openai") return createOpenaiAdapter(requestBody, openaiSystem, absorbName); + if (protocol === "anthropic") return createAnthropicAdapter(requestBody, anthropicSystem); throw new Error(`[acp-loop] unknown protocol: ${protocol}`); } diff --git a/src/plugin.ts b/src/plugin.ts index 3b357ea0..87e2d823 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -13,7 +13,7 @@ import { log as loggerLog } from "./logger.js"; import { degenerateTurnWarning } from "./degenerate-turn.js"; import { noteWeakOverflow } from "./weak-overflow.js"; import { warnCacheCollapse } from "./cache-warn.js"; -import { backfillHostUsage, promptInputTotal, type WireProtocol } from "./util.js"; +import { promptInputTotal, type WireProtocol } from "./util.js"; import { stateDir } from "./paths.js"; // The proxy's own version, read from package.json at runtime (works in both dev @@ -449,7 +449,7 @@ export function handlePluginStatus(conversationId: string, res: import("node:htt const systemPromptTokens = typeof sysTokRaw === "number" && Number.isFinite(sysTokRaw) && sysTokRaw > 0 ? sysTokRaw : 0; panel = buildStatusPanel({ version: `billion-context@${PROXY_VERSION}`, - tokenCount: session.hostContextTokens ?? session.stats.lastInputTokens, + tokenCount: session.stats.lastInputTokens, systemPromptTokens, state: session.state, nudge, @@ -469,8 +469,7 @@ export function handlePluginStatus(conversationId: string, res: import("node:htt label: session.meta.label ?? null, pluginAgent: session.metadata.pluginAgent ?? null, contextLimit: typeof limit === "number" ? limit : null, - contextTokens: session.hostContextTokens ?? session.stats.lastInputTokens, - hostCredit: session.hostCreditTokens ?? 0, + contextTokens: session.stats.lastInputTokens, inputTokens: session.stats.inputTokens, outputTokens: session.stats.outputTokens, cachedTokens: session.stats.cachedTokens, @@ -626,14 +625,12 @@ export function applyUsageSample(session: Session, sample: UsageSample, protocol // compress tool results shrink the next request, not this report. session.stats.lastInputTokens = Math.max(0, total - (session.stats.compressCreditTokens ?? 0)); warnCacheCollapse(session, total, sample.cachedTokens ?? 0); - // #408: host-facing baseline = this report + prepare-time fold credit. - session.hostContextTokens = total + (session.hostCreditTokens ?? 0); // #695: per-request parity with the wire path's [acp-usage] — without // this, post-fold cache cliffs cannot be attributed from logs. const hit = sample.cachedTokens === undefined || total <= 0 ? undefined : Math.round((100 * (sample.cachedTokens ?? 0)) / total); const foldNew = session.stats.pendingFoldUsage === true; if (foldNew) session.stats.pendingFoldUsage = false; - loggerLog("info", `[${session.id}] [plugin] [acp-usage] input=${total} cached=${sample.cachedTokens ?? "n/a"}${hit === undefined ? "" : ` (cache hit ${hit}%)`} ctx=${session.hostContextTokens}${foldNew ? " fold=new" : ""}`); + loggerLog("info", `[${session.id}] [plugin] [acp-usage] input=${total} cached=${sample.cachedTokens ?? "n/a"}${hit === undefined ? "" : ` (cache hit ${hit}%)`}${foldNew ? " fold=new" : ""}`); } if (sample.outputTokens !== undefined) session.stats.outputTokens += sample.outputTokens; } @@ -674,7 +671,6 @@ export async function pipePluginChatWithStrip( const decoder = new TextDecoder("utf-8"); let buf = ""; const acc: UsageSample = {}; - const credit = session?.hostCreditTokens ?? 0; const onDrop = (snippet: string) => { loggerLog("warn", `[tag-echo] stripped model-emitted render tag (plugin passthrough): ${snippet.slice(0, 80).replace(/\n/g, " ")}`); log?.(`[tag-echo] stripped model-emitted render tag from plugin passthrough text`); @@ -898,23 +894,8 @@ export async function pipePluginChatWithStrip( // the uncompressed baseline. Patch `ev` BEFORE the tag-echo // processors run and only rebuild when they return the event // verbatim — otherwise their render-tag stripping is lost. - // message_delta input_tokens is normally absent (or a 0 echo) - // — only patch a real echo. - let backfilled = false; - if (credit > 0 && protocol) { - const usage = - ev["type"] === "message_start" - ? ((ev["message"] as Record | undefined)?.["usage"] as Record | undefined) - : (ev["usage"] as Record | undefined); - const deltaEcho = ev["type"] === "message_delta" && (num(usage?.["input_tokens"]) ?? 0) <= 0; - if (usage && !deltaEcho && backfillHostUsage(protocol, usage, credit)) backfilled = true; - } const out = protocol === "anthropic" ? processAnthropic(ev, rawEvent) : processOpenai(ev, rawEvent); - if (backfilled && out === rawEvent + "\n\n") { - await write(rebuildEvent(rawEvent, ev)); - } else if (out.length > 0) { - await write(out); - } + if (out.length > 0) await write(out); } } if (res.destroyed || res.writableEnded) break; @@ -1092,17 +1073,6 @@ export async function pipePluginResponsesWithStrip( let evOut = ev; let rebuild = containsRenderTagText(jsonStr); if (rebuild) evOut = stripResponsesText(ev); - if (type === "response.completed") { - // #408: acc already holds the pre-backfill sample - // (usageFromSseEvent ran above) — the internal - // ledger stays post-fold, the host gets the - // uncompressed baseline. - const credit = session?.hostCreditTokens ?? 0; - const usage = (evOut["response"] as Record | undefined)?.["usage"] as Record | undefined; - if (credit > 0 && usage && backfillHostUsage("responses", usage, credit)) { - rebuild = true; - } - } const out = rebuild ? rebuildEvent(rawEvent, evOut) : rawEvent + "\n\n"; await write(flushTail(out)); continue; @@ -1217,11 +1187,6 @@ export async function pipePluginJson( num(usage["cache_read_input_tokens"]), }, protocol); markDirty(session); - // #408: backfill mutates json.usage in place — reserialize below. - const credit = session.hostCreditTokens ?? 0; - if (credit > 0 && protocol && backfillHostUsage(protocol, usage, credit)) { - mutated = true; - } } } } catch { /* non-JSON body — forward verbatim */ } diff --git a/src/server.ts b/src/server.ts index 941657c6..f770b697 100644 --- a/src/server.ts +++ b/src/server.ts @@ -73,7 +73,7 @@ import { flushPrefixAffinity, hydratePrefixAffinity, scheduleAffinityPersist } f import { consumePluginRegisterFor, flushConversations, handlePluginCompact, handlePluginManifest, handlePluginRegister, handlePluginStatus, handlePluginTool, loadConversations, pipePluginChatWithStrip, pipePluginJson, pipePluginResponsesWithStrip, pluginAgentHeader, pluginConversationHeader, pluginReportedContextWindow, recordPluginSession, rememberPluginMessages, takePendingPluginRegister } from "./plugin.js"; import { setupMitm, readMitmUpstream } from "./mitm.js"; import type { BiliMessage } from "acp-kernel/wire"; -import { backfillHostUsage, isLoopbackAddress, inspectContextOverflow, reserveOutputHeadroom, shouldReserveOutputHeadroom, systemToUser, usageTotals, type WireProtocol } from "./util.js"; +import { isLoopbackAddress, inspectContextOverflow, reserveOutputHeadroom, shouldReserveOutputHeadroom, systemToUser, usageTotals, type WireProtocol } from "./util.js"; import { resolveConfirmedLimit, resolveLearnedLimit, resolveSpeculativeLimit, retractStaleLearnedLimits } from "./weak-overflow.js"; import { BILI_TUNNEL_HEADER, checkTunnelDestination, tunnelAllowlistFromEnv } from "./tunnel-guard.js"; @@ -1916,54 +1916,6 @@ function diagNudge(turn: { nudge?: { shouldInject: boolean; reason: string; cont return `[${sessionId}] nudge ${inject}: usage=${pct} (${tokenCount}/${limit}), growth=${growth}/${floor} (ref=${ref}, interval=${interval}), pendingT1=${pendingT1}/${interval}${modelTag}, reason="${n.reason.slice(0, 120)}"`; } -// #408/#590/#623/#645/#648: hosts that get the #408 uncompressed-baseline usage -// backfill. pi's AND omp's bili extensions cancel the host's NATIVE compaction -// so ACP owns compression — pi cancels auto-compaction, omp cancels ALL -// compaction (its session_before_compact event carries no reason field, so -// manual /compact can't be preserved). Codex is exempted the same way (#645): -// the backfilled baseline is a virtual number the model never receives — it -// drifts turn-to-turn (real post-fold usage + a character-based estimate of -// the folded-out tokens, so the metric can decrement with no compress), it -// exceeds the window (user saw 1315/950k = 138%), and it drives nothing in -// codex: codex's auto-compact keys off total_tokens, which the backfill never -// touches. Detected by UA (same signal as native-compact interception) because -// bili-launched codex sessions carry pluginAgent "mcp" (shared with claude). -// With the host's compaction off (pi/omp) or the backfill inert (codex), -// reporting the baseline only puts a >100% footer that mismatches the folded -// request actually forwarded (#590 pi 302.7%, #623 omp 205%, #645 codex 138%). -// Plain proxy clients keep the #408 behavior by default (their native -// compaction stays live and consumes the baseline); the `hostUsageCredit` -// config option (#648) additionally lets such a client opt out entirely -// ("off") — ZCode and similar plain anthropic clients otherwise show the -// cumulative, drifting baseline as inflated context in their UI. -function armHostUsageCredit( - session: Session, - originalMessages: CoreMessage[], - processedMessages: CoreMessage[], - headers: http.IncomingHttpHeaders, - hostUsageCredit: "auto" | "off", - log: (level: string, msg: string) => void, -): void { - session.hostCreditTokens = 0; - // #648: "off" disables the #408 uncompressed-baseline backfill — the host - // sees the actually-forwarded (folded) request, matching [acp-usage] - // input=. Plain proxy clients (ZCode) otherwise show a cumulative, - // drifting baseline that overstates real context pressure. - if (hostUsageCredit === "off") return; - if (session.metadata.pluginAgent === "pi" || session.metadata.pluginAgent === "omp") return; - if (isCodexClient(headers)) return; - // #408: tokens folded out of the forwarded view vs the host's own (unfolded) - // view — added back into the usage reported to the host so its anchor - // reflects the uncompressed baseline. Same estimator both sides, so - // systematic error cancels in the difference. - session.hostCreditTokens = processedMessages.length > 0 - ? Math.max(0, estimateCoreMessages(originalMessages) - estimateCoreMessages(processedMessages)) - : 0; - if (session.hostCreditTokens > 0) { - log("info", `[${session.id}] host usage backfill armed: +${session.hostCreditTokens} tok (forwarded view is folded); host usage will report the uncompressed baseline`); - } -} - // Zero-baseline sessions are judged conservatively ONLY when they arrived // anonymously (prefix-affinity forks/reloads, #553): they carry the full raw // history but no measurement yet, so feeding 0 blinds the nudge (usage 0%, @@ -1993,7 +1945,6 @@ function prepareAnthropic( const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; - session.hostCreditTokens = 0; const injectTools = opts.compress.injectTool && !pluginMode; const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId, isStrictReasoningEcho(session, upstreamOrigin)); @@ -2103,7 +2054,6 @@ function prepareAnthropic( // identity chain (#268), not part of the Anthropic Messages API — strip it // so the real upstream never sees a field it doesn't know. delete (rebuilt as Record).prompt_cache_key; - armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log); return { body: JSON.stringify(rebuilt), session, processedMessages, originalMessages, anthropicSystem: parsed.system, protocol: "anthropic", stream, compressInjected: injectTools, pluginMode, nudge, prompts, renderTags: "text-only" } as Prepared; } @@ -2232,7 +2182,6 @@ function prepareOpenai( const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; - session.hostCreditTokens = 0; let openaiSystemText = ""; const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId, isStrictReasoningEcho(session, upstreamOrigin)); let openaiOutboundSystem: string | undefined; @@ -2360,7 +2309,6 @@ function prepareOpenai( if (stream && (rebuilt as Record).stream_options === undefined) { (rebuilt as Record).stream_options = { include_usage: true }; } - armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log); // #532: title-gen side requests carry their own tiny system and would // clobber the conversation's measured overhead — skip them. if (!isTitleGen && openaiOutboundSystem !== undefined) { @@ -2389,7 +2337,6 @@ function prepareResponses( const sessionId = session.id; const stream = parsed.stream === true; ++session.stats.requests; - session.hostCreditTokens = 0; const stripReasoning = (msgs: BiliMessage[]): BiliMessage[] => withReasoningDrop(msgs, reasoning, log, sessionId, isStrictReasoningEcho(session, upstreamOrigin)); if (reconcileNativeCompactionBoundary(session)) { log("info", `[${sessionId}] reconciled ACP state after native Responses compact boundary`); @@ -2617,7 +2564,6 @@ function prepareResponses( }); log("info", `[${sessionId}] responses forward tools=[${fwdTools.join(",")}] injectTool=${injectTools}${pluginMode ? " (plugin mode: wire injection suppressed)" : ""} NO_INJECT_TOOL=${!!process.env.ACP_NO_INJECT_TOOL} NO_COMPRESS_PROMPT=${!!process.env.ACP_NO_COMPRESS_PROMPT}`); } - armHostUsageCredit(session, originalMessages, processedMessages, req.headers, opts.hostUsageCredit, log); // #532: measure the outbound developer(system)+tools overhead for the panel. // On this wire the system rides the injected developer message outside the // fold space, so counting devContent + tools does not double-count the @@ -3895,7 +3841,7 @@ async function forward( ? `\n\n---\n\n${buildAbsorbSystemPrompt(absorbToolName(loopConfig))}` : ""; const systemPrompt = (textProtocol ? buildCompressHybridSystemPrompt(prepared.prompts ?? defaultPrompts) : buildCompressSystemPrompt(prepared.prompts ?? defaultPrompts)) + absorbSection; - const adapter = pickAdapter(prepared.protocol, parsedReq, textProtocol, prepared.responsesProjection, prepared.anthropicSystem, prepared.openaiSystemText, prepared.session.hostCreditTokens ?? 0, absorbActive ? absorbToolName(loopConfig) : undefined); + const adapter = pickAdapter(prepared.protocol, parsedReq, textProtocol, prepared.responsesProjection, prepared.anthropicSystem, prepared.openaiSystemText, absorbActive ? absorbToolName(loopConfig) : undefined); const refreshFolded = (current: CoreMessage[]): CoreMessage[] => { // #422: mirror the prepare's fold with the post-compress state so // the re-request shows the compression the model just performed. @@ -4004,13 +3950,6 @@ async function forward( const out = u.completion_tokens ?? u.output_tokens; if (typeof out === "number") prepared.session.stats.outputTokens += out; } - // #408: the provider measured the folded view — add the - // prepare-time credit back so the host anchors on the - // uncompressed baseline. - const credit = prepared.session.hostCreditTokens ?? 0; - if (credit > 0 && backfillHostUsage(prepared.protocol, u, credit)) { - prepared.session.hostContextTokens = (typeof total === "number" ? total : 0) + credit; - } if (prepared.protocol === "openai") { rewriteOpenaiJsonResponse(json, ctx); } else if (prepared.protocol === "responses") { diff --git a/src/session.ts b/src/session.ts index 5db8c5bc..9892cb5f 100644 --- a/src/session.ts +++ b/src/session.ts @@ -146,19 +146,6 @@ export type Session = { * retry callbacks to correlate a transient upstream rejection with the * rewrite that preceded it (#189). A fresh process has none. */ lastCompress?: LastCompressInfo; - /** In-memory only (NOT persisted): tokens folded out of THIS request's - * forwarded view vs the host's own (unfolded) view, computed in prepare* - * as est(originalMessages) − est(processedMessages). Usage recorders add - * this back into the input-side usage field before forwarding to the host, - * so the host's usage anchor carries the uncompressed baseline instead of - * the post-fold value (#408). Overwritten each prepare(); 0 when nothing - * was folded this request. */ - hostCreditTokens?: number; - /** In-memory only (NOT persisted): last input-side usage total reported to - * the host AFTER the hostCreditTokens backfill (uncompressed baseline). - * Feeds the /acp panel tokenCount so it matches what the host footer - * shows (#408). 0/undefined until the first backfilled usage lands. */ - hostContextTokens?: number; /** Promise chain for per-session serialization. Two concurrent requests * sharing a session id would interleave processTurn / stream-rewriter * mutations on session.state, corrupting it. withSessionLock chains each @@ -314,8 +301,6 @@ export function resetSessionCompression(session: Session): void { session.blockContents.clear(); session.stats.lastInputTokens = 0; session.stats.contextTokens = 0; - session.hostCreditTokens = 0; - session.hostContextTokens = 0; session.metadata.nativeCompactionAt = Date.now(); markDirty(session); } diff --git a/src/util.ts b/src/util.ts index 40383761..0c138e67 100644 --- a/src/util.ts +++ b/src/util.ts @@ -111,34 +111,6 @@ export function promptInputTotal( return input + (splitSemantics && typeof cached === "number" ? cached : 0); } -/** #408: add the prepare-time fold credit back into a usage object's - * input-side fields, in place, so the host's usage anchor reports the - * uncompressed baseline instead of the post-fold value the provider measured. - * Field names per protocol: Anthropic/Responses `input_tokens` (TOTAL there), - * OpenAI `prompt_tokens` (+ `total_tokens` to keep the sum consistent). - * Returns true when anything was patched. */ -export function backfillHostUsage( - protocol: WireProtocol, - usage: Record, - credit: number, -): boolean { - if (!Number.isFinite(credit) || credit <= 0) return false; - let patched = false; - const add = (key: string): void => { - if (typeof usage[key] === "number" && Number.isFinite(usage[key])) { - usage[key] = (usage[key] as number) + credit; - patched = true; - } - }; - if (protocol === "openai") { - add("prompt_tokens"); - add("total_tokens"); - } else { - add("input_tokens"); - } - return patched; -} - /** Result of inspecting an upstream response for a "context too long" error. */ export interface ContextOverflowInfo { /** True if the response looks like an upstream context-overflow error. */ diff --git a/tests/host-usage-backfill.test.ts b/tests/host-usage-postfold.test.ts similarity index 78% rename from tests/host-usage-backfill.test.ts rename to tests/host-usage-postfold.test.ts index 073e3968..a28f5a51 100644 --- a/tests/host-usage-backfill.test.ts +++ b/tests/host-usage-postfold.test.ts @@ -9,14 +9,14 @@ import type { Config, CoreMessage } from "acp-kernel"; import { createCore, createInitialState, defaultConfig } from "acp-kernel"; import { listSessions, _resetSessionsForTest, type Session } from "../src/session.ts"; import { runCompressLoop, createResponsesAdapter, createOpenaiAdapter, createAnthropicAdapter } from "../src/loop/index.ts"; -import { backfillHostUsage, promptInputTotal, usageTotals } from "../src/util.ts"; +import { promptInputTotal, usageTotals } from "../src/util.ts"; import { pipePluginChatWithStrip, pipePluginResponsesWithStrip, pipePluginJson, _resetPluginStateForTest } from "../src/plugin.ts"; import { SessionStore, _setStoreForTest } from "../src/persist.ts"; import { startServer } from "../src/server.ts"; import type { ProxyOptions } from "../src/config.ts"; import { _setForTest as setRegistryForTest } from "../src/registry.ts"; -function makeSession(id: string, hostCreditTokens?: number): Session { +function makeSession(id: string): Session { return { id, meta: {}, @@ -28,11 +28,10 @@ function makeSession(id: string, hostCreditTokens?: number): Session { blockContents: new Map(), inFlight: 0, persisted: false, - ...(hostCreditTokens !== undefined ? { hostCreditTokens } : {}), }; } -function makeCtx(id: string, messages: CoreMessage[], hostCreditTokens?: number): { +function makeCtx(id: string, messages: CoreMessage[]): { core: ReturnType; config: Config; messages: CoreMessage[]; @@ -43,7 +42,7 @@ function makeCtx(id: string, messages: CoreMessage[], hostCreditTokens?: number) core: createCore(), config: defaultConfig(200000), messages, - session: makeSession(id, hostCreditTokens), + session: makeSession(id), log: () => {}, }; } @@ -107,69 +106,7 @@ function jsonFilesUnder(dir: string): string[] { return out; } -test("backfillHostUsage: openai patches prompt_tokens + total_tokens", () => { - const u: Record = { prompt_tokens: 60000, completion_tokens: 5, total_tokens: 60005 }; - assert.equal(backfillHostUsage("openai", u, 40000), true); - assert.equal(u.prompt_tokens, 100000); - assert.equal(u.total_tokens, 100005); - assert.equal(u.completion_tokens, 5); -}); - -test("backfillHostUsage: openai prompt_tokens only (no total_tokens invented)", () => { - const u: Record = { prompt_tokens: 60000 }; - assert.equal(backfillHostUsage("openai", u, 40000), true); - assert.equal(u.prompt_tokens, 100000); - assert.equal("total_tokens" in u, false); -}); - -test("backfillHostUsage: anthropic + responses patch input_tokens", () => { - const a: Record = { input_tokens: 60000, output_tokens: 5 }; - assert.equal(backfillHostUsage("anthropic", a, 40000), true); - assert.equal(a.input_tokens, 100000); - assert.equal(a.output_tokens, 5); - const r: Record = { input_tokens: 60000 }; - assert.equal(backfillHostUsage("responses", r, 40000), true); - assert.equal(r.input_tokens, 100000); -}); - -test("backfillHostUsage: credit <= 0 or missing input field is a no-op", () => { - const u: Record = { prompt_tokens: 60000 }; - assert.equal(backfillHostUsage("openai", u, 0), false); - assert.equal(u.prompt_tokens, 60000); - const v: Record = { completion_tokens: 5 }; - assert.equal(backfillHostUsage("openai", v, 40000), false); - assert.equal(v.completion_tokens, 5); -}); - -test("#408: responses loop — host completion carries uncompressed baseline, internal ledger stays post-fold", async () => { - const ctx = makeCtx("loop-resp", [textMsg("raw_1", "user", "hello")], 40000); - const sse = (type: string, data: unknown): string => `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; - const body = - sse("response.created", { type: "response.created", response: { id: "resp_1", status: "in_progress" } }) + - sse("response.completed", { - type: "response.completed", - response: { id: "resp_1", status: "completed", output: [], usage: { input_tokens: 60000, output_tokens: 5 } }, - }); - const chunks: Buffer[] = []; - for await (const chunk of runCompressLoop( - streamOf([body]), - { ...ctx, protocol: "responses" }, - { model: "m", input: [] }, - { url: "https://upstream.test/v1/responses", headers: { authorization: "Bearer t" } }, - createResponsesAdapter(), - "", - )) { - chunks.push(chunk); - } - const out = Buffer.concat(chunks).toString("utf8"); - assert.ok(out.includes('"input_tokens":100000'), `expected backfilled input_tokens in completed event, got: ${out}`); - assert.ok(!out.includes('"input_tokens":60000'), "raw folded input_tokens must not reach the host"); - assert.equal(ctx.session.hostContextTokens, 100000); - assert.equal(ctx.session.stats.lastInputTokens, 60000); - assert.equal(ctx.session.stats.inputTokens, 60000); -}); - -test("#408: responses loop — no credit leaves usage untouched (control)", async () => { +test("#660: responses loop — provider usage forwarded verbatim (internal ledger credits post-fold)", async () => { const ctx = makeCtx("loop-resp-ctrl", [textMsg("raw_1", "user", "hello")]); const sse = (type: string, data: unknown): string => `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; const body = @@ -191,12 +128,11 @@ test("#408: responses loop — no credit leaves usage untouched (control)", asyn } const out = Buffer.concat(chunks).toString("utf8"); assert.ok(out.includes('"input_tokens":60000'), out); - assert.equal(ctx.session.hostContextTokens, 60000); assert.equal(ctx.session.stats.lastInputTokens, 60000); }); -test("#408: openai adapter — raw finish chunk with usage carries the backfill on real tool calls", async () => { - const adapter = createOpenaiAdapter({ model: "m" }, undefined, 40000); +test("#660: openai adapter — terminal usage chunk reaches the host untouched on real tool calls", async () => { + const adapter = createOpenaiAdapter({ model: "m" }); const chunk = (o: unknown): string => `data: ${JSON.stringify(o)}\n\n`; const stream = streamOf([ chunk({ id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "get_weather", arguments: "" } }] } }] }), @@ -207,12 +143,12 @@ test("#408: openai adapter — raw finish chunk with usage carries the backfill for await (const ev of adapter.parseStream(stream, 1)) { if (ev.kind === "meta") meta += ev.chunk.toString("utf8"); } - assert.ok(meta.includes('"prompt_tokens":90000'), `expected backfilled prompt_tokens in raw finish chunk: ${meta}`); - assert.ok(meta.includes('"total_tokens":90010'), meta); + assert.ok(meta.includes('"prompt_tokens":50000'), `usage chunk must reach the host unmodified: ${meta}`); + assert.ok(!meta.includes('"prompt_tokens":90'), meta); }); -test("#408: anthropic adapter — first-round message_start meta carries backfilled input_tokens", async () => { - const adapter = createAnthropicAdapter({ model: "m" }, undefined, 40000); +test("#660: anthropic adapter — first-round message_start reaches the host untouched", async () => { + const adapter = createAnthropicAdapter({ model: "m" }); const stream = streamOf([ `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg_1", type: "message", role: "assistant", content: [], usage: { input_tokens: 60000, output_tokens: 1 } } })}\n\n`, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`, @@ -221,51 +157,12 @@ test("#408: anthropic adapter — first-round message_start meta carries backfil for await (const ev of adapter.parseStream(stream, 1)) { if (ev.kind === "meta") meta += ev.chunk.toString("utf8"); } - assert.ok(meta.includes('"input_tokens":100000'), `expected backfilled input_tokens in message_start: ${meta}`); + assert.ok(meta.includes('"input_tokens":60000'), `message_start usage must reach the host unmodified: ${meta}`); }); before(_resetPluginStateForTest); -test("#408: pipePluginChatWithStrip — openai final usage chunk backfilled, ledger post-fold", async () => { - await withTempStore("pipe-openai", async (_dir, store) => { - _setStoreForTest(store); - const session = makeSession("pipe-oai", 40000); - const chunks: Buffer[] = []; - const res = makeRes(chunks); - const stream = streamOf([ - `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", choices: [{ index: 0, delta: { content: "Hi" } }] })}\n\n`, - `data: ${JSON.stringify({ id: "c1", object: "chat.completion.chunk", choices: [], usage: { prompt_tokens: 60000, completion_tokens: 5, total_tokens: 60005 } })}\n\n`, - "data: [DONE]\n\n", - ]); - await pipePluginChatWithStrip(stream, res, "openai", session); - const out = chunks.join(""); - assert.ok(out.includes('"prompt_tokens":100000'), out); - assert.ok(out.includes('"total_tokens":100005'), out); - assert.equal(session.stats.lastInputTokens, 60000); - assert.equal(session.hostContextTokens, 100000); - }); -}); - -test("#408: pipePluginChatWithStrip — anthropic message_start backfilled, zero message_delta untouched", async () => { - await withTempStore("pipe-anthropic", async (_dir, store) => { - _setStoreForTest(store); - const session = makeSession("pipe-ant", 40000); - const chunks: Buffer[] = []; - const res = makeRes(chunks); - const stream = streamOf([ - `event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: "msg_1", usage: { input_tokens: 60000, cache_read_input_tokens: 1000 } } })}\n\n`, - `event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", usage: { input_tokens: 0, output_tokens: 10 } })}\n\n`, - ]); - await pipePluginChatWithStrip(stream, res, "anthropic", session); - const out = chunks.join(""); - assert.ok(out.includes('"input_tokens":100000'), out); - assert.ok(out.includes('"input_tokens":0'), "zero message_delta input_tokens must stay 0"); - assert.equal(session.stats.lastInputTokens, 61000); - assert.equal(session.hostContextTokens, 101000); - }); -}); - -test("#408: pipePluginChatWithStrip — no credit leaves bytes verbatim (control)", async () => { +test("#660: pipePluginChatWithStrip forwards usage frames verbatim", async () => { await withTempStore("pipe-ctrl", async (_dir, store) => { _setStoreForTest(store); const session = makeSession("pipe-ctrl"); @@ -279,7 +176,6 @@ test("#408: pipePluginChatWithStrip — no credit leaves bytes verbatim (control const out = chunks.join(""); assert.ok(out.includes('"prompt_tokens":60000'), out); assert.ok(!out.includes('"prompt_tokens":100000'), out); - assert.equal(session.hostContextTokens, 60000); }); }); @@ -326,15 +222,14 @@ test("#408: pipePluginChatWithStrip — split-semantics openai usage keeps lastI ]); await pipePluginChatWithStrip(stream, res, "openai", session); assert.equal(session.stats.lastInputTokens, 26284); - assert.equal(session.hostContextTokens, 26284); assert.equal(session.stats.cachedTokens, 26278); }); }); -test("#408: pipePluginResponsesWithStrip — response.completed usage backfilled", async () => { +test("#660: pipePluginResponsesWithStrip — response.completed usage forwarded verbatim", async () => { await withTempStore("pipe-resp", async (_dir, store) => { _setStoreForTest(store); - const session = makeSession("pipe-resp", 40000); + const session = makeSession("pipe-resp"); const chunks: Buffer[] = []; const res = makeRes(chunks); const stream = streamOf([ @@ -342,16 +237,15 @@ test("#408: pipePluginResponsesWithStrip — response.completed usage backfilled ]); await pipePluginResponsesWithStrip(stream, res, session); const out = chunks.join(""); - assert.ok(out.includes('"input_tokens":100000'), out); + assert.ok(out.includes('"input_tokens":60000'), out); assert.equal(session.stats.lastInputTokens, 60000); - assert.equal(session.hostContextTokens, 100000); }); }); -test("#408: pipePluginJson — openai JSON usage backfilled", async () => { +test("#660: pipePluginJson — openai JSON usage forwarded verbatim", async () => { await withTempStore("pipe-json", async (_dir, store) => { _setStoreForTest(store); - const session = makeSession("pipe-json", 40000); + const session = makeSession("pipe-json"); const chunks: Buffer[] = []; const res = makeRes(chunks); const body = JSON.stringify({ @@ -363,10 +257,9 @@ test("#408: pipePluginJson — openai JSON usage backfilled", async () => { await pipePluginJson(streamOf([body]), res, session, "openai"); const out = chunks.join(""); const json = JSON.parse(out) as { usage: Record }; - assert.equal(json.usage.prompt_tokens, 100000); - assert.equal(json.usage.total_tokens, 100005); + assert.equal(json.usage.prompt_tokens, 60000); + assert.equal(json.usage.total_tokens, 60005); assert.equal(session.stats.lastInputTokens, 60000); - assert.equal(session.hostContextTokens, 100000); }); }); @@ -409,7 +302,7 @@ test("#408: persist — flat v1 negative lastInputTokens clamps to 0 on load", a }); }); -test("#408: prepareOpenai arms the credit — host sees backfilled usage after a compress fold", async () => { +test("#408/#660: prepareOpenai — post-fold provider usage reaches the host verbatim", async () => { _setStoreForTest(new SessionStore({ enabled: false })); setRegistryForTest({}); const upstreamBodies: string[] = []; @@ -490,8 +383,8 @@ test("#408: prepareOpenai arms the credit — host sees backfilled usage after a // turn 5: the model folds m00003..m00004 (u2 + a2, outside the kernel's // protected zone of the last 5 messages). The range must NOT include // m00001 — the kernel never prunes the first user message, so folding - // it would leave the big content in the forwarded view and the credit - // (est(original) − est(processed)) would stay ~0. + // it would leave the big content in the forwarded view and the fold + // assertion below would pass vacuously. history.push({ role: "user", content: "t5" }); const r2 = await post(); assert.ok(!r2.includes('"name":"compress"'), `compress tool call must be suppressed from the host: ${r2}`); @@ -503,9 +396,7 @@ test("#408: prepareOpenai arms the credit — host sees backfilled usage after a const r3 = await post(); const m = r3.match(/"prompt_tokens":(\d+)/); assert.ok(m, `turn 6 usage chunk missing: ${r3}`); - const prompt = Number(m[1]); - assert.ok(prompt > 100, `host-facing prompt_tokens must be backfilled above the post-fold 100 (got ${prompt})`); - assert.ok(prompt >= 100 + 200, `backfill must carry a meaningful share of the folded ~1400-token range (got ${prompt})`); + assert.equal(Number(m[1]), 100, `host-facing prompt_tokens must be the provider-measured post-fold value — no baseline backfill (#660): ${r3}`); assert.ok(upstreamBodies.length >= 7, `expected 7 upstream requests (turn5 has a compress round-trip), got ${upstreamBodies.length}`); assert.ok(!upstreamBodies[6]!.includes("SENTINEL_FOLD_GONE"), "turn-6 upstream body must not carry the folded u2 content — fold must have happened"); } finally { @@ -514,7 +405,7 @@ test("#408: prepareOpenai arms the credit — host sees backfilled usage after a } }); -test("#590: pi plugin mode reports folded usage — host backfill suppressed", async () => { +test("#590: pi plugin mode reports folded usage verbatim", async () => { _setStoreForTest(new SessionStore({ enabled: false })); setRegistryForTest({}); _resetPluginStateForTest(); @@ -561,8 +452,7 @@ test("#590: pi plugin mode reports folded usage — host backfill suppressed", a const url = `http://127.0.0.1:${proxyPort}/bili/http://127.0.0.1:${relayPort}/v1/messages`; // Sizing copied from plugin-protocol.test.ts: the compressed head // (m00001..m00002) exceeds minCompressibleChars while the protected-zone - // walk exhausts itself on the tail — so the fold is real and large - // enough that an ungated backfill would be plainly visible. + // walk exhausts itself on the tail — so the fold is real and non-vacuous. const headFiller = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ".repeat(28); const tailFiller = "enim ad minim veniam quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in voluptate. ".repeat(28); type AnthropicMessage = { role: string; content: string | Array> }; @@ -626,17 +516,16 @@ test("#590: pi plugin mode reports folded usage — host backfill suppressed", a // this would pass vacuously with no credit to suppress. assert.equal(upstreamBodies.length, 2); assert.ok(!upstreamBodies[1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.equal(inputTokensOf(r2), 100, "pi plugin mode must report the folded request's own usage — no uncompressed-baseline backfill (#590)"); + assert.equal(inputTokensOf(r2), 100, "pi plugin mode must report the folded request's own usage verbatim (#590)"); } finally { await new Promise((resolve, reject) => proxy.close((e) => (e ? reject(e) : resolve()))); await new Promise((resolve, reject) => relay.close((e) => (e ? reject(e) : resolve()))); } }); -test("#623: omp plugin mode reports folded usage — host backfill suppressed", async () => { - // Mirrors the #590 pi e2e, binding the session as omp. The wire is - // incidental — armHostUsageCredit's pluginAgent gate is protocol-agnostic; - // reusing the proven pi fixture guarantees a real fold (not a vacuous pass). +test("#623: omp plugin mode reports folded usage verbatim", async () => { + // Mirrors the #590 pi e2e, binding the session as omp; reusing the proven + // pi fixture guarantees a real fold (not a vacuous pass). _setStoreForTest(new SessionStore({ enabled: false })); setRegistryForTest({}); _resetPluginStateForTest(); @@ -742,20 +631,17 @@ test("#623: omp plugin mode reports folded usage — host backfill suppressed", ]); assert.equal(upstreamBodies.length, 2); assert.ok(!upstreamBodies[1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.equal(inputTokensOf(r2), 100, "omp plugin mode must report the folded request's own usage — no uncompressed-baseline backfill (#623)"); + assert.equal(inputTokensOf(r2), 100, "omp plugin mode must report the folded request's own usage verbatim (#623)"); } finally { await new Promise((resolve, reject) => proxy.close((e) => (e ? reject(e) : resolve()))); await new Promise((resolve, reject) => relay.close((e) => (e ? reject(e) : resolve()))); } }); -// #648: ZCode — a plain proxy client on the anthropic wire (no x-bili-plugin -// header, no special UA) — must be able to opt out of the #408 -// uncompressed-baseline backfill via hostUsageCredit: "off", reporting the -// folded request's own usage (matching [acp-usage] input=). The control test -// pins the other side of the gate: an identical plain client on the default -// (hostUsageCredit: "auto") still gets the #408 backfill. The fold is real -// (the relay emits a compress tool_use), not a vacuous pass. +// #648/#660: ZCode — a plain proxy client on the anthropic wire (no +// x-bili-plugin header, no special UA). Every host sees the folded request's +// own provider-measured usage (#660). The fold is real (the relay emits a +// compress tool_use), not a vacuous pass. const ZCODE_CONV_648 = "zcode-usage-648"; @@ -803,7 +689,7 @@ function zcodeConversation(): Array<{ role: string; content: string }> { return history; } -async function withZCodeHarness(hostUsageCredit: "auto" | "off", fn: (h: { proxy: http.Server; upstream: http.Server; bodies: string[]; url: string }) => Promise): Promise { +async function withZCodeHarness(fn: (h: { proxy: http.Server; upstream: http.Server; bodies: string[]; url: string }) => Promise): Promise { const bodies: string[] = []; const upstream = http.createServer((req, res) => { const chunks: Buffer[] = []; @@ -842,7 +728,6 @@ async function withZCodeHarness(hostUsageCredit: "auto" | "off", fn: (h: { proxy debug: false, passthrough: false, autoUpdate: false, - hostUsageCredit, mitm: { enabled: false, domains: [] }, } as ProxyOptions); await once(proxy, "listening"); @@ -879,8 +764,8 @@ async function setupZCodeCompressedSession(h: { bodies: string[]; url: string }) return h.bodies.length; } -test("#648: ZCode (anthropic wire, hostUsageCredit off) reports folded usage — host backfill suppressed", async () => { - await withZCodeHarness("off", async (h) => { +test("#648/#660: ZCode (anthropic wire, plain proxy client) reports folded usage verbatim", async () => { + await withZCodeHarness(async (h) => { const afterSetup = await setupZCodeCompressedSession(h); const r2 = await fetch(h.url, { method: "POST", @@ -891,33 +776,13 @@ test("#648: ZCode (anthropic wire, hostUsageCredit off) reports folded usage — const raw = await r2.text(); assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once"); assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.equal(zcodeInputTokensOf(raw), 1000, "hostUsageCredit off must report the folded request's own usage — no uncompressed-baseline backfill (#648)"); + assert.equal(zcodeInputTokensOf(raw), 1000, "host usage must be the folded request's own provider-measured value — no baseline backfill (#648/#660)"); }); }); -test("#648 control: plain client (anthropic wire, hostUsageCredit auto) still gets the #408 backfill", async () => { - await withZCodeHarness("auto", async (h) => { - const afterSetup = await setupZCodeCompressedSession(h); - const r2 = await fetch(h.url, { - method: "POST", - headers: { "content-type": "application/json", "x-acp-session": ZCODE_CONV_648 }, - body: JSON.stringify({ model: "claude-test", max_tokens: 1024, stream: true, system: "You are a test assistant.", messages: zcodeConversation() }), - }); - assert.equal(r2.status, 200); - const raw = await r2.text(); - assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once"); - assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.ok(zcodeInputTokensOf(raw) > 1000, "plain proxy client with hostUsageCredit auto must still see the uncompressed baseline (#408)"); - }); -}); - -// #645: codex — a plain proxy client on the responses wire identified by UA — -// must report the folded request's own usage; the #408 uncompressed-baseline -// backfill is suppressed (virtual number the model never receives, drifts -// turn-to-turn, exceeds the window: 1315/950k). The control test pins the -// other side of the gate: an identical non-codex client still gets the -// backfill. Harness mirrors codex-compact-e2e.test.ts (real fold, not a -// vacuous pass). +// #645/#660: codex — a plain proxy client on the responses wire identified by +// UA. Every host sees the folded request's own provider-measured usage +// (#660). Harness mirrors codex-compact-e2e.test.ts (real fold, not a vacuous pass). const CODEX_UA_645 = "codex_cli_rs/0.1.0 (linux x86_64)"; const CODEX_CONV_645 = "codex-usage-645"; @@ -1033,7 +898,7 @@ async function setupCodexCompressedSession(h: { bodies: string[]; url: string }, return h.bodies.length; } -test("#645: codex (responses wire, UA) reports folded usage — host backfill suppressed", async () => { +test("#645/#660: codex UA client (responses wire) reports folded usage verbatim", async () => { await withCodexHarness(async (h) => { const afterSetup = await setupCodexCompressedSession(h, CODEX_UA_645); const r2 = await fetch(h.url, { @@ -1045,22 +910,6 @@ test("#645: codex (responses wire, UA) reports folded usage — host backfill su const raw = await r2.text(); assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once"); assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.equal(completedUsageOf(raw).input_tokens, 1000, "codex must report the folded request's own usage — no uncompressed-baseline backfill (#645)"); - }); -}); - -test("#645 control: non-codex plain client (responses wire) still gets the #408 backfill", async () => { - await withCodexHarness(async (h) => { - const afterSetup = await setupCodexCompressedSession(h); - const r2 = await fetch(h.url, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "gpt-resp", stream: true, session_id: CODEX_CONV_645, instructions: "You are the test coding agent.", input: codexConversation() }), - }); - assert.equal(r2.status, 200); - const raw = await r2.text(); - assert.equal(h.bodies.length, afterSetup + 1, "post-fold turn forwarded to upstream exactly once"); - assert.ok(!h.bodies[h.bodies.length - 1]!.includes("SENTINEL_FOLD_GONE"), "post-fold upstream body must not carry the folded head content"); - assert.ok(completedUsageOf(raw).input_tokens > 1000, "plain proxy client must still see the uncompressed baseline (#408)"); + assert.equal(completedUsageOf(raw).input_tokens, 1000, "codex (UA) must report the folded request's own provider-measured usage — no baseline backfill (#645/#660)"); }); }); diff --git a/tests/issue589-usage-frame.test.ts b/tests/issue589-usage-frame.test.ts index fac0eaab..75d8fe92 100644 --- a/tests/issue589-usage-frame.test.ts +++ b/tests/issue589-usage-frame.test.ts @@ -31,27 +31,20 @@ async function collect(adapter: ReturnType, events: return { meta, kinds }; } -test("#589: raw tool-call round forwards the trailing usage-only frame, patched with credit", async () => { - const adapter = createOpenaiAdapter({ model: "m" }, undefined, 40000); +test("#589: raw tool-call round forwards the trailing usage-only frame verbatim", async () => { + const adapter = createOpenaiAdapter({ model: "m" }); const { meta } = await collect(adapter, [chunk(toolDelta), chunk(finishChunk), chunk(usageFrame), "data: [DONE]\n\n"]); - const usageAt = meta.indexOf('"prompt_tokens":90000'); + const usageAt = meta.indexOf('"prompt_tokens":50000'); const finishAt = meta.indexOf('"finish_reason":"tool_calls"'); const doneAt = meta.indexOf("[DONE]"); assert.ok(usageAt >= 0, `expected patched usage frame in client stream: ${meta}`); - assert.ok(meta.includes('"total_tokens":90010'), meta); + assert.ok(meta.includes('"total_tokens":50010'), meta); assert.ok(finishAt >= 0 && usageAt > finishAt, `usage frame must come after the finish chunk: ${meta}`); assert.ok(doneAt >= 0 && usageAt < doneAt, `usage frame must come before [DONE]: ${meta}`); }); -test("#589: trailing usage-only frame forwarded verbatim when no credit", async () => { - const adapter = createOpenaiAdapter({ model: "m" }, undefined, 0); - const { meta } = await collect(adapter, [chunk(toolDelta), chunk(finishChunk), chunk(usageFrame), "data: [DONE]\n\n"]); - assert.ok(meta.includes('"prompt_tokens":50000'), meta); - assert.ok(!meta.includes('"prompt_tokens":90000'), meta); -}); - test("#589: non-tool rounds keep swallowing the trailing frame (rebuild embeds usage)", async () => { - const adapter = createOpenaiAdapter({ model: "m" }, undefined, 40000); + const adapter = createOpenaiAdapter({ model: "m" }); const textDelta = { id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: { content: "hi there" } }] }; const finishText = { id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }; const { meta } = await collect(adapter, [chunk(textDelta), chunk(finishText), chunk(usageFrame), "data: [DONE]\n\n"]); @@ -59,7 +52,7 @@ test("#589: non-tool rounds keep swallowing the trailing frame (rebuild embeds u }); test("#589: usage-only frame without usage field is not forwarded", async () => { - const adapter = createOpenaiAdapter({ model: "m" }, undefined, 40000); + const adapter = createOpenaiAdapter({ model: "m" }); const emptyChoices = { id: "c1", object: "chat.completion.chunk", created: 1, model: "m", choices: [] }; const { meta } = await collect(adapter, [chunk(toolDelta), chunk(finishChunk), chunk(emptyChoices), "data: [DONE]\n\n"]); assert.ok(!meta.includes("chat.completion.chunk\" }"), meta);