From 0bca1b7a31658058b863ec9550550592e14ac87b Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 00:33:39 +0800 Subject: [PATCH 1/2] feat: align launcher auto-compact budgets with bili window (codex -c, claude env) (#321 PR-D) --- src/client-config.ts | 42 +++++- src/launcher.ts | 131 +++++++++++++++- tests/launcher.test.ts | 330 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+), 5 deletions(-) diff --git a/src/client-config.ts b/src/client-config.ts index 510829baf..7e4496dc2 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -8,6 +8,12 @@ import path from "node:path"; export interface ClaudeSettings { anthropicBaseUrl?: string; + /** Model claude runs: settings `env.ANTHROPIC_MODEL` ?? top-level `model`. */ + model?: string; + /** The user's explicit auto-compact window (settings `autoCompactWindow` + * or `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW`) — when set, the launcher + * must NOT override it with its own budget injection (#321). */ + autoCompactWindow?: number; } export interface ModelWindow { @@ -28,6 +34,12 @@ export interface CodexProvider { export interface CodexConfig { modelProvider?: string; openaiBaseUrl?: string; + /** Top-level `model` — the model codex runs (budget alignment, #321). */ + model?: string; + /** Top-level `model_context_window` override (if set). */ + contextWindow?: number; + /** Top-level `model_auto_compact_token_limit` override (if set). */ + autoCompactLimit?: number; /** Top-level `model` + `model_context_window` override pair (if set). */ modelWindows?: ModelWindow[]; providers: Record; @@ -173,18 +185,35 @@ export function readClaudeSettings(homeDir: string, cwd: string, env: NodeJS.Pro path.join(cwd, ".claude", "settings.json"), ]; let anthropicBaseUrl: string | undefined; + let model: string | undefined; + let autoCompactWindow: number | undefined; for (const f of files) { const obj = readJsonObject(f); const settingsEnv = obj?.env; if (settingsEnv && typeof settingsEnv === "object" && !Array.isArray(settingsEnv)) { - const v = (settingsEnv as Record).ANTHROPIC_BASE_URL; + const e = settingsEnv as Record; + const v = e.ANTHROPIC_BASE_URL; if (nonEmpty(v)) anthropicBaseUrl = v; + // env-block values beat same-file top-level settings (claude applies + // the env block as real environment, which outranks settings). + const m = e.ANTHROPIC_MODEL; + if (nonEmpty(m)) model = String(m); + const acw = Number(e.CLAUDE_CODE_AUTO_COMPACT_WINDOW); + if (Number.isFinite(acw) && acw > 0) autoCompactWindow = acw; } + const tm = obj?.model; + if (nonEmpty(tm) && model === undefined) model = String(tm); + const tacw = Number(obj?.autoCompactWindow); + if (Number.isFinite(tacw) && tacw > 0 && autoCompactWindow === undefined) autoCompactWindow = tacw; } // Honor a shell-exported ANTHROPIC_BASE_URL (claude's native override) so // the launcher wraps the relay the user actually uses, not the default. if (!anthropicBaseUrl && nonEmpty(env.ANTHROPIC_BASE_URL)) anthropicBaseUrl = env.ANTHROPIC_BASE_URL; - return anthropicBaseUrl ? { anthropicBaseUrl } : {}; + return { + ...(anthropicBaseUrl ? { anthropicBaseUrl } : {}), + ...(model ? { model } : {}), + ...(autoCompactWindow ? { autoCompactWindow } : {}), + }; } /** @@ -198,6 +227,7 @@ export function parseCodexToml(text: string): CodexConfig { let curProvider: string | null = null; let codexModel: string | undefined; let codexContextWindow: number | undefined; + let codexAutoCompactLimit: number | undefined; for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; @@ -224,10 +254,14 @@ export function parseCodexToml(text: string): CodexConfig { } else if (curProvider && key === "base_url") { result.providers[curProvider].baseUrl = val; } - } else if (numMatch && table === "" && numMatch[1] === "model_context_window") { - codexContextWindow = Number(numMatch[2]); + } else if (numMatch && table === "") { + if (numMatch[1] === "model_context_window") codexContextWindow = Number(numMatch[2]); + else if (numMatch[1] === "model_auto_compact_token_limit") codexAutoCompactLimit = Number(numMatch[2]); } } + if (codexModel) result.model = codexModel; + if (codexContextWindow) result.contextWindow = codexContextWindow; + if (codexAutoCompactLimit) result.autoCompactLimit = codexAutoCompactLimit; const win = toModelWindow(codexModel, codexContextWindow); if (win) result.modelWindows = [win]; return result; diff --git a/src/launcher.ts b/src/launcher.ts index 20b223575..dd7048b7c 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -42,7 +42,9 @@ import { selfPackageRoot, isBiliPiEntry, ompPluginLoadedFrom } from "./plugin-in function selfDistFile(name: string): string { return path.join(selfPackageRoot(), "dist", name); } -import { nonEmpty, resolvePiHome, resolveOmpHome, resolveHermesHome, resolveDshHome, loadClientConfig, collectModelWindows, type ClientConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; +import { nonEmpty, resolvePiHome, resolveOmpHome, resolveHermesHome, resolveDshHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; +import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, type ProviderRoutes } from "./config.js"; +import { contextFromRegistry } from "./registry.js"; export { type ClaudeSettings, @@ -349,6 +351,108 @@ export function buildCodexArgs( return args; } +/** + * PR-D (#321): budget alignment for launcher-spawned agents. Resolves the + * context window bili will use as its compression denominator for `model` — + * the same chain the proxy applies, minus the per-request-only sources + * (anthropic-beta header, plugin report, launcher window): + * per-route per-model config declaration → built-in CONTEXT_LIMIT_TABLE + * → models.dev registry (snapshot-first, offline-safe). + */ +export async function resolveLauncherWindow( + model: string | undefined, + routes: ProviderRoutes, + upstreamUrl: string | undefined, +): Promise { + if (!model) return undefined; + let host: string | undefined; + if (upstreamUrl) { + try { + host = new URL(upstreamUrl).hostname; + } catch { + host = undefined; + } + } + return ( + resolveConfiguredContextLimit(routes, upstreamUrl, model) ?? + lookupContextLimit(model) ?? + (await contextFromRegistry(model, host)) + ); +} + +/** + * PR-D (#321): codex's auto-compact budget is keyed off the window CODEX + * believes the model has (its bundled model table — bili has no say in it), + * while bili's ACP compression is keyed off bili's own window resolution. + * Two uncoordinated budgets (#292): when bili's window exceeds codex's + * perception, codex's ledger (server-reported usage, which bili sees) crosses + * codex's ~90% threshold first and fires its native compaction ahead of ACP. + * + * Injecting `-c model_context_window= -c model_auto_compact_token_limit=` + * (W = bili's effective window) makes codex's auto-compact threshold 90%×W — + * ACP (≈55%×W) always fires first, and codex's LOCAL compaction (benign for + * bili: same-session truncation the kernel deactivates by message id) only + * backstops when ACP fails, before codex's 95% hard cap. codex clamps both + * values to its own max_context_window, so an over-generous W degrades to + * codex's own perception instead of overshooting. + * + * Returns [] (no injection) when: + * - no model is configured (nothing to resolve a window for), + * - the user already set `model_context_window` in codex's config.toml + * (bili's proxy uses exactly that value as its launcher window — the + * budget is already aligned by the user's own declaration), + * - bili resolves no window for the model (no authoritative value to inject). + * A user-set `model_auto_compact_token_limit` is honored (not overridden). + */ +/** The base URL codex will actually call: the selected provider's + * `base_url`, else top-level `openai_base_url`, else codex's built-in + * OpenAI default (model-provider-info: `https://api.openai.com/v1`). */ +export function codexUpstreamUrl(codex: CodexConfig | undefined): string { + const provider = codex?.modelProvider ? codex?.providers?.[codex.modelProvider]?.baseUrl : undefined; + return provider ?? codex?.openaiBaseUrl ?? "https://api.openai.com/v1"; +} + +export async function resolveCodexBudgetArgs(opts: { + model: string | undefined; + clientWindow: number | undefined; + clientAutoCompactLimit: number | undefined; + routes: ProviderRoutes; + upstreamUrl: string | undefined; +}): Promise { + const { model, clientWindow, clientAutoCompactLimit, routes, upstreamUrl } = opts; + if (!model || clientWindow) return []; + const window = await resolveLauncherWindow(model, routes, upstreamUrl); + if (!window) return []; + const limit = clientAutoCompactLimit ?? window; + return ["-c", `model_context_window=${window}`, "-c", `model_auto_compact_token_limit=${limit}`]; +} + +/** + * PR-D (#321): claude-code's auto-compact window is a single env knob — + * `CLAUDE_CODE_AUTO_COMPACT_WINDOW` outranks settings and is clamped DOWN to + * the model window claude itself perceives (never up), so injecting bili's + * window is always safe: it tightens claude's threshold to bili's budget when + * bili's window is smaller, and is a no-op when it is larger. + * + * Returns {} (no injection) when: no model resolvable, the user already set + * an explicit auto-compact window (settings `autoCompactWindow` or + * `env.CLAUDE_CODE_AUTO_COMPACT_WINDOW`, or a shell-exported env var), or + * bili resolves no window for the model. + */ +export async function resolveClaudeBudgetEnv(opts: { + model: string | undefined; + userAutoCompactWindow: number | undefined; + shellAutoCompactWindow: string | undefined; + routes: ProviderRoutes; + upstreamUrl: string | undefined; +}): Promise { + const { model, userAutoCompactWindow, shellAutoCompactWindow, routes, upstreamUrl } = opts; + if (!model || userAutoCompactWindow !== undefined || nonEmpty(shellAutoCompactWindow)) return {}; + const window = await resolveLauncherWindow(model, routes, upstreamUrl); + if (!window) return {}; + return { CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(window) }; +} + export function buildClaudeEnv( origin: string, caPath: string, @@ -1278,6 +1382,9 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} const config = loadClientConfig(process.env, process.cwd()); const base = baseClientName(params.client); const routes = discoverRoutes(base, config); + // bili's own route graph (same sources the spawned proxy child reads — + // used to resolve the budget-alignment window, #321). + const biliRoutes = loadRoutes(process.env); const domains = dedupeInOrder([...routes.httpsDomains, ...(params.mitmDomains ?? [])]); const handle = await ensureProxyRunning({ host, port, passthrough, debug, mitmDomains: domains, modelWindows: collectModelWindows(config) }, deps); console.error( @@ -1436,6 +1543,17 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} } else { env = buildCodexEnv(origin, resolveCombinedCaPath(process.env), process.env); clientArgs = buildCodexArgs(origin, routes.httpRewrites, routes.httpsRewrites, clientArgs); + const budgetArgs = await resolveCodexBudgetArgs({ + model: config.codex?.model, + clientWindow: config.codex?.contextWindow, + clientAutoCompactLimit: config.codex?.autoCompactLimit, + routes: biliRoutes, + upstreamUrl: codexUpstreamUrl(config.codex), + }); + if (budgetArgs.length > 0) { + clientArgs = [...budgetArgs, ...clientArgs]; + console.error(`bili: codex budget aligned — ${budgetArgs.slice(2).join(", ")} (model: ${config.codex?.model})`); + } if (injectMcp && codexConversationId) clientArgs = [...buildCodexMcpArgs(origin, codexConversationId), ...clientArgs]; } } else { @@ -1443,6 +1561,17 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} ? buildClaudePluginEnv(origin, true, process.env) : buildClaudeEnv(origin, ca, routes.httpRewrites, routes.httpsRewrites, process.env); if (directUrl) env.BILLION_CONTEXT_PROXY = origin; + const claudeBudget = await resolveClaudeBudgetEnv({ + model: nonEmpty(process.env.ANTHROPIC_MODEL) ? process.env.ANTHROPIC_MODEL : config.claude?.model, + userAutoCompactWindow: config.claude?.autoCompactWindow, + shellAutoCompactWindow: process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, + routes: biliRoutes, + upstreamUrl: config.claude?.anthropicBaseUrl ?? "https://api.anthropic.com", + }); + Object.assign(env, claudeBudget); + if (claudeBudget.CLAUDE_CODE_AUTO_COMPACT_WINDOW !== undefined) { + console.error(`bili: claude budget aligned — CLAUDE_CODE_AUTO_COMPACT_WINDOW=${claudeBudget.CLAUDE_CODE_AUTO_COMPACT_WINDOW}`); + } if (injectMcp) { const mcpFile = path.join(os.tmpdir(), `bili-mcp-${Date.now()}.json`); fs.writeFileSync(mcpFile, JSON.stringify(buildMcpConfig(origin))); diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 23fa7d5f3..170c1f2e0 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -48,12 +48,18 @@ import { findFreePort, ensureProxyRunning, stopProxy, + resolveLauncherWindow, + resolveCodexBudgetArgs, + resolveClaudeBudgetEnv, + codexUpstreamUrl, + readClaudeSettings, type SpawnChild, type SpawnFn, runLaunch, type ClientConfig, type HttpRewrite, } from "../src/launcher.ts"; +import { _setForTest as registrySetForTest, _resetForTest as registryResetForTest } from "../src/registry.ts"; test("isLaunchClient: pi/claude/codex/omp/opencode/pi-test true, others false", () => { assert.equal(isLaunchClient("pi"), true); @@ -1721,3 +1727,327 @@ test("runLaunch omp: launcher hands per-model windows to the spawned proxy", asy fs.rmSync(home, { recursive: true, force: true }); } }); + +// --- PR-D (#321): launcher budget alignment (codex -c / claude env) --- + +test("codexUpstreamUrl: provider base_url > openai_base_url > built-in default", () => { + assert.equal(codexUpstreamUrl(undefined), "https://api.openai.com/v1"); + assert.equal(codexUpstreamUrl({ providers: {} }), "https://api.openai.com/v1"); + assert.equal( + codexUpstreamUrl({ providers: {}, openaiBaseUrl: "https://relay.example.com/v1" }), + "https://relay.example.com/v1", + ); + assert.equal( + codexUpstreamUrl({ + modelProvider: "relay", + openaiBaseUrl: "https://openai.example.com/v1", + providers: { relay: { baseUrl: "https://relay.example.com/v1" } }, + }), + "https://relay.example.com/v1", + ); + assert.equal( + codexUpstreamUrl({ modelProvider: "missing", openaiBaseUrl: "https://openai.example.com/v1", providers: {} }), + "https://openai.example.com/v1", + ); +}); + +test("resolveLauncherWindow: config route > built-in table > registry > nothing", async () => { + const routes = { "https://api.openai.com/v1": { models: { "gpt-x": { context: 123456 } } } }; + registrySetForTest({}); + try { + assert.equal(await resolveLauncherWindow("gpt-x", routes, "https://api.openai.com/v1"), 123456); + assert.equal(await resolveLauncherWindow("gpt-5.5", {}, "https://api.openai.com/v1"), 400000); + registrySetForTest({ "openai/bili-fallback-model": { limit: { context: 333333 } } }); + assert.equal(await resolveLauncherWindow("bili-fallback-model", {}, "https://api.openai.com/v1"), 333333); + assert.equal(await resolveLauncherWindow("bili-nonexistent-model-xyz", {}, "https://api.openai.com/v1"), undefined); + assert.equal(await resolveLauncherWindow(undefined, routes, "https://api.openai.com/v1"), undefined); + } finally { + registryResetForTest(); + } +}); + +test("resolveCodexBudgetArgs: injects window + same-value limit from bili's chain", async () => { + registrySetForTest({}); + try { + assert.deepEqual( + await resolveCodexBudgetArgs({ model: "gpt-5.5", clientWindow: undefined, clientAutoCompactLimit: undefined, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), + ["-c", "model_context_window=400000", "-c", "model_auto_compact_token_limit=400000"], + ); + const routes = { "https://relay.example.com/v1": { models: { "gpt-x": { context: 123456 } } } }; + assert.deepEqual( + await resolveCodexBudgetArgs({ model: "gpt-x", clientWindow: undefined, clientAutoCompactLimit: undefined, routes, upstreamUrl: "https://relay.example.com/v1" }), + ["-c", "model_context_window=123456", "-c", "model_auto_compact_token_limit=123456"], + ); + registrySetForTest({ "openai/bili-fallback-model": { limit: { context: 333333 } } }); + assert.deepEqual( + await resolveCodexBudgetArgs({ model: "bili-fallback-model", clientWindow: undefined, clientAutoCompactLimit: undefined, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), + ["-c", "model_context_window=333333", "-c", "model_auto_compact_token_limit=333333"], + ); + } finally { + registryResetForTest(); + } +}); + +test("resolveCodexBudgetArgs: no injection when user self-aligned or unresolvable", async () => { + registrySetForTest({}); + try { + assert.deepEqual(await resolveCodexBudgetArgs({ model: "gpt-5.5", clientWindow: 1000000, clientAutoCompactLimit: undefined, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), []); + assert.deepEqual(await resolveCodexBudgetArgs({ model: undefined, clientWindow: undefined, clientAutoCompactLimit: undefined, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), []); + assert.deepEqual(await resolveCodexBudgetArgs({ model: "bili-nonexistent-model-xyz", clientWindow: undefined, clientAutoCompactLimit: undefined, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), []); + } finally { + registryResetForTest(); + } +}); + +test("resolveCodexBudgetArgs: honors user-set model_auto_compact_token_limit", async () => { + assert.deepEqual( + await resolveCodexBudgetArgs({ model: "gpt-5.5", clientWindow: undefined, clientAutoCompactLimit: 111111, routes: {}, upstreamUrl: "https://api.openai.com/v1" }), + ["-c", "model_context_window=400000", "-c", "model_auto_compact_token_limit=111111"], + ); +}); + +test("resolveClaudeBudgetEnv: injects CLAUDE_CODE_AUTO_COMPACT_WINDOW from bili's chain", async () => { + registrySetForTest({}); + try { + assert.deepEqual( + await resolveClaudeBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://api.anthropic.com" }), + { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "200000" }, + ); + const routes = { "https://relay.example.com": { models: { "claude-x": { context: 123456 } } } }; + assert.deepEqual( + await resolveClaudeBudgetEnv({ model: "claude-x", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes, upstreamUrl: "https://relay.example.com" }), + { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "123456" }, + ); + registrySetForTest({ "anthropic/bili-fallback-model": { limit: { context: 333333 } } }); + assert.deepEqual( + await resolveClaudeBudgetEnv({ model: "bili-fallback-model", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://api.anthropic.com" }), + { CLAUDE_CODE_AUTO_COMPACT_WINDOW: "333333" }, + ); + } finally { + registryResetForTest(); + } +}); + +test("resolveClaudeBudgetEnv: no injection when user self-aligned or unresolvable", async () => { + registrySetForTest({}); + try { + assert.deepEqual(await resolveClaudeBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: 300000, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://api.anthropic.com" }), {}); + assert.deepEqual(await resolveClaudeBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, shellAutoCompactWindow: "250000", routes: {}, upstreamUrl: "https://api.anthropic.com" }), {}); + assert.deepEqual(await resolveClaudeBudgetEnv({ model: undefined, userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://api.anthropic.com" }), {}); + assert.deepEqual(await resolveClaudeBudgetEnv({ model: "bili-nonexistent-model-xyz", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://api.anthropic.com" }), {}); + } finally { + registryResetForTest(); + } +}); + +test("parseCodexToml: stores model / model_context_window / model_auto_compact_token_limit", () => { + const cfg = parseCodexToml(` +model = "gpt-5.5" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 + +[model_providers.relay] +base_url = "https://relay.example.com/v1" +`); + assert.equal(cfg.model, "gpt-5.5"); + assert.equal(cfg.contextWindow, 1000000); + assert.equal(cfg.autoCompactLimit, 900000); + assert.deepEqual(cfg.modelWindows, [{ id: "gpt-5.5", contextWindow: 1000000 }]); + const bare = parseCodexToml(`model = "gpt-5.5"\n`); + assert.equal(bare.model, "gpt-5.5"); + assert.equal(bare.contextWindow, undefined); + assert.equal(bare.autoCompactLimit, undefined); + assert.equal(bare.modelWindows, undefined); +}); + +test("readClaudeSettings: model from env block / top-level, autoCompactWindow from both forms", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-claude-settings-")); + try { + const settingsDir = path.join(home, ".claude"); + fs.mkdirSync(settingsDir, { recursive: true }); + // top-level model + autoCompactWindow + fs.writeFileSync(path.join(settingsDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5", autoCompactWindow: 300000 })); + let cfg = readClaudeSettings(home, os.tmpdir(), {}); + assert.equal(cfg.model, "claude-sonnet-4-5"); + assert.equal(cfg.autoCompactWindow, 300000); + // env block beats same-file top-level + fs.writeFileSync( + path.join(settingsDir, "settings.json"), + JSON.stringify({ model: "claude-sonnet-4-5", autoCompactWindow: 300000, env: { ANTHROPIC_MODEL: "claude-opus-4-5", CLAUDE_CODE_AUTO_COMPACT_WINDOW: "250000" } }), + ); + cfg = readClaudeSettings(home, os.tmpdir(), {}); + assert.equal(cfg.model, "claude-opus-4-5"); + assert.equal(cfg.autoCompactWindow, 250000); + // nothing set → empty object + fs.writeFileSync(path.join(settingsDir, "settings.json"), JSON.stringify({})); + cfg = readClaudeSettings(home, os.tmpdir(), {}); + assert.deepEqual(cfg, {}); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("runLaunch codex: budget args injected for MITM mode (built-in table window)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-codex-budget-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevClientBin = process.env.BILI_CLIENT_BIN; + const prevAnthropicModel = process.env.ANTHROPIC_MODEL; + const prevAutoCompact = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + delete process.env.ANTHROPIC_MODEL; + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + const fakeCodex = path.join(home, "fake-codex"); + fs.writeFileSync(fakeCodex, ""); + process.env.BILI_CLIENT_BIN = fakeCodex; + const codexHome = path.join(home, ".codex"); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync(path.join(codexHome, "config.toml"), 'model = "gpt-5.5"\n'); + + const clientArgsSeen: string[][] = []; + const spawnImpl: SpawnFn = (cmd, args) => { + if (cmd === fakeCodex) { + clientArgsSeen.push([...args]); + const child = makeFakeChild(0); + const orig = child.on.bind(child); + (child as { on: SpawnChild["on"] }).on = (event, listener) => { + orig(event, listener); + if (event === "exit") setTimeout(() => listener(0, null), 0); + return child; + }; + return child; + } + return makeFakeChild(42422); + }; + const fetchImpl = async () => ({ ok: true }); + const prevExit = process.exit; + process.exit = (() => undefined) as typeof process.exit; + + try { + await runLaunch( + { client: "codex", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientArgsSeen.length, 1); + const args = clientArgsSeen[0]; + const i = args.indexOf("model_context_window=400000"); + assert.ok(i > -1, `expected model_context_window=400000 in ${JSON.stringify(args)}`); + assert.equal(args[i - 1], "-c"); + const j = args.indexOf("model_auto_compact_token_limit=400000"); + assert.ok(j > -1, `expected model_auto_compact_token_limit=400000 in ${JSON.stringify(args)}`); + assert.equal(args[j - 1], "-c"); + + // user self-aligned (model_context_window in config.toml) → no injection + fs.writeFileSync(path.join(codexHome, "config.toml"), 'model = "gpt-5.5"\nmodel_context_window = 1000000\n'); + clientArgsSeen.length = 0; + await runLaunch( + { client: "codex", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientArgsSeen.length, 1); + assert.ok(!clientArgsSeen[0].some((a) => a.startsWith("model_context_window=")), JSON.stringify(clientArgsSeen[0])); + + // no model in config.toml → no injection + fs.writeFileSync(path.join(codexHome, "config.toml"), ""); + clientArgsSeen.length = 0; + await runLaunch( + { client: "codex", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientArgsSeen.length, 1); + assert.ok(!clientArgsSeen[0].some((a) => a.startsWith("model_context_window=")), JSON.stringify(clientArgsSeen[0])); + } finally { + process.exit = prevExit; + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevClientBin === undefined) delete process.env.BILI_CLIENT_BIN; + else process.env.BILI_CLIENT_BIN = prevClientBin; + if (prevAnthropicModel === undefined) delete process.env.ANTHROPIC_MODEL; + else process.env.ANTHROPIC_MODEL = prevAnthropicModel; + if (prevAutoCompact === undefined) delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + else process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = prevAutoCompact; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("runLaunch claude: CLAUDE_CODE_AUTO_COMPACT_WINDOW injected (built-in table window)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-claude-budget-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevClientBin = process.env.BILI_CLIENT_BIN; + const prevAnthropicModel = process.env.ANTHROPIC_MODEL; + const prevAutoCompact = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + delete process.env.ANTHROPIC_MODEL; + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + const fakeClaude = path.join(home, "fake-claude"); + fs.writeFileSync(fakeClaude, ""); + process.env.BILI_CLIENT_BIN = fakeClaude; + const claudeDir = path.join(home, ".claude"); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5" })); + + const clientEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const spawnImpl: SpawnFn = (cmd, args, opts) => { + if (cmd === fakeClaude) { + clientEnvs.push((opts as { env?: NodeJS.ProcessEnv } | undefined)?.env); + const child = makeFakeChild(0); + const orig = child.on.bind(child); + (child as { on: SpawnChild["on"] }).on = (event, listener) => { + orig(event, listener); + if (event === "exit") setTimeout(() => listener(0, null), 0); + return child; + }; + return child; + } + return makeFakeChild(42422); + }; + const fetchImpl = async () => ({ ok: true }); + const prevExit = process.exit; + process.exit = (() => undefined) as typeof process.exit; + + try { + await runLaunch( + { client: "claude", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + assert.equal(clientEnvs[0]?.CLAUDE_CODE_AUTO_COMPACT_WINDOW, "200000"); + + // user self-aligned (settings autoCompactWindow) → no injection + fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5", autoCompactWindow: 300000 })); + clientEnvs.length = 0; + await runLaunch( + { client: "claude", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + assert.equal(clientEnvs[0]?.CLAUDE_CODE_AUTO_COMPACT_WINDOW, undefined); + + // shell-exported ANTHROPIC_MODEL wins over settings model + fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5" })); + process.env.ANTHROPIC_MODEL = "claude-opus-4-5"; + clientEnvs.length = 0; + await runLaunch( + { client: "claude", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + assert.equal(clientEnvs[0]?.CLAUDE_CODE_AUTO_COMPACT_WINDOW, "200000"); + } finally { + process.exit = prevExit; + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevClientBin === undefined) delete process.env.BILI_CLIENT_BIN; + else process.env.BILI_CLIENT_BIN = prevClientBin; + if (prevAnthropicModel === undefined) delete process.env.ANTHROPIC_MODEL; + else process.env.ANTHROPIC_MODEL = prevAnthropicModel; + if (prevAutoCompact === undefined) delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + else process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = prevAutoCompact; + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 8adeef168a4f9867e16b2f9c1eee273c83eaceae Mon Sep 17 00:00:00 2001 From: ework-agent Date: Sat, 29 Aug 2026 02:22:01 +0800 Subject: [PATCH 2/2] docs: manual claude /bili/ routing + auto-compact alignment (#321 PR-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /bili/ prefix section had no Claude Code entry. Add it (claude's undici fetch ignores HTTPS_PROXY, so the /bili/ URL form via ANTHROPIC_BASE_URL is the only manual option) plus a note that manual mode must set CLAUDE_CODE_AUTO_COMPACT_WINDOW itself — the bili claude launcher does this automatically (PR-D) but manual /bili/ users do not get the injection. claude clamps the value down to its perceived model window, so over-setting is safe. EN + zh-CN. --- CONFIGURATION.md | 14 ++++++++++++++ CONFIGURATION.zh-CN.md | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index b7ec2cb07..5d55020be 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -449,6 +449,20 @@ Run `codex login` as usual; the OAuth token travels in the `Authorization` heade "baseUrl": "http://localhost:8787/bili/https://api.anthropic.com" ``` +**Claude Code** — set the `ANTHROPIC_BASE_URL` env var to the `/bili/` URL. (claude's undici fetch ignores `HTTPS_PROXY`, so the `/bili/` URL form is the only manual option — cert MITM cannot intercept it.) + +```bash +export ANTHROPIC_BASE_URL="http://localhost:8787/bili/https://api.anthropic.com" +``` + +> **Auto-compact alignment (manual mode only).** The `bili claude` launcher automatically sets `CLAUDE_CODE_AUTO_COMPACT_WINDOW` to bili's effective window for your model, so claude's own auto-compact threshold lines up with bili's compression budget. In manual `/bili/` mode you must do this yourself — otherwise claude may run its own local auto-compact (a "summarize the conversation" turn) on a threshold that doesn't match bili's window. That is usually harmless (same session-id, so bili re-derives state from the truncation) but noisier than needed. Set it to bili's effective window for your model: +> +> ```bash +> export CLAUDE_CODE_AUTO_COMPACT_WINDOW= +> ``` +> +> claude clamps this value **down** to the window it perceives for the model (never up), so over-setting is safe. You can also set it persistently via claude's settings (`autoCompactWindow`). + **Other API-key clients** (Cursor / Aider / Continue …) — wherever the upstream URL is configured, prepend `http://localhost:8787/bili/`. Nothing else changes. The `/bili/` prefix doubles as a **self-detection signal**: billion-context client extensions (billion-context-pi / opencode-acp) recognize it in their own baseUrl and self-disable, so you never get double compression. diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index ff51a4903..d1df7f1fb 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -449,6 +449,20 @@ openai_base_url = "http://localhost:8787/bili/https://chatgpt.com/backend-api/co "baseUrl": "http://localhost:8787/bili/https://api.anthropic.com" ``` +**Claude Code** —— 把 `ANTHROPIC_BASE_URL` 环境变量设成 `/bili/` URL。(claude 的 undici fetch 忽略 `HTTPS_PROXY`,所以 `/bili/` URL 形式是唯一的手动方式 —— 证书 MITM 拦不到它。) + +```bash +export ANTHROPIC_BASE_URL="http://localhost:8787/bili/https://api.anthropic.com" +``` + +> **自动压缩对齐(仅手动模式)。** `bili claude` launcher 会自动把 `CLAUDE_CODE_AUTO_COMPACT_WINDOW` 设成 bili 对你模型的有效窗口,让 claude 自己的自动压缩阈值与 bili 的压缩预算对齐。手动 `/bili/` 模式下需要你自己设 —— 否则 claude 可能在与 bili 窗口不一致的阈值上跑它自己的本地自动压缩(一次“总结对话”轮次)。这通常无害(同一 session-id,bili 会从截断中重新推导状态),但比必要的更吵。把它设成 bili 对你模型的有效窗口: +> +> ```bash +> export CLAUDE_CODE_AUTO_COMPACT_WINDOW= +> ``` +> +> claude 会把这个值**向下**钳制到它自己感知的模型窗口(不会向上),所以设大了是安全的。也可以持久化到 claude 的 settings(`autoCompactWindow`)里。 + **其他 API-key 客户端(Cursor / Aider / Continue ……)** —— 只要配置了上游 URL,前面加 `http://localhost:8787/bili/` 就行,其他都不用改。 `/bili/` 前缀还是个**自检测信号**:billion-context 的客户端扩展(billion-context-pi / opencode-acp)能在自己的 baseUrl 里认出它并自禁用,避免双层压缩。