billion-context 0.1.85 → 0.1.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -389,6 +389,23 @@ recommended** for many concurrent conversations because of the collision
389
389
  risk — until pi grows its own session-id signal. For pi multi-agent use,
390
390
  pass an explicit `x-acp-session` header per conversation to avoid collisions.
391
391
 
392
+ ### Windows: exclude the sessions dir from antivirus (#362)
393
+
394
+ The proxy persists each session's compression state to the sessions dir
395
+ (`%USERPROFILE%\.local\share\billion-context\` by default) and rewrites the
396
+ file every turn of a long session. On Windows, real-time antivirus (Windows
397
+ Defender), the search indexer, or a sync tool (OneDrive) can lock that
398
+ directory mid-write, so the rename fails with `EPERM` and every persist for
399
+ that session fails until the lock clears.
400
+
401
+ When the same session fails N consecutive writes (default `5`), the proxy
402
+ logs a one-time, actionable alert naming the directory to exclude. To fix it
403
+ at the root: add `%USERPROFILE%\.local\share\billion-context\` to your
404
+ antivirus **exclusions** (Windows Defender: Settings → Virus & threat
405
+ protection → Manage settings → Exclusions → Add an exclusion → Folder) and
406
+ make sure no sync tool (OneDrive / Dropbox / …) is syncing that path. Full
407
+ steps in [CONFIGURATION.md](CONFIGURATION.md#windows-exclude-the-sessions-dir-from-antivirus-362).
408
+
392
409
  ## Status
393
410
 
394
411
  Early. Protocol handling and compression work against mock tests (500+ passing). Real-model integration testing is the next milestone. Expect rough edges.
package/README.zh-CN.md CHANGED
@@ -265,6 +265,12 @@ Windows 下会自动发现常见 Clash/Mihomo 静态系统代理;Web UI 会显
265
265
 
266
266
  **建议:** Codex 和 OpenCode 可以安全地通过代理并发跑很多会话。pi 单个 agent 没问题,但因碰撞风险**不建议**并发多会话 —— 直到 pi 自己长出 session-id 信号。pi 多 agent 场景下,每个会话发一个显式 `x-acp-session` header 来避免碰撞。
267
267
 
268
+ ### Windows:把会话目录加入杀软排除项(#362)
269
+
270
+ 代理把每个会话的压缩状态持久化到会话目录(默认 `%USERPROFILE%\.local\share\billion-context\`),长会话每一轮都会重写该文件。在 Windows 上,实时杀毒(Windows Defender)、搜索索引器或同步工具(OneDrive)可能在写入中途锁住该目录,导致 rename 以 `EPERM` 失败,在锁解除前该会话的每次持久化都会失败。
271
+
272
+ 当同一会话连续 N 次写失败(默认 `5`)时,代理会打一条一次性、可操作的告警,明确指出要排除的目录。要从根上修复:把 `%USERPROFILE%\.local\share\billion-context\` 加入杀软**排除项**(Windows Defender:设置 → 病毒和威胁防护 → 管理设置 → 排除项 → 添加排除 → 文件夹),并确认没有同步工具(OneDrive / Dropbox / …)在同步该路径。完整步骤见 [CONFIGURATION.zh-CN.md](CONFIGURATION.zh-CN.md) 的「Windows:把会话目录加入杀软排除项」章节。
273
+
268
274
  ## 状态
269
275
 
270
276
  早期。协议处理和压缩已通过 mock 测试(500+ 项通过)。真实模型集成测试是下一里程碑。预期会有粗糙的地方。
package/dist/index.js CHANGED
@@ -47078,7 +47078,7 @@ function caDir() {
47078
47078
  }
47079
47079
 
47080
47080
  // src/logger.ts
47081
- import { createWriteStream, fstatSync, mkdirSync, statSync, renameSync } from "fs";
47081
+ import { createWriteStream, fstatSync, mkdirSync, statSync, renameSync, unlinkSync } from "fs";
47082
47082
  import path2 from "path";
47083
47083
  var MAX_BYTES = 10 * 1024 * 1024;
47084
47084
  var stream;
@@ -47094,6 +47094,10 @@ function openStream(file) {
47094
47094
  existingSize = statSync(file).size;
47095
47095
  if (existingSize >= MAX_BYTES) {
47096
47096
  try {
47097
+ try {
47098
+ unlinkSync(file + ".old");
47099
+ } catch {
47100
+ }
47097
47101
  renameSync(file, file + ".old");
47098
47102
  existingSize = 0;
47099
47103
  } catch {
@@ -47738,6 +47742,11 @@ function loadRoutes(env = process.env) {
47738
47742
  }
47739
47743
  return routes;
47740
47744
  }
47745
+ function passthroughState(env) {
47746
+ const filePassthrough = loadConfigFile().passthrough === true;
47747
+ if (env.ACP_PASSTHROUGH !== void 0) return { enabled: env.ACP_PASSTHROUGH === "1", source: "env" };
47748
+ return { enabled: filePassthrough, source: filePassthrough ? "file" : null };
47749
+ }
47741
47750
  function loadOptions(env = process.env) {
47742
47751
  const fileConfig = loadConfigFile();
47743
47752
  const port = parseInt(env.ACP_PORT ?? env.PORT ?? `${fileConfig.port ?? 8787}`, 10);
@@ -47748,6 +47757,7 @@ function loadOptions(env = process.env) {
47748
47757
  const host = rawHost === "localhost" ? "127.0.0.1" : rawHost;
47749
47758
  const upstream = (env.ACP_UPSTREAM ?? fileConfig.upstream ?? "https://api.anthropic.com").replace(/\/$/, "");
47750
47759
  const routes = loadRoutes(env);
47760
+ const passthrough = passthroughState(env);
47751
47761
  const modelContextLimit = parseInt(env.ACP_MODEL_CONTEXT_LIMIT ?? `${fileConfig.modelContextLimit ?? 2e5}`, 10);
47752
47762
  const biliProxy = nonEmpty(env.BILI_UPSTREAM_PROXY);
47753
47763
  const webProxy = nonEmpty(fileConfig.upstreamProxy);
@@ -47802,7 +47812,8 @@ function loadOptions(env = process.env) {
47802
47812
  log: env.ACP_LOG !== "0" && fileConfig.log !== false,
47803
47813
  debug: (env.ACP_DEBUG ?? (fileConfig.debug ? "1" : "0")) === "1",
47804
47814
  dumpSse: env.ACP_DUMP_SSE || fileConfig.dumpSse || void 0,
47805
- passthrough: (env.ACP_PASSTHROUGH ?? (fileConfig.passthrough ? "1" : "0")) === "1",
47815
+ passthrough: passthrough.enabled,
47816
+ passthroughSource: passthrough.source,
47806
47817
  autoUpdate: (env.ACP_AUTO_UPDATE ?? (fileConfig.autoUpdate === false ? "0" : "1")) !== "0",
47807
47818
  logFile: env.ACP_LOG_FILE !== void 0 ? env.ACP_LOG_FILE || void 0 : fileConfig.logFile,
47808
47819
  mitm: {
@@ -49475,15 +49486,11 @@ var StateStore = class {
49475
49486
  }
49476
49487
  const file = this.resolvePath(id, payload);
49477
49488
  const data = JSON.stringify(this.envelope(id, payload));
49478
- try {
49479
- fs.mkdirSync(path4.dirname(file), { recursive: true });
49480
- } catch (e) {
49481
- this.log("warn", `[persist] could not create dir ${path4.dirname(file)}: ${errText(e)}`);
49482
- }
49483
49489
  let lastErr;
49484
49490
  for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
49485
49491
  const tmp = this.tempPath(file);
49486
49492
  try {
49493
+ fs.mkdirSync(path4.dirname(file), { recursive: true });
49487
49494
  fs.writeFileSync(tmp, data, "utf8");
49488
49495
  fs.renameSync(tmp, file);
49489
49496
  lastErr = void 0;
@@ -49494,9 +49501,7 @@ var StateStore = class {
49494
49501
  fs.unlinkSync(tmp);
49495
49502
  } catch {
49496
49503
  }
49497
- const code = e.code;
49498
- if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") break;
49499
- if (attempt === this.retryAttempts - 1) break;
49504
+ if (!isTransientFsError(e) || attempt === this.retryAttempts - 1) break;
49500
49505
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.backoffMs(attempt));
49501
49506
  }
49502
49507
  }
@@ -49506,14 +49511,19 @@ var StateStore = class {
49506
49511
  this.removeSpillSync(file);
49507
49512
  return true;
49508
49513
  }
49514
+ const spill = this.spillPathFor(file);
49509
49515
  let spillPath = null;
49510
- try {
49511
- const spill = this.spillPathFor(file);
49512
- fs.writeFileSync(spill, data, "utf8");
49513
- spillPath = spill;
49514
- this.discovered.set(id, spill);
49515
- } catch {
49516
+ for (let attempt = 0; attempt < this.retryAttempts && spillPath === null; attempt++) {
49517
+ try {
49518
+ fs.mkdirSync(path4.dirname(spill), { recursive: true });
49519
+ fs.writeFileSync(spill, data, "utf8");
49520
+ spillPath = spill;
49521
+ } catch (e) {
49522
+ if (!isTransientFsError(e) || attempt === this.retryAttempts - 1) break;
49523
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, this.backoffMs(attempt));
49524
+ }
49516
49525
  }
49526
+ if (spillPath) this.discovered.set(id, spillPath);
49517
49527
  this.recordFailure(id, lastErr, spillPath);
49518
49528
  return spillPath !== null;
49519
49529
  }
@@ -49617,33 +49627,45 @@ var StateStore = class {
49617
49627
  throw e;
49618
49628
  }
49619
49629
  const file = this.resolvePath(id, payload);
49620
- try {
49621
- await fsp.mkdir(path4.dirname(file), { recursive: true });
49622
- } catch (e) {
49623
- this.log("warn", `[persist] could not create dir ${path4.dirname(file)}: ${errText(e)}`);
49630
+ const data = JSON.stringify(this.envelope(id, payload));
49631
+ let lastErr;
49632
+ for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
49633
+ const tmp = this.tempPath(file);
49634
+ try {
49635
+ await fsp.mkdir(path4.dirname(file), { recursive: true });
49636
+ await fsp.writeFile(tmp, data, "utf8");
49637
+ await fsp.rename(tmp, file);
49638
+ this.discovered.set(id, file);
49639
+ this.clearFailure(id);
49640
+ await this.removeSpill(file);
49641
+ return;
49642
+ } catch (e) {
49643
+ lastErr = e;
49644
+ await fsp.unlink(tmp).catch(() => {
49645
+ });
49646
+ if (!isTransientFsError(e) || attempt === this.retryAttempts - 1) break;
49647
+ const { promise, resolve } = Promise.withResolvers();
49648
+ setTimeout(resolve, this.backoffMs(attempt));
49649
+ await promise;
49650
+ }
49624
49651
  }
49625
- const tmp = this.tempPath(file);
49626
- const env = this.envelope(id, payload);
49627
- try {
49628
- await fsp.writeFile(tmp, JSON.stringify(env), "utf8");
49629
- await this.renameWithRetry(tmp, file);
49630
- this.discovered.set(id, file);
49631
- this.clearFailure(id);
49632
- await this.removeSpill(file);
49633
- } catch (e) {
49634
- await fsp.unlink(tmp).catch(() => {
49635
- });
49636
- let spillPath = null;
49652
+ const spill = this.spillPathFor(file);
49653
+ let spillPath = null;
49654
+ for (let attempt = 0; attempt < this.retryAttempts && spillPath === null; attempt++) {
49637
49655
  try {
49638
- const spill = this.spillPathFor(file);
49639
- await fsp.writeFile(spill, JSON.stringify(env), "utf8");
49656
+ await fsp.mkdir(path4.dirname(spill), { recursive: true });
49657
+ await fsp.writeFile(spill, data, "utf8");
49640
49658
  spillPath = spill;
49641
- this.discovered.set(id, spill);
49642
- } catch {
49659
+ } catch (e) {
49660
+ if (!isTransientFsError(e) || attempt === this.retryAttempts - 1) break;
49661
+ const { promise, resolve } = Promise.withResolvers();
49662
+ setTimeout(resolve, this.backoffMs(attempt));
49663
+ await promise;
49643
49664
  }
49644
- this.recordFailure(id, e, spillPath);
49645
- throw e;
49646
49665
  }
49666
+ if (spillPath) this.discovered.set(id, spillPath);
49667
+ this.recordFailure(id, lastErr, spillPath);
49668
+ throw lastErr;
49647
49669
  }
49648
49670
  envelope(id, payload) {
49649
49671
  return { version: this.version, savedAt: Date.now(), id, payload };
@@ -49709,27 +49731,6 @@ var StateStore = class {
49709
49731
  clearFailure(id) {
49710
49732
  this.failCounts.delete(id);
49711
49733
  }
49712
- /** fs.rename with exponential-backoff retries on Windows transient locks
49713
- * (EPERM/EBUSY/EACCES from AV scan, search indexer, SMB). Other errors
49714
- * are real and rethrown immediately. */
49715
- async renameWithRetry(src, dest) {
49716
- let lastErr;
49717
- for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
49718
- try {
49719
- await fsp.rename(src, dest);
49720
- return;
49721
- } catch (e) {
49722
- lastErr = e;
49723
- const code = e.code;
49724
- if (code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw e;
49725
- if (attempt === this.retryAttempts - 1) throw e;
49726
- const { promise, resolve } = Promise.withResolvers();
49727
- setTimeout(resolve, this.backoffMs(attempt));
49728
- await promise;
49729
- }
49730
- }
49731
- throw lastErr;
49732
- }
49733
49734
  /** Parse + validate one file. Corrupt or invalid records return null
49734
49735
  * (logged) instead of throwing — load paths must never block boot. */
49735
49736
  readEnvelope(file) {
@@ -49805,6 +49806,10 @@ function isEnvelopeLike(value) {
49805
49806
  function flatFileNameFor(id) {
49806
49807
  return createHash2("sha256").update(id, "utf8").digest("hex").slice(0, 24) + ".json";
49807
49808
  }
49809
+ function isTransientFsError(e) {
49810
+ const code = e.code;
49811
+ return code === "EPERM" || code === "EBUSY" || code === "EACCES" || code === "ENOENT" || code === "ENOTDIR";
49812
+ }
49808
49813
  function errText(e) {
49809
49814
  return e instanceof Error ? e.message : String(e);
49810
49815
  }
@@ -49813,6 +49818,57 @@ function rel(p2, base) {
49813
49818
  return r && !r.startsWith("..") ? r : p2;
49814
49819
  }
49815
49820
 
49821
+ // src/persist-eperm.ts
49822
+ var LOCK_CODES = /\b(EPERM|EBUSY|EACCES)\b/;
49823
+ var WRITE_FAIL_RE = /^\[persist\] write failed for (.+?) \(total (\d+)x\): (.+)$/;
49824
+ var PersistEpermAlert = class {
49825
+ dir;
49826
+ threshold;
49827
+ repeatMs;
49828
+ platform;
49829
+ now;
49830
+ onAlert;
49831
+ hasAlerted = false;
49832
+ alertedAt = 0;
49833
+ constructor(opts) {
49834
+ this.dir = opts.dir;
49835
+ this.threshold = opts.threshold ?? 5;
49836
+ this.repeatMs = opts.repeatMs ?? 0;
49837
+ this.platform = opts.platform ?? process.platform;
49838
+ this.now = opts.now ?? Date.now;
49839
+ this.onAlert = opts.onAlert ?? ((d, c) => log("warn", buildAlertMessage(d, c)));
49840
+ }
49841
+ // Returns true iff an alert was emitted. Runs on every kernel log line, so
49842
+ // the non-matching cases bail before any allocation.
49843
+ observe(level, msg) {
49844
+ if (this.platform !== "win32") return false;
49845
+ if (level !== "error" && level !== "warn") return false;
49846
+ const m2 = WRITE_FAIL_RE.exec(msg);
49847
+ if (!m2) return false;
49848
+ const count = Number(m2[2]);
49849
+ if (count < this.threshold) return false;
49850
+ if (!LOCK_CODES.test(m2[3])) return false;
49851
+ if (!this.shouldAlert()) return false;
49852
+ this.hasAlerted = true;
49853
+ this.alertedAt = this.now();
49854
+ this.onAlert(this.dir, count);
49855
+ return true;
49856
+ }
49857
+ shouldAlert() {
49858
+ if (!this.hasAlerted) return true;
49859
+ if (this.repeatMs <= 0) return false;
49860
+ return this.now() - this.alertedAt >= this.repeatMs;
49861
+ }
49862
+ };
49863
+ function buildAlertMessage(dir, count) {
49864
+ return [
49865
+ `[persist] session dir ${dir} failed ${count} consecutive writes (EPERM \u2014 rename is locked).`,
49866
+ "Likely cause: Windows Defender / antivirus / indexer / a sync tool is locking the directory.",
49867
+ `Fix: add ${dir} to Windows Defender exclusions (Settings \u2192 Virus & threat protection \u2192 Manage settings \u2192 Exclusions \u2192 Add an exclusion),`,
49868
+ "and make sure no OneDrive / sync tool is syncing this path."
49869
+ ].join("\n");
49870
+ }
49871
+
49816
49872
  // src/persist.ts
49817
49873
  var PERSIST_VERSION = 3;
49818
49874
  var MIGRATION_MARKER = ".bili-migration-286.done";
@@ -49850,12 +49906,21 @@ var SessionStore = class {
49850
49906
  const debounceMs = opts?.debounceMs ?? defaultDebounce();
49851
49907
  this.enabled = (opts?.enabled ?? true) && debounceMs >= 0;
49852
49908
  this.dir = opts?.dir ?? defaultDir();
49909
+ const baseLog = opts?.log ?? defaultLogger;
49910
+ const epermAlert = new PersistEpermAlert({
49911
+ dir: this.dir,
49912
+ threshold: epermAlertThreshold(),
49913
+ repeatMs: epermAlertRepeatMs()
49914
+ });
49853
49915
  this.store = new StateStore({
49854
49916
  dir: this.dir,
49855
49917
  version: PERSIST_VERSION,
49856
49918
  debounceMs: Math.max(0, debounceMs),
49857
49919
  enabled: this.enabled,
49858
- log: opts?.log ?? defaultLogger,
49920
+ log: (level, msg) => {
49921
+ epermAlert.observe(level, msg);
49922
+ baseLog(level, msg);
49923
+ },
49859
49924
  relPath: (id, payload) => relPathFor(id, payload.meta?.protocol ?? payload.protocol, payload.meta?.upstreamOrigin ?? payload.upstreamOrigin),
49860
49925
  // Adopt the pre-envelope flat format this store itself wrote
49861
49926
  // before the kernel extraction (and every v1/v2 file before it).
@@ -50123,6 +50188,22 @@ function persistEnabled() {
50123
50188
  if (env === "0" || env === "false") return false;
50124
50189
  return true;
50125
50190
  }
50191
+ function epermAlertThreshold() {
50192
+ const env = process.env.BILI_PERSIST_EPERM_ALERT_THRESHOLD;
50193
+ if (env) {
50194
+ const n = Number.parseInt(env, 10);
50195
+ if (Number.isFinite(n) && n > 0) return n;
50196
+ }
50197
+ return 5;
50198
+ }
50199
+ function epermAlertRepeatMs() {
50200
+ const env = process.env.BILI_PERSIST_EPERM_ALERT_REPEAT_MS;
50201
+ if (env) {
50202
+ const n = Number.parseInt(env, 10);
50203
+ if (Number.isFinite(n) && n >= 0) return n;
50204
+ }
50205
+ return 0;
50206
+ }
50126
50207
  function defaultLogger(level, m2) {
50127
50208
  log(level, m2);
50128
50209
  }
@@ -50353,7 +50434,7 @@ function withStagedCompressGuidance(text) {
50353
50434
  }
50354
50435
 
50355
50436
  // src/decompress-shared.ts
50356
- import { mkdirSync as mkdirSync4, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
50437
+ import { mkdirSync as mkdirSync4, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
50357
50438
  import { dirname as dirname2, join as join2 } from "path";
50358
50439
  import { tmpdir } from "os";
50359
50440
  var trackedTempFiles = [];
@@ -50369,7 +50450,7 @@ function reapTempFiles() {
50369
50450
  const oldest = trackedTempFiles.shift();
50370
50451
  if (!oldest) break;
50371
50452
  try {
50372
- unlinkSync(oldest.path);
50453
+ unlinkSync2(oldest.path);
50373
50454
  } catch {
50374
50455
  }
50375
50456
  }
@@ -50377,7 +50458,7 @@ function reapTempFiles() {
50377
50458
  process.on("beforeExit", () => {
50378
50459
  for (const f2 of trackedTempFiles) {
50379
50460
  try {
50380
- unlinkSync(f2.path);
50461
+ unlinkSync2(f2.path);
50381
50462
  } catch {
50382
50463
  }
50383
50464
  }
@@ -51262,7 +51343,7 @@ function busy(button,on,label){if(!button)return;if(on){button.dataset.label=but
51262
51343
  async function json(url,options){const response=await fetch(url,options);const data=await response.json().catch(()=>({}));if(!response.ok)throw new Error(data.error||data.detail||("HTTP "+response.status));return data}
51263
51344
  function showPage(name){document.querySelectorAll(".page").forEach((node)=>node.classList.toggle("active",node.id==="page-"+name));document.querySelectorAll(".nav button").forEach((node)=>node.classList.toggle("active",node.dataset.page===name));if(name==="sessions")loadSessions();if(name==="upstream"){loadUpstream();loadOverrides()}}
51264
51345
  document.querySelectorAll(".nav button").forEach((button)=>button.addEventListener("click",()=>showPage(button.dataset.page)));
51265
- async function loadConfig(){const data=await json("/__bili/config");if(data.parseError)toast(String(data.parseError),true);byId("providers-json").value=JSON.stringify(data.providers||{},null,2);byId("proxy-url").value=data.upstreamProxy||"";byId("compress-json").value=data.compress?JSON.stringify(data.compress,null,2):"";document.querySelectorAll('input[name="proxy-mode"]').forEach((input)=>input.checked=input.value===(data.upstreamProxyMode||"direct"));return data}
51346
+ async function loadConfig(){const data=await json("/__bili/config");if(data.parseError)toast(String(data.parseError),true);byId("providers-json").value=JSON.stringify(data.providers||{},null,2);byId("proxy-url").value=data.upstreamProxy||"";byId("compress-json").value=data.compress?JSON.stringify(data.compress,null,2):"";document.querySelectorAll('input[name="proxy-mode"]').forEach((input)=>input.checked=input.value===(data.upstreamProxyMode||"direct"));const pt=data.passthrough||{enabled:false};const banner=byId("passthrough-banner");if(banner){if(pt.enabled){banner.hidden=false;byId("passthrough-source").textContent=pt.source==="env"?"\u73AF\u5883\u53D8\u91CF ACP_PASSTHROUGH\uFF08\u6216 --passthrough \u53C2\u6570\uFF09":"\u914D\u7F6E\u6587\u4EF6 passthrough: true";const btn=byId("clear-passthrough");btn.disabled=pt.source==="env";btn.title=pt.source==="env"?"\u9700\u5148 unset ACP_PASSTHROUGH \u5E76\u91CD\u542F":""}else{banner.hidden=true}}return data}
51266
51347
  async function loadUpstream(){try{const data=await json("/__bili/upstream");const labels={"provider":"Provider \u5355\u72EC\u4EE3\u7406","provider-direct":"Provider \u76F4\u8FDE","bili-env":"BILI_UPSTREAM_PROXY","web-manual":"Web \u624B\u52A8\u4EE3\u7406","config":"\u914D\u7F6E\u6587\u4EF6\u4EE3\u7406","HTTPS_PROXY":"HTTPS_PROXY","HTTP_PROXY":"HTTP_PROXY","ALL_PROXY":"ALL_PROXY","windows-system":"Windows \u7CFB\u7EDF\u4EE3\u7406","windows-bypass":"Windows \u7ED5\u8FC7\u5217\u8868","no-proxy":"NO_PROXY","direct":"\u76F4\u8FDE"};byId("upstream-source").textContent=labels[data.source]||data.source||"\u76F4\u8FDE";byId("upstream-effective").textContent=data.proxy||"direct";byId("upstream-pac").textContent=data.autoConfigUrl||"\u2014";const state=byId("upstream-state");state.className="status "+(data.connected===true?"ok":data.connected===false?"err":"");state.textContent=data.connected===true?"CONNECT \u6B63\u5E38":data.connected===false?(data.error||"\u8FDE\u63A5\u5931\u8D25"):"\u5C1A\u672A\u89C2\u5BDF\u5230\u4E0A\u6E38\u8FDE\u63A5"}catch(error){byId("upstream-state").textContent=String(error)}}
51267
51348
  async function saveUpstream(){const button=byId("save-upstream");busy(button,true,"\u4FDD\u5B58\u4E2D\u2026");const mode=document.querySelector('input[name="proxy-mode"]:checked').value;try{await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({upstreamProxyMode:mode,upstreamProxy:byId("proxy-url").value.trim()||null})});toast("\u4E0A\u6E38\u8BBE\u7F6E\u5DF2\u70ED\u66F4\u65B0");await loadUpstream()}catch(error){toast(String(error),true)}finally{busy(button,false)}}
51268
51349
  async function testUpstream(){const button=byId("test-upstream");busy(button,true,"\u6D4B\u8BD5\u4E2D\u2026");try{const data=await json("/__bili/upstream/test",{method:"POST"});toast("\u8FDE\u63A5\u6210\u529F\uFF0CHTTP "+data.status);await loadUpstream()}catch(error){toast(String(error),true);await loadUpstream()}finally{busy(button,false)}}
@@ -51272,7 +51353,9 @@ async function loadOverrides(){try{const data=await loadConfig();const providers
51272
51353
  async function saveOverrides(){const button=byId("save-overrides");busy(button,true,"\u4FDD\u5B58\u4E2D\u2026");try{const data=await loadConfig();const providers=data.providers||{};document.querySelectorAll(".override-proxy").forEach((input)=>{const url=input.dataset.url;if(!url)return;if(!providers[url])providers[url]={};const val=input.value.trim();if(val)providers[url].proxy=val;else delete providers[url].proxy});await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({providers})});await json("/__bili/config/reload",{method:"POST"});toast("\u6309 URL \u8986\u76D6\u5DF2\u70ED\u66F4\u65B0");await loadOverrides()}catch(error){toast(String(error),true)}finally{busy(button,false)}}
51273
51354
  async function loadSessions(){try{const data=await json("/__bili/stats");const rows=(data.sessions||[]).map((item)=>"<tr><td>"+escapeHtml(item.title||"\u2014")+"</td><td>"+escapeHtml(item.protocol||"\u2014")+"</td><td class=mono>"+escapeHtml(item.label||"\u2014")+"</td><td>"+escapeHtml(item.requests)+"</td><td>"+escapeHtml(item.contextTokens)+"</td><td>"+escapeHtml(new Date(item.lastSeen).toLocaleString())+(item.restored?" <span class=muted>(\u6062\u590D\uFF0C\u5F00\u673A\u540E\u672A\u6D3B\u52A8)</span>":"")+"</td></tr>").join("");byId("sessions-body").innerHTML=rows||'<tr><td colspan="6">\u6682\u65E0\u4F1A\u8BDD</td></tr>'}catch(error){toast(String(error),true);throw error}}
51274
51355
  async function refreshSessions(){const button=byId("refresh-sessions");busy(button,true,"\u5237\u65B0\u4E2D\u2026");try{await loadSessions();toast("\u4F1A\u8BDD\u5217\u8868\u5DF2\u5237\u65B0")}catch{}finally{busy(button,false)}}
51275
- document.querySelectorAll(".copy-btn").forEach((btn)=>btn.addEventListener("click",async()=>{busy(btn,true,"\u590D\u5236\u4E2D\u2026");try{await navigator.clipboard.writeText(btn.dataset.copy||"");toast("\u5DF2\u590D\u5236")}catch(e){toast(String(e),true)}finally{busy(btn,false)}}));byId("save-upstream").addEventListener("click",saveUpstream);byId("test-upstream").addEventListener("click",testUpstream);byId("save-providers").addEventListener("click",saveProviders);byId("save-compress").addEventListener("click",saveCompress);byId("save-overrides").addEventListener("click",saveOverrides);byId("refresh-sessions").addEventListener("click",refreshSessions);
51356
+ document.querySelectorAll(".copy-btn").forEach((btn)=>btn.addEventListener("click",async()=>{busy(btn,true,"\u590D\u5236\u4E2D\u2026");try{await navigator.clipboard.writeText(btn.dataset.copy||"");toast("\u5DF2\u590D\u5236")}catch(e){toast(String(e),true)}finally{busy(btn,false)}}));async function clearPassthrough(){const button=byId("clear-passthrough");busy(button,true,"\u5904\u7406\u4E2D\u2026");try{await json("/__bili/config",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify({passthrough:null})});toast("\u5DF2\u6062\u590D\u538B\u7F29");await loadConfig()}catch(error){toast(String(error),true)}finally{busy(button,false)}}
51357
+ byId("clear-passthrough").addEventListener("click",clearPassthrough);
51358
+ byId("save-upstream").addEventListener("click",saveUpstream);byId("test-upstream").addEventListener("click",testUpstream);byId("save-providers").addEventListener("click",saveProviders);byId("save-compress").addEventListener("click",saveCompress);byId("save-overrides").addEventListener("click",saveOverrides);byId("refresh-sessions").addEventListener("click",refreshSessions);
51276
51359
  Promise.all([loadConfig(),loadUpstream()]).catch((error)=>toast(String(error),true));setInterval(()=>{if(byId("page-sessions").classList.contains("active"))loadSessions().catch(()=>{})},5000);
51277
51360
  `;
51278
51361
 
@@ -51294,7 +51377,7 @@ function renderPage(origin, version2) {
51294
51377
  const caReady = existsSync4(caPath);
51295
51378
  return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>billion-context</title><style>${WEB_STYLES}</style></head><body>
51296
51379
  <div class="app"><aside class="sidebar"><div class="brand"><div class="brand-title">billion<span>-context</span></div><div class="version">v${version2}</div></div><a class="fork-ribbon" href="https://github.com/ranxianglei/billion-context" target="_blank" rel="noopener">\u2605 Fork me on GitHub</a><nav class="nav"><button class="active" data-page="overview">\u6982\u89C8</button><button data-page="routing">\u63A5\u5165</button><button data-page="upstream">\u4E0A\u6E38</button><button data-page="sessions">\u4F1A\u8BDD</button><button data-page="settings">\u8BBE\u7F6E</button></nav></aside>
51297
- <main class="main"><section id="page-overview" class="page active"><h1>\u6982\u89C8</h1><p class="lead">\u672C\u5730\u8DEF\u7531\u4E0E\u4E0A\u6E38\u7F51\u7EDC\u76F8\u4E92\u72EC\u7ACB\uFF0CACP \u538B\u7F29\u8FD0\u884C\u5728\u4E24\u8005\u4E4B\u95F4\u3002</p><div class="grid"><div class="card"><h2>\u670D\u52A1\u5730\u5740</h2><p class="mono">${origin}</p></div><div class="card"><h2>\u5BA2\u6237\u7AEF\u63A5\u5165</h2><p class="status">\u5728"\u63A5\u5165"\u9875\u5C06\u5404\u5BA2\u6237\u7AEF\uFF08Codex / Claude Code / OpenCode / Pi \u7B49\uFF09\u7684\u6D41\u91CF\u8DEF\u7531\u5230 bili\u3002</p></div></div></section>
51380
+ <main class="main"><section id="page-overview" class="page active"><h1>\u6982\u89C8</h1><p class="lead">\u672C\u5730\u8DEF\u7531\u4E0E\u4E0A\u6E38\u7F51\u7EDC\u76F8\u4E92\u72EC\u7ACB\uFF0CACP \u538B\u7F29\u8FD0\u884C\u5728\u4E24\u8005\u4E4B\u95F4\u3002</p><div id="passthrough-banner" class="notice" hidden>\u26A0\uFE0F <strong>Passthrough \u5DF2\u542F\u7528</strong>\uFF1A\u6240\u6709\u8BF7\u6C42\u539F\u6837\u8F6C\u53D1\uFF0C\u538B\u7F29\u5B8C\u5168\u5173\u95ED\uFF0C\u4E0D\u4F1A\u8282\u7701\u4EFB\u4F55 token\u3002\u6765\u6E90\uFF1A<span id="passthrough-source" class="mono"></span>\u3002 <button id="clear-passthrough" class="btn">\u6062\u590D\u538B\u7F29</button></div><div class="grid"><div class="card"><h2>\u670D\u52A1\u5730\u5740</h2><p class="mono">${origin}</p></div><div class="card"><h2>\u5BA2\u6237\u7AEF\u63A5\u5165</h2><p class="status">\u5728"\u63A5\u5165"\u9875\u5C06\u5404\u5BA2\u6237\u7AEF\uFF08Codex / Claude Code / OpenCode / Pi \u7B49\uFF09\u7684\u6D41\u91CF\u8DEF\u7531\u5230 bili\u3002</p></div></div></section>
51298
51381
  <section id="page-routing" class="page"><h1>\u5BA2\u6237\u7AEF\u63A5\u5165</h1><p class="lead">\u4E24\u7C7B\u63A5\u5165\uFF1AA. \u63A8\u8350\u63A5\u5165\u65B9\u5F0F\uFF08<span class="mono">/bili/</span> \u524D\u7F00\uFF0C\u96F6\u914D\u7F6E\uFF09\uFF1BB. \u8BC1\u4E66\u63A5\u5165\u65B9\u5F0F\uFF08MITM\uFF0C\u9700\u4FE1\u4EFB CA \u8BC1\u4E66\uFF09\u3002</p>
51299
51382
  <h2 class="section-title">A. \u63A8\u8350\u63A5\u5165\u65B9\u5F0F\uFF08/bili/ \u524D\u7F00\uFF09</h2>
51300
51383
  <p class="hint">\u5728\u5BA2\u6237\u7AEF\u914D\u7F6E\u91CC\u628A\u539F baseURL \u524D\u52A0\u4E0A <span class="mono">${origin}/bili/</span> \u5373\u53EF\uFF0C\u5176\u4F59\u4E0D\u53D8\u3002</p>
@@ -51313,7 +51396,7 @@ function renderPage(origin, version2) {
51313
51396
  }
51314
51397
 
51315
51398
  // src/web/api.ts
51316
- import { closeSync, existsSync as existsSync5, fsyncSync, mkdirSync as mkdirSync5, openSync, renameSync as renameSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "fs";
51399
+ import { closeSync, existsSync as existsSync5, fsyncSync, mkdirSync as mkdirSync5, openSync, renameSync as renameSync2, unlinkSync as unlinkSync3, writeFileSync as writeFileSync4 } from "fs";
51317
51400
  import { dirname as dirname3 } from "path";
51318
51401
  import { randomUUID as randomUUID2 } from "crypto";
51319
51402
  function readConfig() {
@@ -51352,7 +51435,7 @@ function atomicWriteConfig(config) {
51352
51435
  } catch (error) {
51353
51436
  if (descriptor !== void 0) closeSync(descriptor);
51354
51437
  try {
51355
- unlinkSync2(tempPath);
51438
+ unlinkSync3(tempPath);
51356
51439
  } catch {
51357
51440
  }
51358
51441
  throw error;
@@ -51370,6 +51453,7 @@ async function handleConfigGet(res) {
51370
51453
  upstreamProxy: upstream.proxy ?? null,
51371
51454
  upstreamProxyMode: upstream.mode,
51372
51455
  compress: config.compress ?? null,
51456
+ passthrough: passthroughState(process.env),
51373
51457
  ...parseError ? { parseError } : {}
51374
51458
  }, null, 2));
51375
51459
  }
@@ -51383,7 +51467,8 @@ async function handleConfigPut(req, res, onChanged, biliPort = 8787) {
51383
51467
  const hasProxy = Object.prototype.hasOwnProperty.call(body, "upstreamProxy");
51384
51468
  const hasMode = Object.prototype.hasOwnProperty.call(body, "upstreamProxyMode");
51385
51469
  const hasCompress = Object.prototype.hasOwnProperty.call(body, "compress");
51386
- if (!hasProviders && !hasProxy && !hasMode && !hasCompress) return sendError(res, 400, "expected providers, upstream proxy, or compress settings");
51470
+ const hasPassthrough = Object.prototype.hasOwnProperty.call(body, "passthrough");
51471
+ if (!hasProviders && !hasProxy && !hasMode && !hasCompress && !hasPassthrough) return sendError(res, 400, "expected providers, upstream proxy, compress, or passthrough settings");
51387
51472
  const routes = {};
51388
51473
  if (hasProviders) {
51389
51474
  if (!body.providers || typeof body.providers !== "object" || Array.isArray(body.providers)) {
@@ -51427,6 +51512,14 @@ async function handleConfigPut(req, res, onChanged, biliPort = 8787) {
51427
51512
  compress = body.compress === null ? {} : parseCompressSettings(body.compress);
51428
51513
  if (compress === void 0) return sendError(res, 400, "invalid compress settings");
51429
51514
  }
51515
+ if (hasPassthrough) {
51516
+ if (body.passthrough !== null && typeof body.passthrough !== "boolean") {
51517
+ return sendError(res, 400, "passthrough must be a boolean or null");
51518
+ }
51519
+ if (passthroughState(process.env).source === "env") {
51520
+ return sendError(res, 409, "passthrough is forced by the ACP_PASSTHROUGH environment variable (or --passthrough flag); unset it and restart to change here");
51521
+ }
51522
+ }
51430
51523
  const config = readConfig();
51431
51524
  if (hasProviders) config.providers = routes;
51432
51525
  if (hasProxy) {
@@ -51438,6 +51531,10 @@ async function handleConfigPut(req, res, onChanged, biliPort = 8787) {
51438
51531
  if (compress && Object.keys(compress).length > 0) config.compress = compress;
51439
51532
  else delete config.compress;
51440
51533
  }
51534
+ if (hasPassthrough) {
51535
+ if (body.passthrough === true) config.passthrough = true;
51536
+ else delete config.passthrough;
51537
+ }
51441
51538
  try {
51442
51539
  atomicWriteConfig(config);
51443
51540
  onChanged?.();
@@ -51448,6 +51545,7 @@ async function handleConfigPut(req, res, onChanged, biliPort = 8787) {
51448
51545
  if (hasProviders) changed.push(`${Object.keys(routes).length} routes`);
51449
51546
  if (hasProxy || hasMode) changed.push("network");
51450
51547
  if (hasCompress) changed.push("compress");
51548
+ if (hasPassthrough) changed.push("passthrough");
51451
51549
  log("info", `[acp-web] configuration updated (${changed.join(", ") || "none"})`);
51452
51550
  res.writeHead(200, { "content-type": "application/json" });
51453
51551
  res.end(JSON.stringify({ ok: true, providers: hasProviders ? Object.keys(routes).length : void 0 }));
@@ -57119,8 +57217,23 @@ Content-Length: ${Buffer.byteLength(body)}\r
57119
57217
  const nOverrides = Object.keys(opts.routes).length;
57120
57218
  log2(
57121
57219
  "info",
57122
- `acp-proxy listening on http://${displayHost}:${actualPort} \u2014 web UI: http://${displayHost}:${actualPort}/__bili/ \u2014 zero-config: prefix any baseURL with http://${displayHost}:${actualPort}/bili/` + (nOverrides ? ` \u2014 context overrides for ${nOverrides} upstream URL(s)` : "") + (opts.mitm.enabled ? ` \u2014 MITM proxy on (whitelist)${opts.mitm.domains.length ? ` +${opts.mitm.domains.join(",")}` : ""}` : "")
57220
+ `acp-proxy listening on http://${displayHost}:${actualPort} \u2014 web UI: http://${displayHost}:${actualPort}/__bili/ \u2014 zero-config: prefix any baseURL with http://${displayHost}:${actualPort}/bili/` + (nOverrides ? ` \u2014 context overrides for ${nOverrides} upstream URL(s)` : "") + (opts.mitm.enabled ? ` \u2014 MITM proxy on (whitelist)${opts.mitm.domains.length ? ` +${opts.mitm.domains.join(",")}` : ""}` : "") + (opts.passthrough ? " \u2014 PASSTHROUGH (compression OFF)" : "")
57123
57221
  );
57222
+ if (opts.passthrough) {
57223
+ log2(
57224
+ "warn",
57225
+ `[passthrough] compression is OFF \u2014 every request is forwarded verbatim, no tokens are saved (source: ${opts.passthroughSource === "env" ? "ACP_PASSTHROUGH env var or --passthrough flag" : `config file ${configFile()}`}). ` + (opts.passthroughSource === "env" ? "Unset ACP_PASSTHROUGH (or drop --passthrough) and restart to re-enable compression." : 'Clear it in the web UI (\u6982\u89C8 page) or remove "passthrough": true from the config file to re-enable compression.')
57226
+ );
57227
+ }
57228
+ const envKnobs = [];
57229
+ if (process.env.ACP_PASSTHROUGH !== void 0) envKnobs.push(`ACP_PASSTHROUGH=${process.env.ACP_PASSTHROUGH}`);
57230
+ if (process.env.ACP_MODEL_CONTEXT_LIMIT !== void 0) envKnobs.push(`ACP_MODEL_CONTEXT_LIMIT=${process.env.ACP_MODEL_CONTEXT_LIMIT}`);
57231
+ if (process.env.ACP_COMPRESS_TOOL !== void 0) envKnobs.push(`ACP_COMPRESS_TOOL=${process.env.ACP_COMPRESS_TOOL}`);
57232
+ if (process.env.ACP_COMPRESS_NUDGE !== void 0) envKnobs.push(`ACP_COMPRESS_NUDGE=${process.env.ACP_COMPRESS_NUDGE}`);
57233
+ if (process.env.BILI_PERSIST !== void 0) envKnobs.push(`BILI_PERSIST=${process.env.BILI_PERSIST}`);
57234
+ if (envKnobs.length > 0) {
57235
+ log2("info", `[config] env overrides active (win over the config file): ${envKnobs.join(", ")}`);
57236
+ }
57124
57237
  if (nonLoopbackBind) {
57125
57238
  log2(
57126
57239
  "warn",
@@ -57200,9 +57313,34 @@ var SIDE_REQUEST_MAX_TOKENS = 200;
57200
57313
  function isSideRequest(parsed) {
57201
57314
  if (!parsed || typeof parsed !== "object") return false;
57202
57315
  const p2 = parsed;
57316
+ if (Array.isArray(p2.tools) && p2.tools.length > 0) return false;
57203
57317
  const raw = p2.max_tokens ?? p2.max_completion_tokens ?? p2.max_output_tokens;
57204
57318
  return typeof raw === "number" && raw > 0 && raw <= SIDE_REQUEST_MAX_TOKENS;
57205
57319
  }
57320
+ function outputBudgetField(parsed) {
57321
+ if (!parsed || typeof parsed !== "object") return null;
57322
+ const p2 = parsed;
57323
+ for (const field of ["max_tokens", "max_completion_tokens", "max_output_tokens"]) {
57324
+ if (typeof p2[field] === "number" && p2[field] > 0) return field;
57325
+ }
57326
+ return null;
57327
+ }
57328
+ function restoreOutputBudget(parsed, session, log2) {
57329
+ const field = outputBudgetField(parsed);
57330
+ if (!field) return;
57331
+ const p2 = parsed;
57332
+ const value = p2[field];
57333
+ if (value > SIDE_REQUEST_MAX_TOKENS) {
57334
+ session.metadata.outputBudgetHighWater = value;
57335
+ return;
57336
+ }
57337
+ if (!Array.isArray(p2.tools) || p2.tools.length === 0) return;
57338
+ const highWater = session.metadata.outputBudgetHighWater;
57339
+ if (typeof highWater === "number" && highWater > SIDE_REQUEST_MAX_TOKENS) {
57340
+ p2[field] = highWater;
57341
+ log2("info", `[${session.id}] output budget restored ${value} -> ${highWater} (#546: client shrank it from its raw-history estimate)`);
57342
+ }
57343
+ }
57206
57344
  function isTrustedAdminOrigin(origin, host, trustedHosts) {
57207
57345
  if (!host || !trustedHosts.has(host.toLowerCase())) return false;
57208
57346
  if (!origin) return true;
@@ -57271,6 +57409,14 @@ async function handle(req, res, opts, core, config, log2, instanceId, instanceSt
57271
57409
  if (req.method === "PUT" && req.url === "/__bili/config") {
57272
57410
  return handleConfigPut(req, res, () => {
57273
57411
  const fresh = loadOptions();
57412
+ if (fresh.passthrough !== opts.passthrough) {
57413
+ log2(
57414
+ fresh.passthrough ? "warn" : "info",
57415
+ `[passthrough] ${fresh.passthrough ? "compression turned OFF via web config \u2014 forwarding verbatim" : "compression re-enabled via web config"}`
57416
+ );
57417
+ }
57418
+ opts.passthrough = fresh.passthrough;
57419
+ opts.passthroughSource = fresh.passthroughSource;
57274
57420
  opts.proxy = fresh.proxy;
57275
57421
  opts.proxyMode = fresh.proxyMode;
57276
57422
  opts.proxySource = fresh.proxySource;
@@ -57593,6 +57739,7 @@ ${bodyBuffer.toString("utf8")}`);
57593
57739
  }
57594
57740
  }
57595
57741
  const pluginMode = pluginAgent !== void 0;
57742
+ restoreOutputBudget(parsed, session, log2);
57596
57743
  if (!countTokens && !responsesCompact && protocol !== null && isSideRequest(parsed)) {
57597
57744
  log2("info", `[${session.id}] side request (max_tokens<=${SIDE_REQUEST_MAX_TOKENS}) \u2192 passthrough + tag strip only, kernel state untouched`);
57598
57745
  const sidePrepared = {