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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Versions follow the merge of a `*_release-v*` branch; CI publishes to npm on tag
### Fixes

- **Round-evidence closure for compress-reasoning drop (#651, #348 twin)**: the closure gate shipped with the reasoning drop — "compress call followed by a genuine user message" — is unreachable in long agentic sessions (no user messages after the opening prompt, observed: 0 drops while 30 compress rounds retained 20.6K/8.4K/10.6K-char thinking floors). A round now closes on tool-result evidence: the compress call's `tool-result` (matching `toolCallId`) exists at a later index and at least one message follows it. In-flight rounds (result missing or still the last message) stay untouched; per-provider `compress.providers.<name>.reasoning.drop=false` escape hatch preserved for reasoning-replay models (GLM). Mirrors billion-context-pi #348 (PR #349).
- **`hostUsageCredit` config switch: opt plain proxy clients out of the #408 uncompressed-baseline usage backfill (#648)**: plain anthropic proxy clients (ZCode — base-url → proxy, no `x-bili-plugin` header, no special UA) fell through every #408 backfill exemption (pi/omp by `pluginAgent`, codex by UA in #647) and got the full uncompressed-baseline backfill armed, so their UI showed a cumulative, drifting baseline (real folded value + per-compression backfill) instead of the actually-forwarded context — overstating real pressure and going non-monotonic on pure-append turns (est drift). New `hostUsageCredit` option (`"auto" | "off"`, default `auto` = current behavior; `BILI_HOST_USAGE_CREDIT` env / `hostUsageCredit` file key). `off` disables the #408 backfill entirely so the usage reported to the host is the actually-forwarded (folded) request, matching `[acp-usage] input=`. `auto` keeps today's behavior for everyone; ZCode users set `off` to get the actually-forwarded value.

- **Lenient compress-arg parsing: salvage single-quoted JSON before hard rejection (#603)**: weak local models (reported via omp#121) emit `compress` args with single quotes (`{'content':[{'startId':...}]}`) — a malformation class the kernel's salvage ladder (fences, trailing commas, raw newlines, double-stringification, truncated/prose-wrapped arrays) does not cover, so the whole call was rejected `kind=malformed-json`, the round was wasted, and the model saw a FAILED result that can trigger tag-echoing. `parseCompressInput` now retries once through a quote-normalization pass when the kernel recovers zero ranges or reports invalid items: a state machine converts single-quoted strings to double-quoted ones (apostrophes inside double-quoted values are data and are copied verbatim; control characters inside single-quoted regions become JSON escapes), applied to raw-string args and to object inputs whose `content` value is a stringified array. The retry wins only when it recovers strictly more ranges — valid input is never rewritten — and salvaged ranges pass the same ref-validation gate as any other range, so the worst case is a wasted round, never a wrong compression. A `[acp-compress-input] quote-salvage: recovered N range(s)` warn logs each recovery for attribution.
- **Forward-once-then-learn for image-dominated payloads — no false 502 on pixel-tile upstreams (#496)**: the default per-image estimate (`base64 length / 4`, uncapped) matches byte-billing relays but overestimates pixel-tile upstreams (official Anthropic/OpenAI) by up to ~200×, so a session whose *estimated* image floor alone exceeded the window was hard-failed with a 502 `preflight_compress_failed` ("Images alone account for ~N tokens") even though the real cost was a few K tokens — a regression vs master for official-API multimodal users (e.g. `bili claude` pasting screenshots). The fit gate now forwards ONCE instead of hard-failing when the over-window is attributable solely to the image estimate (`textEstimate < limit`), there is no upstream evidence of overflow yet (`lastInputTokens < limit` and no learned limit for the model), and images are present. The upstream then arbitrates billing: a pixel-tile upstream accepts (usage reports small → zero behavior change); a byte-billing relay rejects once, the existing self-heal learns the true window, and every subsequent request fails fast with an accurate message — exactly one rejected forward, strictly better than master's infinite 400 loop, no new knob. Also documents `BILI_IMAGE_TOKEN_CAP` (per-image estimate cap) in CONFIGURATION.
Expand Down
1 change: 1 addition & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,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). |
| `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. |
Expand Down
15 changes: 15 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,15 @@ 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
Expand Down Expand Up @@ -446,6 +455,7 @@ 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: {
Expand Down Expand Up @@ -480,6 +490,7 @@ type FileConfig = {
autoUpdate?: boolean;
/** Dist-tag channel the auto-updater follows (default "latest"). */
updateTag?: string;
hostUsageCredit?: "auto" | "off";
upstreamProxy?: string;
upstreamProxyMode?: string;
logFile?: string;
Expand Down Expand Up @@ -590,6 +601,10 @@ 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<string, unknown>;
Expand Down
19 changes: 14 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1822,7 +1822,7 @@ 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: hosts that get the #408 uncompressed-baseline usage
// #408/#590/#623/#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
Expand All @@ -1832,14 +1832,23 @@ function diagNudge(turn: { nudge?: { shouldInject: boolean; reason: string; cont
// (#590 pi 302.7%, #623 omp 205%). Gate on pluginAgent so ONLY the
// bili-launched extensions are exempted: plain proxy clients and codex
// native-compact interception keep the #408 behavior (their native compaction
// stays live and consumes the baseline).
// stays live and consumes the baseline). The `hostUsageCredit` config option
// (#648) additionally lets a plain proxy 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[],
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;
// #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
Expand Down Expand Up @@ -1990,7 +1999,7 @@ 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<string, unknown>).prompt_cache_key;
armHostUsageCredit(session, originalMessages, processedMessages, log);
armHostUsageCredit(session, originalMessages, processedMessages, 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;
}

Expand Down Expand Up @@ -2245,7 +2254,7 @@ function prepareOpenai(
if (stream && (rebuilt as Record<string, unknown>).stream_options === undefined) {
(rebuilt as Record<string, unknown>).stream_options = { include_usage: true };
}
armHostUsageCredit(session, originalMessages, processedMessages, log);
armHostUsageCredit(session, originalMessages, processedMessages, 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) {
Expand Down Expand Up @@ -2501,7 +2510,7 @@ 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, log);
armHostUsageCredit(session, originalMessages, processedMessages, 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
Expand Down
Loading
Loading