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
2 changes: 1 addition & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 exemptedtheir 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 entirelyevery 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. |
Expand Down
1 change: 1 addition & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。 |
Expand Down
15 changes: 0 additions & 15 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down
47 changes: 23 additions & 24 deletions src/exit-matrix.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -66,7 +65,7 @@ export const ERROR_DELIVERY: Record<WireExitId, ExitCell> = {
"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"],
},
};

Expand Down Expand Up @@ -108,48 +107,48 @@ export const ABORT_PROPAGATION: Record<WireExitId, ExitCell> = {
},
};

export const HOST_USAGE_BACKFILL: Record<WireExitId, ExitCell> = {
export const HOST_USAGE_PASSTHROUGH: Record<WireExitId, ExitCell> = {
"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;
24 changes: 4 additions & 20 deletions src/loop/adapter-anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ function buildTextDeltaEvent(index: number, text: string): Buffer {
);
}

export function createAnthropicAdapter(requestBody: Record<string, unknown>, originalSystem?: AnthropicRequestBody["system"], hostCredit = 0): CompressLoopAdapter {
export function createAnthropicAdapter(requestBody: Record<string, unknown>, originalSystem?: AnthropicRequestBody["system"]): CompressLoopAdapter {
const model = (requestBody.model as string) ?? undefined;
let messageId: string | undefined;
let clientIndex = 0;
Expand Down Expand Up @@ -248,26 +248,10 @@ export function createAnthropicAdapter(requestBody: Record<string, unknown>, 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<string, unknown> | undefined;
const pu = (pmsg?.["usage"] ?? {}) as Record<string, unknown>;
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;
Expand Down
37 changes: 7 additions & 30 deletions src/loop/adapter-openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,7 @@ function stripFinishReasonChunk(buf: Buffer): Buffer {
}
}

function patchUsageChunk(eventStr: string, parsed: Record<string, unknown>, u: Record<string, unknown>, 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<string, unknown>, clientSystem?: string, hostCredit = 0, absorbName?: string): CompressLoopAdapter {
export function createOpenaiAdapter(requestBody: Record<string, unknown>, clientSystem?: string, absorbName?: string): CompressLoopAdapter {
const model = (requestBody.model as string) ?? "unknown";
let responseId = `chatcmpl-proxy-${Date.now()}`;
let toolIndex = 0;
Expand Down Expand Up @@ -352,10 +331,10 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, 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;
Expand All @@ -376,11 +355,9 @@ export function createOpenaiAdapter(requestBody: Record<string, unknown>, 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
Expand Down
13 changes: 0 additions & 13 deletions src/loop/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 2 additions & 3 deletions src/loop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Loading
Loading