From ae0fb6d7fc48f9b171078f14cf4d49e63a6dc2b6 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 8 Sep 2026 17:54:55 +0800 Subject: [PATCH 1/4] feat: add codebuddy client support (bili codebuddy) (#640) --- AGENTS.md | 2 +- README.md | 3 +- src/cli.ts | 4 +- src/client-config.ts | 136 ++++++++++++++++- src/discover.ts | 10 +- src/launcher.ts | 102 ++++++++++++- tests/discover.test.ts | 38 +++++ tests/launcher.test.ts | 324 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 610 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0ff0b8e6..59379430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ billion-context/ │ ├── session-id.ts # Session ID generation │ ├── persist.ts # On-disk session persistence (kernel StateStore) │ ├── update.ts # Auto-update: checks npm, auto-installs latest -│ ├── launcher.ts # `bili ` launchers (pi/codex/claude/omp/opencode/hermes) +│ ├── launcher.ts # `bili ` launchers (pi/codex/claude/omp/opencode/hermes/dsh/codebuddy) │ ├── client-config.ts # READ-only discovery of each client's upstream config │ ├── mitm.ts / ca.ts # Cert-MITM proxying + lazily generated root CA │ ├── mcp.ts # Plugin-in-launcher MCP shell (spawn-time injection) diff --git a/README.md b/README.md index 13e66f71..6f4ff0c9 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ rewrite until dsh gains a settings-path env or an upstream loopback opt-out. Overlay dirs created by older versions are left in place and never merged back into the real home. -### Option 1 — Launcher (`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh`) +### Option 1 — Launcher (`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh` / `bili codebuddy`) The launcher wraps a client in one command: it starts a proxy on an independent port (a fresh instance is always spawned — a port is never @@ -177,6 +177,7 @@ bili omp # pi-style, file-free (#535): env + extens bili opencode # MITM for HTTPS + temp opencode.json (/bili/ for HTTP) + thin /acp plugin bili hermes # file-free (#535): hermes proxy env (HTTPS_PROXY + HERMES_CA_BUNDLE) — https via CONNECT MITM, http via absolute-form forward proxy; real ~/.hermes untouched bili dsh # deepseek-harness: non-loopback upstreams ride proxy envs (https MITM, http absolute-form), loopback keeps the overlay DSH_HOME (~/.dsh-bili) rewrite (#535), built-in deepseek route via DEEPSEEK_BASE_URL, native /acp command injected via --patch +bili codebuddy # Tencent CodeBuddy Code CLI: CODEBUDDY_BASE_URL /bili/ rewrite (Anthropic protocol), budget aligned via CODEBUDDY_AUTO_COMPACT_WINDOW; real ~/.codebuddy untouched bili pi --mitm-domain api.foo.com # add a domain to the MITM whitelist ``` diff --git a/src/cli.ts b/src/cli.ts index abb94860..ed10bff4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,6 +67,7 @@ Usage: bili opencode [opts --] [args] start a proxy + launch opencode against it (cert-MITM) bili hermes [opts --] [args] start a proxy + launch hermes-agent against it (/bili/ rewrite) bili dsh [opts --] [args] start a proxy + launch deepseek-harness against it (/bili/ rewrite) + bili codebuddy [opts --] [args] start a proxy + launch codebuddy against it (/bili/ rewrite) bili test pi non-polluting pi smoke test through the proxy bili export [session] [--full] list sessions / export one as a Markdown handoff (--full includes original messages; --output FILE) @@ -81,7 +82,7 @@ Usage: bili --version print version bili --help show this help -Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili hermes / bili dsh): +Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili hermes / bili dsh / bili codebuddy): Brings up a proxy on an independent port (a fresh instance every launch), then runs the client pointed at it via HTTPS_PROXY + the proxy's MITM CA — no config-file edits. Discovered HTTPS upstream domains are auto-whitelisted for MITM so the proxy TLS-terminates exactly the hosts the @@ -97,6 +98,7 @@ Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili h bili omp # launch omp through the proxy (pi-based; /bili/ rewrite) bili hermes # launch hermes-agent through the proxy (/bili/ rewrite of ~/.hermes/config.yaml) bili dsh --profile web "task" # launch deepseek-harness through the proxy (/bili/ rewrite of ~/.dsh/settings.yaml) + bili codebuddy # launch codebuddy through the proxy (CODEBUDDY_BASE_URL /bili/ rewrite) bili test pi # quick end-to-end check of the pi path bili --mitm-domain api.foo.com pi # add a domain to the MITM whitelist (flags precede the client) bili -F http://127.0.0.1:7897 codex # route bili's upstream through a proxy (gost-style -F) diff --git a/src/client-config.ts b/src/client-config.ts index 67417006..2e5537ec 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -93,6 +93,24 @@ export interface DshConfig { baseUrls: string[]; } +export interface CodebuddyConfig { + /** Model endpoint (Anthropic protocol): settings `env.CODEBUDDY_BASE_URL` + * ?? shell `CODEBUDDY_BASE_URL`. */ + codebuddyBaseUrl?: string; + /** The model codebuddy runs: settings top-level `model`. */ + model?: string; + /** The user's explicit auto-compact window (settings `autoCompactWindow`) + * — when set, the launcher must NOT override it with its own budget + * injection (#321 pattern). */ + autoCompactWindow?: number; + /** Per-model context windows from the two-tier models.json + * (`maxInputTokens`), project-level winning per model id. */ + models?: ModelWindow[]; + /** Per-model `url` values from the two-tier models.json (inventory; the + * launcher does NOT rewrite these in v1 — they bypass CODEBUDDY_BASE_URL). */ + modelUrls?: string[]; +} + export interface ClientConfig { claude?: ClaudeSettings; codex?: CodexConfig; @@ -102,6 +120,7 @@ export interface ClientConfig { opencode?: OpencodeConfig; hermes?: HermesConfig; dsh?: DshConfig; + codebuddy?: CodebuddyConfig; } export function nonEmpty(s: unknown): s is string { @@ -150,6 +169,118 @@ export function resolveDshHome(env: NodeJS.ProcessEnv): string { : path.join(h, ".dsh"); } +/** codebuddy (Tencent CodeBuddy Code CLI) keeps its config under + * CODEBUDDY_CONFIG_DIR (default ~/.codebuddy). */ +export function resolveCodebuddyHome(env: NodeJS.ProcessEnv): string { + const h = os.homedir(); + return nonEmpty(env.CODEBUDDY_CONFIG_DIR) ? env.CODEBUDDY_CONFIG_DIR! + : path.join(h, ".codebuddy"); +} + +/** codebuddy models.json: per-model `url` (OpenAI /chat/completions full + * path) + `maxInputTokens` (context window). The container shape is + * unverified in the wild, so this tolerates a top-level model map, a + * `models` map, or a `models`/top-level array of {id|name, url, + * maxInputTokens} entries. */ +export function parseCodebuddyModelsJson(obj: unknown): { models: ModelWindow[]; urls: string[] } { + const out: { models: ModelWindow[]; urls: string[] } = { models: [], urls: [] }; + const seenUrl = new Set(); + const collect = (id: unknown, entry: unknown): void => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return; + const e = entry as Record; + const url = e.url; + if (typeof url === "string" && url.length > 0 && !seenUrl.has(url)) { + seenUrl.add(url); + out.urls.push(url); + } + const win = toModelWindow(id, e.maxInputTokens); + if (win) out.models.push(win); + }; + if (!obj) return out; + if (Array.isArray(obj)) { + for (const item of obj) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const it = item as Record; + collect(it.id ?? it.name, it); + } + return out; + } + if (typeof obj !== "object") return out; + const root = obj as Record; + const modelsField = root.models; + if (Array.isArray(modelsField)) { + for (const item of modelsField) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const it = item as Record; + collect(it.id ?? it.name, it); + } + return out; + } + if (modelsField && typeof modelsField === "object") { + for (const [id, val] of Object.entries(modelsField as Record)) collect(id, val); + return out; + } + for (const [id, val] of Object.entries(root)) collect(id, val); + return out; +} + +function readJsonFile(filePath: string): unknown { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +/** codebuddy config discovery (read-only): + * - /settings.json: `env.CODEBUDDY_BASE_URL` (Anthropic-protocol + * endpoint), top-level `model`, top-level `autoCompactWindow`; + * - two-tier /models.json + /.codebuddy/models.json (project + * level wins per model id): per-model `url` + `maxInputTokens`. + * A shell-exported CODEBUDDY_BASE_URL (codebuddy's native override) is + * honored when no settings value exists. */ +export function readCodebuddyConfig(codebuddyHome: string, cwd: string, env: NodeJS.ProcessEnv = process.env): CodebuddyConfig { + let codebuddyBaseUrl: string | undefined; + let model: string | undefined; + let autoCompactWindow: number | undefined; + const settings = readJsonObject(path.join(codebuddyHome, "settings.json")); + const settingsEnv = settings?.env; + if (settingsEnv && typeof settingsEnv === "object" && !Array.isArray(settingsEnv)) { + const e = settingsEnv as Record; + const v = e.CODEBUDDY_BASE_URL; + if (nonEmpty(v)) codebuddyBaseUrl = v; + } + const tm = settings?.model; + if (nonEmpty(tm)) model = String(tm); + const tacw = Number(settings?.autoCompactWindow); + if (Number.isFinite(tacw) && tacw > 0) autoCompactWindow = tacw; + if (!codebuddyBaseUrl && nonEmpty(env.CODEBUDDY_BASE_URL)) codebuddyBaseUrl = env.CODEBUDDY_BASE_URL; + + const windowByModel = new Map(); + const urls: string[] = []; + const seenUrl = new Set(); + for (const f of [ + path.join(codebuddyHome, "models.json"), + path.join(cwd, ".codebuddy", "models.json"), + ]) { + const parsed = parseCodebuddyModelsJson(readJsonFile(f)); + for (const w of parsed.models) windowByModel.set(w.id, w.contextWindow); + for (const u of parsed.urls) { + if (!seenUrl.has(u)) { + seenUrl.add(u); + urls.push(u); + } + } + } + return { + ...(codebuddyBaseUrl ? { codebuddyBaseUrl } : {}), + ...(model ? { model } : {}), + ...(autoCompactWindow ? { autoCompactWindow } : {}), + ...(windowByModel.size > 0 ? { models: [...windowByModel.entries()].map(([id, contextWindow]) => ({ id, contextWindow })) } : {}), + ...(urls.length > 0 ? { modelUrls: urls } : {}), + }; +} + /** Line-based scanner for dsh settings.yaml: collects every http(s) URL that * appears as a baseURL/baseUrl/base_url value (llm-pi-ai provider profiles, * llm-deepseek baseURL, model-level overrides). Route discovery only needs @@ -570,6 +701,7 @@ export function loadClientConfig(env: NodeJS.ProcessEnv, cwd: string): ClientCon config.opencode = readOpencodeConfig(resolveOpencodeConfigFile(env)); config.hermes = readHermesConfig(resolveHermesHome(env)); config.dsh = readDshConfig(resolveDshHome(env)); + config.codebuddy = readCodebuddyConfig(resolveCodebuddyHome(env), cwd, env); return config; } @@ -577,7 +709,7 @@ export function loadClientConfig(env: NodeJS.ProcessEnv, cwd: string): ClientCon * launched client's own declarations are authoritative (#436: launching * `bili omp` with omp's models.yml declaring 131072 must not be overridden by * another client's larger declaration for the same model id). */ -export type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh"; +export type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh" | "codebuddy"; /** Collect per-model context windows from client configs the launcher can * read (pi models.json, omp models.yml, opencode opencode.json, codex @@ -599,11 +731,13 @@ export function collectModelWindows(config: ClientConfig, scope?: ModelWindowSco else if (scope === "pi") for (const p of Object.values(config.pi?.providers ?? {})) add(p.models); else if (scope === "omp") for (const p of Object.values(config.omp?.providers ?? {})) add(p.models); else if (scope === "opencode") for (const p of Object.values(config.opencode?.providers ?? {})) add(p.models); + else if (scope === "codebuddy") add(config.codebuddy?.models); return out; } for (const p of Object.values(config.pi?.providers ?? {})) add(p.models); for (const p of Object.values(config.omp?.providers ?? {})) add(p.models); for (const p of Object.values(config.opencode?.providers ?? {})) add(p.models); add(config.codex?.modelWindows); + add(config.codebuddy?.models); return out; } diff --git a/src/discover.ts b/src/discover.ts index d58b9044..70903783 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { loadClientConfig, resolvePiHome, nonEmpty, type ClientConfig } from "./client-config.js"; +import { loadClientConfig, resolvePiHome, resolveCodebuddyHome, nonEmpty, type ClientConfig } from "./client-config.js"; const TTL_MS = 2000; @@ -43,6 +43,10 @@ export function extractHttpsHosts(config: ClientConfig): string[] { if (config.zcode) { for (const prov of Object.values(config.zcode.providers)) push(prov.baseURL); } + if (config.codebuddy) { + push(config.codebuddy.codebuddyBaseUrl); + for (const u of config.codebuddy.modelUrls ?? []) push(u); + } return out; } @@ -50,12 +54,16 @@ function configFilePaths(env: NodeJS.ProcessEnv): string[] { const home = os.homedir(); const codexHome = nonEmpty(env.CODEX_HOME) ? env.CODEX_HOME : path.join(home, ".codex"); const zcodeHome = nonEmpty(env.ZCODE_DATA_BASE_DIR) ? env.ZCODE_DATA_BASE_DIR : path.join(home, ".zcode"); + const codebuddyHome = resolveCodebuddyHome(env); return [ path.join(home, ".claude", "settings.json"), path.join(process.cwd(), ".claude", "settings.json"), path.join(codexHome, "config.toml"), path.join(resolvePiHome(env), "models.json"), path.join(zcodeHome, "v2", "config.json"), + path.join(codebuddyHome, "settings.json"), + path.join(codebuddyHome, "models.json"), + path.join(process.cwd(), ".codebuddy", "models.json"), ]; } diff --git a/src/launcher.ts b/src/launcher.ts index 73d0f90e..98bb152d 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -79,12 +79,16 @@ export { readOpencodeConfig, type OpencodeConfig, type OpencodeProvider, + type CodebuddyConfig, + readCodebuddyConfig, + parseCodebuddyModelsJson, + resolveCodebuddyHome, } from "./client-config.js"; export const LAUNCHER_DEFAULT_HOST = "127.0.0.1"; -export const LAUNCH_CLIENTS = ["pi", "codex", "claude", "omp", "opencode", "hermes", "dsh", "pi-test"] as const; +export const LAUNCH_CLIENTS = ["pi", "codex", "claude", "omp", "opencode", "hermes", "dsh", "codebuddy", "pi-test"] as const; export type ClientName = (typeof LAUNCH_CLIENTS)[number]; -export type BaseClientName = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh"; +export type BaseClientName = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes" | "dsh" | "codebuddy"; const HEALTH_PATH = "/__bili/health"; const HEALTH_POLL_INTERVAL_MS = 200; @@ -280,6 +284,40 @@ export function discoverRoutes(client: ClientName, config: ClientConfig): Discov // Unparseable base URL: leave routes empty (proxy still runs; claude // falls back to its own default endpoint). } + } else if (client === "codebuddy") { + // codebuddy (Tencent CodeBuddy Code CLI) honors CODEBUDDY_BASE_URL + // (Anthropic protocol) natively, so — like claude — every upstream is + // routed through the /bili/ URL form. The CN platform default endpoint + // is the verified fallback; other deployments (e.g. the international + // build, whose default endpoint is unconfirmed) must set + // CODEBUDDY_BASE_URL in settings.json or the shell. models.json + // per-model urls BYPASS CODEBUDDY_BASE_URL, so they are collected as + // MITM-whitelist inventory only, never rewritten (v1). + const raw = nonEmpty(config.codebuddy?.codebuddyBaseUrl) ? config.codebuddy!.codebuddyBaseUrl! : "https://tencent.sso.codebuddy.cn/v2"; + const real = unwrapUpstream(raw); + try { + const url = new URL(real); + if ((url.protocol === "https:" || url.protocol === "http:") && !rewriteKeys.has("CODEBUDDY_BASE_URL")) { + rewriteKeys.add("CODEBUDDY_BASE_URL"); + httpRewrites.push({ key: "CODEBUDDY_BASE_URL", realUpstream: real }); + } + } catch { + // Unparseable base URL: leave routes empty (proxy still runs; + // codebuddy falls back to its own default endpoint). + } + for (const rawModelUrl of config.codebuddy?.modelUrls ?? []) { + try { + const url = new URL(unwrapUpstream(rawModelUrl)); + if (url.protocol !== "https:") continue; + const host = url.hostname; + if (host && !httpsSeen.has(host.toLowerCase())) { + httpsSeen.add(host.toLowerCase()); + httpsDomains.push(host); + } + } catch { + // Unparseable model url: skip. + } + } } else if (client === "pi") { for (const [name, prov] of Object.entries(config.pi?.providers ?? {})) { classify(prov.baseUrl, name); @@ -542,6 +580,41 @@ export function buildClaudeEnv( return env; } +/** codebuddy budget alignment (#321 pattern, mirrors resolveClaudeBudgetEnv): + * inject CODEBUDDY_AUTO_COMPACT_WINDOW so codebuddy's native auto-compact + * threshold matches bili's compress budget. Returns {} (no injection) when: + * no model resolvable, the user already set an explicit auto-compact window + * (settings `autoCompactWindow` or a shell-exported + * CODEBUDDY_AUTO_COMPACT_WINDOW), or bili resolves no window for the model. */ +export async function resolveCodebuddyBudgetEnv(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 { CODEBUDDY_AUTO_COMPACT_WINDOW: String(window) }; +} + +export function buildCodebuddyEnv( + origin: string, + caPath: string, + httpRewrites: HttpRewrite[], + httpsRewrites: HttpRewrite[], + baseEnv: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...baseEnv, HTTPS_PROXY: origin, NODE_EXTRA_CA_CERTS: caPath, BILLION_CONTEXT_PROXY: origin }; + const r = httpRewrites.find((rw) => rw.key === "CODEBUDDY_BASE_URL"); + if (r) env.CODEBUDDY_BASE_URL = wrapUpstream(origin, r.realUpstream); + const hr = httpsRewrites.find((rw) => rw.key === "CODEBUDDY_BASE_URL"); + if (hr) env.CODEBUDDY_BASE_URL = hr.realUpstream; + return env; +} + // --- Launcher plugin mode (#162): inject the MCP shell + session hooks as // spawn-time flags, never touching host config files on disk. --- @@ -613,9 +686,13 @@ function isPrivateIPv4(host: string): boolean { * fumbles for them. When the codex upstream is a local/private endpoint and * the user has not chosen explicitly, wire mode (flat tools every server * understands) is the sane default. `BILI_LAUNCHER_PLUGIN=1` forces plugin - * mode regardless of the upstream. */ + * mode regardless of the upstream. + * + * codebuddy is always excluded too: its `--mcp-config` compatibility is not + * yet verified against a real build, so v1 runs pure wire mode (the proxy + * injects the context tools on the wire). */ export function launcherInjectMcp(env: NodeJS.ProcessEnv, base: string, codexUpstream?: string): boolean { - if (base === "pi" || base === "omp" || base === "opencode" || base === "hermes" || base === "dsh") return false; + if (base === "pi" || base === "omp" || base === "opencode" || base === "hermes" || base === "dsh" || base === "codebuddy") return false; if (env.BILI_LAUNCHER_PLUGIN === "0") return false; if (base === "codex" && env.BILI_LAUNCHER_PLUGIN === undefined && codexUpstream !== undefined && isPrivateUpstreamHost(codexUpstream)) { return false; @@ -1656,6 +1733,10 @@ export function resolveClientCommand( ); return { command: process.execPath, prefixArgs: [cli] }; } + if (client === "codebuddy") { + const resolved = resolveOnPath("codebuddy", env) ?? resolveOnPath("cbc", env); + return { command: resolved ?? "codebuddy", prefixArgs: [] }; + } const resolved = resolveOnPath(client, env); return { command: resolved ?? client, prefixArgs: [] }; } @@ -1909,6 +1990,19 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} } if (injectMcp && codexConversationId) clientArgs = [...buildCodexMcpArgs(origin, codexConversationId), ...clientArgs]; } + } else if (base === "codebuddy") { + env = buildCodebuddyEnv(origin, ca, routes.httpRewrites, routes.httpsRewrites, process.env); + const codebuddyBudget = await resolveCodebuddyBudgetEnv({ + model: config.codebuddy?.model, + userAutoCompactWindow: config.codebuddy?.autoCompactWindow, + shellAutoCompactWindow: process.env.CODEBUDDY_AUTO_COMPACT_WINDOW, + routes: biliRoutes, + upstreamUrl: config.codebuddy?.codebuddyBaseUrl ?? "https://tencent.sso.codebuddy.cn/v2", + }); + Object.assign(env, codebuddyBudget); + if (codebuddyBudget.CODEBUDDY_AUTO_COMPACT_WINDOW !== undefined) { + console.error(`bili: codebuddy budget aligned — CODEBUDDY_AUTO_COMPACT_WINDOW=${codebuddyBudget.CODEBUDDY_AUTO_COMPACT_WINDOW}`); + } } else { env = directUrl ? buildClaudePluginEnv(origin, true, process.env) diff --git a/tests/discover.test.ts b/tests/discover.test.ts index d8bf82e9..baf84969 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -119,6 +119,24 @@ test("extractHttpsHosts: empty config → []", () => { assert.deepEqual(extractHttpsHosts({}), []); }); +test("extractHttpsHosts: codebuddy base URL + models.json urls (https only, unwrapped)", () => { + const config: ClientConfig = { + codebuddy: { + codebuddyBaseUrl: "https://CB.Example.com/v2", + modelUrls: [ + "https://models.example.com/v1/chat/completions", + "http://local.example.com/v1/chat/completions", + "http://127.0.0.1:8787/bili/https://wrapped.example.com/v1", + ], + }, + }; + assert.deepEqual(extractHttpsHosts(config), [ + "cb.example.com", + "models.example.com", + "wrapped.example.com", + ]); +}); + async function withTempHome(fn: (home: string, env: NodeJS.ProcessEnv) => Promise): Promise { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bili-disc-")); const savedHome = process.env.HOME; @@ -163,6 +181,26 @@ test("discoverMitmDomains: returns union of https hosts from client configs", as }); }); +test("discoverMitmDomains: codebuddy settings.json + models.json hosts discovered", async () => { + await withTempHome((home, env) => { + const cbDir = path.join(home, ".codebuddy"); + fs.mkdirSync(cbDir, { recursive: true }); + fs.writeFileSync( + path.join(cbDir, "settings.json"), + JSON.stringify({ env: { CODEBUDDY_BASE_URL: "https://codebuddy.example.com/v2" } }), + ); + fs.writeFileSync( + path.join(cbDir, "models.json"), + JSON.stringify({ m1: { url: "https://models.example.com/v1/chat/completions", maxInputTokens: 100000 } }), + ); + _resetDiscoveryCacheForTest(); + const domains = discoverMitmDomains(env); + assert.ok(domains.includes("codebuddy.example.com"), `codebuddy host present: ${domains.join(",")}`); + assert.ok(domains.includes("models.example.com"), `models.json host present: ${domains.join(",")}`); + return Promise.resolve(); + }); +}); + test("discoverMitmDomains: two calls within TTL return the same array (cache hit, no re-stat)", async () => { await withTempHome((home, env) => { writeZcodeConfig(home, ["https://cached.example.com"]); diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 478c767f..c200afb8 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -51,8 +51,13 @@ import { resolveLauncherWindow, resolveCodexBudgetArgs, resolveClaudeBudgetEnv, + resolveCodebuddyBudgetEnv, + buildCodebuddyEnv, codexUpstreamUrl, readClaudeSettings, + readCodebuddyConfig, + parseCodebuddyModelsJson, + resolveCodebuddyHome, type SpawnChild, type SpawnFn, runLaunch, @@ -2301,3 +2306,322 @@ test("runLaunch claude: CLAUDE_CODE_AUTO_COMPACT_WINDOW injected (built-in table fs.rmSync(home, { recursive: true, force: true }); } }); + +test("isLaunchClient: codebuddy true", () => { + assert.equal(isLaunchClient("codebuddy"), true); +}); + +test("resolveCodebuddyHome: CODEBUDDY_CONFIG_DIR > default ~/.codebuddy", () => { + const h = os.homedir(); + assert.equal(resolveCodebuddyHome({ CODEBUDDY_CONFIG_DIR: "/custom/cb" }), "/custom/cb"); + assert.equal(resolveCodebuddyHome({}), path.join(h, ".codebuddy")); + assert.equal(resolveCodebuddyHome({ CODEBUDDY_CONFIG_DIR: " " }), path.join(h, ".codebuddy")); +}); + +test("parseCodebuddyModelsJson: top-level map / models map / array shapes", () => { + const topMap = parseCodebuddyModelsJson({ + "model-a": { url: "https://a.example.com/v1/chat/completions", apiKey: "k", maxInputTokens: 100000 }, + "model-b": { maxInputTokens: 200000 }, + junk: "not-an-object", + }); + assert.deepEqual(topMap.models, [ + { id: "model-a", contextWindow: 100000 }, + { id: "model-b", contextWindow: 200000 }, + ]); + assert.deepEqual(topMap.urls, ["https://a.example.com/v1/chat/completions"]); + + const modelsMap = parseCodebuddyModelsJson({ + models: { "model-c": { url: "https://c.example.com/v1/chat/completions", maxInputTokens: 300000 } }, + }); + assert.deepEqual(modelsMap.models, [{ id: "model-c", contextWindow: 300000 }]); + assert.deepEqual(modelsMap.urls, ["https://c.example.com/v1/chat/completions"]); + + const arr = parseCodebuddyModelsJson([ + { id: "model-d", url: "https://d.example.com/v1/chat/completions", maxInputTokens: 400000 }, + { name: "model-e", maxInputTokens: 500000 }, + "junk", + ]); + assert.deepEqual(arr.models, [ + { id: "model-d", contextWindow: 400000 }, + { id: "model-e", contextWindow: 500000 }, + ]); + assert.deepEqual(arr.urls, ["https://d.example.com/v1/chat/completions"]); + + assert.deepEqual(parseCodebuddyModelsJson(null), { models: [], urls: [] }); + assert.deepEqual(parseCodebuddyModelsJson("nope"), { models: [], urls: [] }); + assert.deepEqual(parseCodebuddyModelsJson({ "model-x": { maxInputTokens: -1 } }), { models: [], urls: [] }); +}); + +test("readCodebuddyConfig: settings env block / top-level model / autoCompactWindow / shell fallback", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-codebuddy-settings-")); + try { + const cbDir = path.join(home, ".codebuddy"); + fs.mkdirSync(cbDir, { recursive: true }); + fs.writeFileSync( + path.join(cbDir, "settings.json"), + JSON.stringify({ model: "cb-model-1", autoCompactWindow: 300000, env: { CODEBUDDY_BASE_URL: "http://relay.local/cb" } }), + ); + let cfg = readCodebuddyConfig(cbDir, os.tmpdir(), {}); + assert.equal(cfg.codebuddyBaseUrl, "http://relay.local/cb"); + assert.equal(cfg.model, "cb-model-1"); + assert.equal(cfg.autoCompactWindow, 300000); + + // shell-exported CODEBUDDY_BASE_URL fills in when settings has none + fs.writeFileSync(path.join(cbDir, "settings.json"), JSON.stringify({ model: "cb-model-1" })); + cfg = readCodebuddyConfig(cbDir, os.tmpdir(), { CODEBUDDY_BASE_URL: "https://shell.example.com/v2" }); + assert.equal(cfg.codebuddyBaseUrl, "https://shell.example.com/v2"); + + // nothing set → empty object + fs.writeFileSync(path.join(cbDir, "settings.json"), JSON.stringify({})); + cfg = readCodebuddyConfig(cbDir, os.tmpdir(), {}); + assert.deepEqual(cfg, {}); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("readCodebuddyConfig: two-tier models.json, project level wins per model", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-codebuddy-models-")); + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "bili-codebuddy-cwd-")); + try { + const cbDir = path.join(home, ".codebuddy"); + fs.mkdirSync(cbDir, { recursive: true }); + fs.writeFileSync( + path.join(cbDir, "models.json"), + JSON.stringify({ + "shared-model": { url: "https://global.example.com/v1/chat/completions", maxInputTokens: 100000 }, + "global-only": { url: "https://global.example.com/v1/chat/completions", maxInputTokens: 200000 }, + }), + ); + const projDir = path.join(cwd, ".codebuddy"); + fs.mkdirSync(projDir, { recursive: true }); + fs.writeFileSync( + path.join(projDir, "models.json"), + JSON.stringify({ + "shared-model": { url: "https://project.example.com/v1/chat/completions", maxInputTokens: 999999 }, + }), + ); + const cfg = readCodebuddyConfig(cbDir, cwd, {}); + assert.deepEqual(cfg.models, [ + { id: "shared-model", contextWindow: 999999 }, + { id: "global-only", contextWindow: 200000 }, + ]); + assert.deepEqual(cfg.modelUrls, [ + "https://global.example.com/v1/chat/completions", + "https://project.example.com/v1/chat/completions", + ]); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("buildCodebuddyEnv: CODEBUDDY_BASE_URL rewrite sets env + keeps HTTPS_PROXY/CA", () => { + const rewrites: HttpRewrite[] = [ + { key: "CODEBUDDY_BASE_URL", realUpstream: "http://relay.local/cb" }, + ]; + const env = buildCodebuddyEnv("http://127.0.0.1:8787", "/tmp/ca.pem", rewrites, [], { PATH: "/usr/bin", CODEBUDDY_API_KEY: "cb-x" }); + assert.equal(env.HTTPS_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.NODE_EXTRA_CA_CERTS, "/tmp/ca.pem"); + assert.equal(env.BILLION_CONTEXT_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.CODEBUDDY_API_KEY, "cb-x"); + assert.equal(env.CODEBUDDY_BASE_URL, "http://127.0.0.1:8787/bili/http://relay.local/cb"); +}); + +test("buildCodebuddyEnv: no CODEBUDDY_BASE_URL rewrite → env.CODEBUDDY_BASE_URL unset", () => { + const env = buildCodebuddyEnv("http://127.0.0.1:8787", "/tmp/ca.pem", [], [], { PATH: "/usr/bin" }); + assert.equal(env.CODEBUDDY_BASE_URL, undefined); + assert.equal(env.HTTPS_PROXY, "http://127.0.0.1:8787"); +}); + +test("discoverRoutes: codebuddy default → CODEBUDDY_BASE_URL /bili/ rewrite (CN platform endpoint)", () => { + const routes = discoverRoutes("codebuddy", {}); + assert.deepEqual(routes.httpsDomains, []); + assert.deepEqual(routes.httpsRewrites, []); + assert.deepEqual(routes.httpRewrites, [ + { key: "CODEBUDDY_BASE_URL", realUpstream: "https://tencent.sso.codebuddy.cn/v2" }, + ]); +}); + +test("discoverRoutes: codebuddy configured http base URL → httpRewrites entry, no https domains", () => { + const config: ClientConfig = { + codebuddy: { codebuddyBaseUrl: "http://relay.local/cb" }, + }; + const routes = discoverRoutes("codebuddy", config); + assert.deepEqual(routes.httpsDomains, []); + assert.deepEqual(routes.httpRewrites, [ + { key: "CODEBUDDY_BASE_URL", realUpstream: "http://relay.local/cb" }, + ]); +}); + +test("discoverRoutes: codebuddy /bili/-wrapped base_url unwraps to real upstream for re-wrap", () => { + const config: ClientConfig = { + codebuddy: { codebuddyBaseUrl: "http://127.0.0.1:8787/bili/https://relay.example.com/v2" }, + }; + const routes = discoverRoutes("codebuddy", config); + assert.deepEqual(routes.httpsDomains, []); + assert.deepEqual(routes.httpRewrites, [ + { key: "CODEBUDDY_BASE_URL", realUpstream: "https://relay.example.com/v2" }, + ]); +}); + +test("discoverRoutes: codebuddy models.json urls → httpsDomains inventory, never rewritten", () => { + const config: ClientConfig = { + codebuddy: { + modelUrls: [ + "https://models.example.com/v1/chat/completions", + "http://local.example.com/v1/chat/completions", + "not-a-url", + ], + }, + }; + const routes = discoverRoutes("codebuddy", config); + assert.deepEqual(routes.httpsDomains, ["models.example.com"]); + assert.deepEqual(routes.httpRewrites, [ + { key: "CODEBUDDY_BASE_URL", realUpstream: "https://tencent.sso.codebuddy.cn/v2" }, + ]); +}); + +test("resolveCodebuddyBudgetEnv: injects CODEBUDDY_AUTO_COMPACT_WINDOW from bili's chain", async () => { + registrySetForTest({}); + try { + assert.deepEqual( + await resolveCodebuddyBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), + { CODEBUDDY_AUTO_COMPACT_WINDOW: "200000" }, + ); + const routes = { "https://relay.example.com": { models: { "cb-x": { context: 123456 } } } }; + assert.deepEqual( + await resolveCodebuddyBudgetEnv({ model: "cb-x", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes, upstreamUrl: "https://relay.example.com" }), + { CODEBUDDY_AUTO_COMPACT_WINDOW: "123456" }, + ); + registrySetForTest({ "anthropic/bili-fallback-model": { limit: { context: 333333 } } }); + assert.deepEqual( + await resolveCodebuddyBudgetEnv({ model: "bili-fallback-model", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), + { CODEBUDDY_AUTO_COMPACT_WINDOW: "333333" }, + ); + } finally { + registryResetForTest(); + } +}); + +test("resolveCodebuddyBudgetEnv: no injection when user self-aligned or unresolvable", async () => { + registrySetForTest({}); + try { + assert.deepEqual(await resolveCodebuddyBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: 300000, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), {}); + assert.deepEqual(await resolveCodebuddyBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, shellAutoCompactWindow: "250000", routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), {}); + assert.deepEqual(await resolveCodebuddyBudgetEnv({ model: undefined, userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), {}); + assert.deepEqual(await resolveCodebuddyBudgetEnv({ model: "bili-nonexistent-model-xyz", userAutoCompactWindow: undefined, shellAutoCompactWindow: undefined, routes: {}, upstreamUrl: "https://tencent.sso.codebuddy.cn/v2" }), {}); + } finally { + registryResetForTest(); + } +}); + +test("resolveClientCommand: codebuddy resolves codebuddy, then cbc, then bare name", () => { + assert.deepEqual(resolveClientCommand("codebuddy", { PATH: "/nonexistent-dir-zzz" }), { + command: "codebuddy", + prefixArgs: [], + }); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "bili-path-")); + const cbcFile = path.join(tmp, "cbc"); + fs.writeFileSync(cbcFile, "#!/bin/sh\necho cbc\n", { mode: 0o755 }); + try { + assert.deepEqual(resolveClientCommand("codebuddy", { PATH: tmp }), { + command: cbcFile, + prefixArgs: [], + }); + } finally { + fs.unlinkSync(cbcFile); + fs.rmdirSync(tmp); + } + const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), "bili-path-")); + const cbFile = path.join(tmp2, "codebuddy"); + const cbFile2 = path.join(tmp2, "cbc"); + fs.writeFileSync(cbFile, "#!/bin/sh\necho cb\n", { mode: 0o755 }); + fs.writeFileSync(cbFile2, "#!/bin/sh\necho cbc\n", { mode: 0o755 }); + try { + assert.deepEqual(resolveClientCommand("codebuddy", { PATH: tmp2 }), { + command: cbFile, + prefixArgs: [], + }); + } finally { + fs.unlinkSync(cbFile); + fs.unlinkSync(cbFile2); + fs.rmdirSync(tmp2); + } +}); + +test("runLaunch codebuddy: CODEBUDDY_BASE_URL /bili/ rewrite + budget injected (built-in table window)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-codebuddy-budget-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevClientBin = process.env.BILI_CLIENT_BIN; + const prevBaseUrl = process.env.CODEBUDDY_BASE_URL; + const prevAutoCompact = process.env.CODEBUDDY_AUTO_COMPACT_WINDOW; + const prevConfigDir = process.env.CODEBUDDY_CONFIG_DIR; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + delete process.env.CODEBUDDY_BASE_URL; + delete process.env.CODEBUDDY_AUTO_COMPACT_WINDOW; + delete process.env.CODEBUDDY_CONFIG_DIR; + const fakeCodebuddy = path.join(home, "fake-codebuddy"); + fs.writeFileSync(fakeCodebuddy, ""); + process.env.BILI_CLIENT_BIN = fakeCodebuddy; + const cbDir = path.join(home, ".codebuddy"); + fs.mkdirSync(cbDir, { recursive: true }); + fs.writeFileSync(path.join(cbDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5" })); + + const clientEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const spawnImpl: SpawnFn = (cmd, args, opts) => { + if (cmd === fakeCodebuddy) { + 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: "codebuddy", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + assert.match(clientEnvs[0]?.CODEBUDDY_BASE_URL ?? "", /^http:\/\/127\.0\.0\.1:\d+\/bili\/https:\/\/tencent\.sso\.codebuddy\.cn\/v2$/); + assert.equal(clientEnvs[0]?.CODEBUDDY_AUTO_COMPACT_WINDOW, "200000"); + assert.equal(clientEnvs[0]?.HTTPS_PROXY, clientEnvs[0]?.BILLION_CONTEXT_PROXY); + + // user self-aligned (settings autoCompactWindow) → no budget injection + fs.writeFileSync(path.join(cbDir, "settings.json"), JSON.stringify({ model: "claude-sonnet-4-5", autoCompactWindow: 300000 })); + clientEnvs.length = 0; + await runLaunch( + { client: "codebuddy", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + assert.equal(clientEnvs[0]?.CODEBUDDY_AUTO_COMPACT_WINDOW, undefined); + assert.match(clientEnvs[0]?.CODEBUDDY_BASE_URL ?? "", /^http:\/\/127\.0\.0\.1:\d+\/bili\//); + } 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 (prevBaseUrl === undefined) delete process.env.CODEBUDDY_BASE_URL; + else process.env.CODEBUDDY_BASE_URL = prevBaseUrl; + if (prevAutoCompact === undefined) delete process.env.CODEBUDDY_AUTO_COMPACT_WINDOW; + else process.env.CODEBUDDY_AUTO_COMPACT_WINDOW = prevAutoCompact; + if (prevConfigDir === undefined) delete process.env.CODEBUDDY_CONFIG_DIR; + else process.env.CODEBUDDY_CONFIG_DIR = prevConfigDir; + fs.rmSync(home, { recursive: true, force: true }); + } +}); From b216a0e74caf01ab7ab5914f63d2f4e9a2d7c342 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Tue, 8 Sep 2026 18:21:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20codebuddy=20discover=20test=20?= =?UTF-8?q?=E2=80=94=20pin=20CODEBUDDY=5FCONFIG=5FDIR=20in=20withTempHome?= =?UTF-8?q?=20(Windows)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows os.homedir() does not follow HOME, so resolveCodebuddyHome fell back to the real user profile and the temp-home config files were never found (discoverMitmDomains returned []). Pass CODEBUDDY_CONFIG_DIR in the helper env, parallel to the existing CODEX_HOME / ZCODE_DATA_BASE_DIR / PI_CODING_AGENT_DIR overrides. Fixes the windows-latest CI failure on PR #641. --- tests/discover.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/discover.test.ts b/tests/discover.test.ts index baf84969..ca24028d 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -148,6 +148,7 @@ async function withTempHome(fn: (home: string, env: NodeJS.ProcessEnv) => Pro CODEX_HOME: path.join(tmp, ".codex"), ZCODE_DATA_BASE_DIR: path.join(tmp, ".zcode"), PI_CODING_AGENT_DIR: path.join(tmp, ".pi", "agent"), + CODEBUDDY_CONFIG_DIR: path.join(tmp, ".codebuddy"), }; return await fn(tmp, env); } finally { From ab93c77b04ce9dd8e1889ff7748470f06d516a6a Mon Sep 17 00:00:00 2001 From: ework-agent Date: Wed, 9 Sep 2026 00:33:04 +0800 Subject: [PATCH 3/4] docs: correct codebuddy wire protocol to OpenAI chat completions (#640) --- README.md | 2 +- src/client-config.ts | 8 ++++---- src/launcher.ts | 16 +++++++++------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6f4ff0c9..58875039 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ bili omp # pi-style, file-free (#535): env + extens bili opencode # MITM for HTTPS + temp opencode.json (/bili/ for HTTP) + thin /acp plugin bili hermes # file-free (#535): hermes proxy env (HTTPS_PROXY + HERMES_CA_BUNDLE) — https via CONNECT MITM, http via absolute-form forward proxy; real ~/.hermes untouched bili dsh # deepseek-harness: non-loopback upstreams ride proxy envs (https MITM, http absolute-form), loopback keeps the overlay DSH_HOME (~/.dsh-bili) rewrite (#535), built-in deepseek route via DEEPSEEK_BASE_URL, native /acp command injected via --patch -bili codebuddy # Tencent CodeBuddy Code CLI: CODEBUDDY_BASE_URL /bili/ rewrite (Anthropic protocol), budget aligned via CODEBUDDY_AUTO_COMPACT_WINDOW; real ~/.codebuddy untouched +bili codebuddy # Tencent CodeBuddy Code CLI: CODEBUDDY_BASE_URL /bili/ rewrite (OpenAI chat completions wire), budget aligned via CODEBUDDY_AUTO_COMPACT_WINDOW; real ~/.codebuddy untouched bili pi --mitm-domain api.foo.com # add a domain to the MITM whitelist ``` diff --git a/src/client-config.ts b/src/client-config.ts index 2e5537ec..b4bd8579 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -94,8 +94,8 @@ export interface DshConfig { } export interface CodebuddyConfig { - /** Model endpoint (Anthropic protocol): settings `env.CODEBUDDY_BASE_URL` - * ?? shell `CODEBUDDY_BASE_URL`. */ + /** Model endpoint (OpenAI chat completions wire): settings + * `env.CODEBUDDY_BASE_URL` ?? shell `CODEBUDDY_BASE_URL`. */ codebuddyBaseUrl?: string; /** The model codebuddy runs: settings top-level `model`. */ model?: string; @@ -233,8 +233,8 @@ function readJsonFile(filePath: string): unknown { } /** codebuddy config discovery (read-only): - * - /settings.json: `env.CODEBUDDY_BASE_URL` (Anthropic-protocol - * endpoint), top-level `model`, top-level `autoCompactWindow`; + * - /settings.json: `env.CODEBUDDY_BASE_URL` (OpenAI chat + * completions endpoint), top-level `model`, top-level `autoCompactWindow`; * - two-tier /models.json + /.codebuddy/models.json (project * level wins per model id): per-model `url` + `maxInputTokens`. * A shell-exported CODEBUDDY_BASE_URL (codebuddy's native override) is diff --git a/src/launcher.ts b/src/launcher.ts index 98bb152d..319629f6 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -286,13 +286,15 @@ export function discoverRoutes(client: ClientName, config: ClientConfig): Discov } } else if (client === "codebuddy") { // codebuddy (Tencent CodeBuddy Code CLI) honors CODEBUDDY_BASE_URL - // (Anthropic protocol) natively, so — like claude — every upstream is - // routed through the /bili/ URL form. The CN platform default endpoint - // is the verified fallback; other deployments (e.g. the international - // build, whose default endpoint is unconfirmed) must set - // CODEBUDDY_BASE_URL in settings.json or the shell. models.json - // per-model urls BYPASS CODEBUDDY_BASE_URL, so they are collected as - // MITM-whitelist inventory only, never rewritten (v1). + // natively; its ModelProvider is the OpenAI SDK, so model traffic is + // OpenAI chat completions (POST /chat/completions) — bili routes + // it through the openai adapter by path. Every upstream is routed + // through the /bili/ URL form. The CN platform default endpoint is the + // verified fallback; the international build defaults to + // https://www.codebuddy.ai/v2 (product.json), so other deployments + // must set CODEBUDDY_BASE_URL in settings.json or the shell. + // models.json per-model urls BYPASS CODEBUDDY_BASE_URL, so they are + // collected as MITM-whitelist inventory only, never rewritten (v1). const raw = nonEmpty(config.codebuddy?.codebuddyBaseUrl) ? config.codebuddy!.codebuddyBaseUrl! : "https://tencent.sso.codebuddy.cn/v2"; const real = unwrapUpstream(raw); try { From e9dac95504c9abf0596da12b6c7fce96592a26b6 Mon Sep 17 00:00:00 2001 From: ework-agent Date: Thu, 10 Sep 2026 23:40:14 +0800 Subject: [PATCH 4/4] test: .exe-suffix the codebuddy fake client so win32 spawns it directly (#679) planClientSpawn routes extensionless targets through comspec on Windows, so the extensionless fake-codebuddy never matched the stub spawnImpl and the runLaunch integration test hung (cancelledByParent) in the windows CI job after merging master. --- tests/launcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index f1217e66..16fc7e7b 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -2716,7 +2716,7 @@ test("runLaunch codebuddy: CODEBUDDY_BASE_URL /bili/ rewrite + budget injected ( delete process.env.CODEBUDDY_BASE_URL; delete process.env.CODEBUDDY_AUTO_COMPACT_WINDOW; delete process.env.CODEBUDDY_CONFIG_DIR; - const fakeCodebuddy = path.join(home, "fake-codebuddy"); + const fakeCodebuddy = path.join(home, process.platform === "win32" ? "fake-codebuddy.exe" : "fake-codebuddy"); fs.writeFileSync(fakeCodebuddy, ""); process.env.BILI_CLIENT_BIN = fakeCodebuddy; const cbDir = path.join(home, ".codebuddy");