diff --git a/AGENTS.md b/AGENTS.md index 0ff0b8e6..6ebf2dd3 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/qoder/trae) │ ├── 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/CONFIGURATION.md b/CONFIGURATION.md index 83f5533f..b0d1542f 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -464,6 +464,9 @@ Full command surface (`bili --help` prints an abridged version). Precedence ever | `bili opencode [opts --] [args]` | Proxy + **opencode** | | `bili hermes [opts --] [args]` | Proxy + **hermes-agent** (`/bili/` rewrite) | | `bili dsh [opts --] [args]` | Proxy + **deepseek-harness** (non-loopback upstreams via proxy envs, loopback via `/bili/` rewrite — #535; args like `--profile web "task"` pass through) | +| `bili codebuddy [opts --] [args]` | Proxy + **codebuddy** (Tencent CodeBuddy Code CLI) — `CODEBUDDY_BASE_URL` `/bili/` rewrite, OpenAI chat-completions wire; budget via `CODEBUDDY_AUTO_COMPACT_WINDOW` (#640) | +| `bili qoder [opts --] [args]` | Proxy + **qoder** — cert-MITM via `HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`; model endpoint hardcoded https so no `/bili/` rewrite (default host map whitelisted) (#653) | +| `bili trae [opts --] [args]` | Proxy + **Trae CLI** (ByteDance, closed Go binary) — cert-MITM via `HTTPS_PROXY` + `SSL_CERT_FILE`; model host from `TRAE_CLI_API_HOST` or the default enterprise gateway (#655) | | `bili test pi` | Non-polluting end-to-end smoke test of the pi path | | `bili export [session] [--full] [--output FILE]` | List persisted sessions / export one as a Markdown handoff — see [Sessions & Migration](#sessions--migration) | | `bili update` | Check for & install a newer version now (bypasses the 3-minute throttle) | diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index cda976f6..5ee06d9d 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -460,6 +460,9 @@ | `bili opencode [opts --] [args]` | 代理 + **opencode** | | `bili hermes [opts --] [args]` | 代理 + **hermes-agent**(`/bili/` 重写) | | `bili dsh [opts --] [args]` | 代理 + **deepseek-harness**(`/bili/` 重写;`--profile web "task"` 等参数原样透传) | +| `bili codebuddy [opts --] [args]` | 代理 + **codebuddy**(Tencent CodeBuddy Code CLI)—— `CODEBUDDY_BASE_URL` `/bili/` 重写,OpenAI chat-completions wire;预算对齐走 `CODEBUDDY_AUTO_COMPACT_WINDOW`(#640) | +| `bili qoder [opts --] [args]` | 代理 + **qoder** —— 证书 MITM(`HTTPS_PROXY` + `NODE_EXTRA_CA_CERTS`);模型端点硬编码 https(`/bili/` 改写不可用,默认主机表加白名单)(#653) | +| `bili trae [opts --] [args]` | 代理 + **Trae CLI**(字节跳动,闭源 Go 二进制)—— 证书 MITM(`HTTPS_PROXY` + `SSL_CERT_FILE`);模型主机取 `TRAE_CLI_API_HOST` 或默认企业网关(#655) | | `bili test pi` | 无污染的 pi 链路端到端冒烟测试 | | `bili export [session] [--full] [--output FILE]` | 列出持久化会话 / 把一个会话导出为 Markdown 交接文档 —— 见[会话与迁移](#会话与迁移) | | `bili update` | 立即检查并安装新版本(绕过 3 分钟节流) | diff --git a/README.md b/README.md index 13e66f71..6776d38a 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` / `bili qoder` / `bili trae`) 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,9 @@ 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 (OpenAI chat completions wire), budget aligned via CODEBUDDY_AUTO_COMPACT_WINDOW; real ~/.codebuddy untouched +bili qoder # qoder: model endpoint is hardcoded https (no /bili/ rewrite possible) — cert-MITM via HTTPS_PROXY + NODE_EXTRA_CA_CERTS, default model hosts whitelisted (#653) +bili trae # Trae CLI (ByteDance, closed Go binary, no base-URL override) — cert-MITM via HTTPS_PROXY + SSL_CERT_FILE, model host from TRAE_CLI_API_HOST or the default enterprise gateway (#655) bili pi --mitm-domain api.foo.com # add a domain to the MITM whitelist ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index 9ee2451b..51ddf41c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ npm install -g billion-context bili 永不拥有用户数据:每个被启动的客户端都跑在**真实 home** 上,运行期写入落在用户预期的位置。把客户端指向代理时,启动器按优先级选择——**优先 env 变量**(hermes/dsh/codex 的代理/CA env;pi/omp 的 `BILI_PROVIDER_REWRITES` URL 清单,由扩展加载时经 `registerProvider` 消费),其次 **CLI 参数或扩展 API**(codex `-c key=value`、opencode 插件),最后才是**生成文件**——目前仅剩 opencode 的临时 `opencode.json`(退出即删)和 dsh 的回环例外:dsh 的 fetch 栈对回环目标无条件绕过代理 env,所以本地上游保留持久 `~/.dsh-bili` overlay 改写,直到 dsh 提供 settings-path env 或上游支持回环 opt-out。旧版本创建的 overlay 目录原地保留,绝不合并回真实 home。 -### 方式 1 —— 启动器(`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh`) +### 方式 1 —— 启动器(`bili pi` / `bili codex` / `bili claude` / `bili omp` / `bili opencode` / `bili hermes` / `bili dsh` / `bili codebuddy` / `bili qoder` / `bili trae`) 启动器把客户端包进一条命令:在独立端口拉起一个代理(总是全新实例,绝不复用端口),再按客户端支持的机制把它指向代理 —— 能吃代理/CA 环境变量的走**证书 MITM**,不吃的走隔离的**`/bili/` 配置重写**。真实配置文件从不被修改;客户端自己的配置只被**读取**,用来发现它实际连接的 HTTPS 上游主机,把这些主机加入 MITM 白名单 —— 代理只 TLS 终结它们,其余流量盲透传。 @@ -96,6 +96,9 @@ bili omp # pi 同款,file-free(#535):环境变量 + bili opencode # HTTPS 走 MITM + 临时 opencode.json(HTTP 走 /bili/)+ 轻量 /acp 插件 bili hermes # file-free(#535):hermes 代理环境变量(HTTPS_PROXY + HERMES_CA_BUNDLE)—— https 走 CONNECT MITM,http 走绝对形式转发;真实 ~/.hermes 不动 bili dsh # deepseek-harness:非回环上游走代理 env(https MITM、http absolute-form),回环上游保留 overlay DSH_HOME(~/.dsh-bili)改写(#535),内置 deepseek 路由走 DEEPSEEK_BASE_URL,经 --patch 注入原生 /acp 命令 +bili codebuddy # Tencent CodeBuddy Code CLI:CODEBUDDY_BASE_URL /bili/ 重写(OpenAI chat completions wire),预算对齐走 CODEBUDDY_AUTO_COMPACT_WINDOW;真实 ~/.codebuddy 不动 +bili qoder # qoder:模型端点硬编码 https(无法 /bili/ 改写)—— 证书 MITM(HTTPS_PROXY + NODE_EXTRA_CA_CERTS),默认模型主机已加白名单(#653) +bili trae # Trae CLI(字节跳动,闭源 Go 二进制,无 base-URL 覆盖)—— 证书 MITM(HTTPS_PROXY + SSL_CERT_FILE),模型主机取 TRAE_CLI_API_HOST 或默认企业网关(#655) bili pi --mitm-domain api.foo.com # 向 MITM 白名单追加域名 ``` diff --git a/src/cli.ts b/src/cli.ts index 193d4b8c..e190ca85 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -67,6 +67,9 @@ 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 qoder [opts --] [args] start a proxy + launch qoder against it (cert-MITM) + bili trae [opts --] [args] start a proxy + launch Trae CLI against it (cert-MITM) 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,12 +84,12 @@ 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 / bili qoder / bili trae): 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 - client uses; HTTP / localhost providers go direct. pi/claude trust the CA - via NODE_EXTRA_CA_CERTS, codex via SSL_CERT_FILE. Proxy killed on client exit. + client uses; HTTP / localhost providers go direct. pi/claude/qoder trust the CA + via NODE_EXTRA_CA_CERTS, codex/trae via SSL_CERT_FILE. Proxy killed on client exit. bili flags (-F, --mitm-domain, --port, ...) must precede the client name; everything after the client name is passed through to the client. bili pi # launch pi through the proxy @@ -97,6 +100,9 @@ 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 qoder # launch qoder through the proxy (cert-MITM; model endpoint is hardcoded https, so no /bili/ rewrite) + bili trae # launch Trae CLI through the proxy (cert-MITM; model host via TRAE_CLI_API_HOST or --mitm-domain) 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 47eb1a5b..a7875fca 100644 --- a/src/client-config.ts +++ b/src/client-config.ts @@ -93,6 +93,38 @@ export interface DshConfig { baseUrls: string[]; } +export interface CodebuddyConfig { + /** 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; + /** 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 QoderConfig { + /** Model qoder runs: settings.json `model` (gemini-cli-style `model.name` + * or bare string) — budget alignment (#321 pattern, #653). */ + model?: string; + /** `QODER_MODEL_SERVER_HOST` (undocumented env, scheme/trailing-slash + * stripped) — when set, qoder talks to this host INSTEAD of the + * binary's static default map, so it replaces the MITM whitelist. */ + modelServerHost?: string; +} + +export interface TraeConfig { + modelApiHost?: string; +} + export interface ClientConfig { claude?: ClaudeSettings; codex?: CodexConfig; @@ -102,6 +134,83 @@ export interface ClientConfig { opencode?: OpencodeConfig; hermes?: HermesConfig; dsh?: DshConfig; + codebuddy?: CodebuddyConfig; + qoder?: QoderConfig; + trae?: TraeConfig; +} + +/** qoder's default model-inference hosts, hardcoded in the binary (no config + * file to discover from): prod + regional (US/SG/JP) + the CN gateway. + * daily/test variants are deliberately NOT included. */ +export const QODER_DEFAULT_MODEL_HOSTS = [ + "api2-v2.qoder.sh", + "api1.qoder.sh", + "api2.qoder.sh", + "api3.qoder.sh", + "gateway.qoder.com.cn", +]; + +/** CN-site detection rule (#653 open question 4): the launcher picks the + * `QODER_` vs `QODERCN_` env prefix by, in order — (1) `QODERCLI_SITE=cn`, + * (2) a CN-prefixed config env being set (`QODERCN_CONFIG_DIR` / + * `QODERCN_CLI_HOME`), (3) only the CN config dir existing on disk (the CN + * package defaults to `~/.qoder-cn`, the intl one to `~/.qoder`), else intl. + * Known edge: with BOTH packages installed, a CN launch is detected as intl + * (degrades to no budget injection / wrong-prefix transport env — never + * breaks the launch). */ +export function qoderIsCnSite(env: NodeJS.ProcessEnv = process.env): boolean { + const site = env.QODERCLI_SITE?.trim().toLowerCase(); + if (site === "cn") return true; + if (nonEmpty(env.QODERCN_CONFIG_DIR) || nonEmpty(env.QODERCN_CLI_HOME)) return true; + const h = os.homedir(); + const cnDir = path.join(h, ".qoder-cn"); + const intlDir = path.join(h, ".qoder"); + try { + if (fs.existsSync(cnDir) && !fs.existsSync(intlDir)) return true; + } catch {} + return false; +} + +/** qoder's config root: `QODER_CONFIG_DIR`/`QODERCN_CONFIG_DIR` (full path) + * > `QODER_CLI_HOME`/`QODERCN_CLI_HOME` + dir name > `~/.qoder` (intl) / + * `~/.qoder-cn` (CN); `QODER_CONFIG_DIR_NAME`/`QODERCN_CONFIG_DIR_NAME` + * override the dir name. The site prefix family is chosen by qoderIsCnSite. */ +export function resolveQoderHome(env: NodeJS.ProcessEnv = process.env): string { + const h = os.homedir(); + const cn = qoderIsCnSite(env); + const configDir = cn ? env.QODERCN_CONFIG_DIR : env.QODER_CONFIG_DIR; + if (nonEmpty(configDir)) return configDir!; + const cliHomeEnv = cn ? env.QODERCN_CLI_HOME : env.QODER_CLI_HOME; + const cliHome = nonEmpty(cliHomeEnv) ? cliHomeEnv! : h; + const dirNameEnv = cn ? env.QODERCN_CONFIG_DIR_NAME : env.QODER_CONFIG_DIR_NAME; + const dirName = nonEmpty(dirNameEnv) ? dirNameEnv! : (cn ? ".qoder-cn" : ".qoder"); + return path.join(cliHome, dirName); +} + +/** Read-only discovery of qoder's `/settings.json` (gemini-cli + * style): the selected model for budget alignment. qoder has no local model + * catalog (server-driven), so no windows are collected here. The model host + * override env is read too — it decides which host the MITM whitelist must + * carry. */ +export function readQoderConfig(qoderHome: string, env: NodeJS.ProcessEnv = process.env): QoderConfig { + const result: QoderConfig = {}; + const obj = readJsonObject(path.join(qoderHome, "settings.json")); + const model = obj?.model; + if (typeof model === "string" && model.trim().length > 0) { + result.model = model.trim(); + } else if (model && typeof model === "object" && !Array.isArray(model)) { + const name = (model as Record).name; + if (nonEmpty(name)) result.model = name!.trim(); + } + const cn = qoderIsCnSite(env); + const host = nonEmpty(cn ? env.QODERCN_MODEL_SERVER_HOST : env.QODER_MODEL_SERVER_HOST) + ? (cn ? env.QODERCN_MODEL_SERVER_HOST : env.QODER_MODEL_SERVER_HOST)! + : undefined; + if (host) { + const bare = host.trim().replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + if (bare.length > 0) result.modelServerHost = bare; + } + return result; } export function nonEmpty(s: unknown): s is string { @@ -159,6 +268,118 @@ export function resolveCodexHome(env: NodeJS.ProcessEnv): string { : path.join(h, ".codex"); } +/** 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` (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 + * 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 @@ -188,6 +409,33 @@ export function readDshConfig(dshHome: string): DshConfig { return { baseUrls: parseDshSettingsYaml(text) }; } +/** Default model API gateways for Trae CLI (ByteDance). The CLI is a Go + * binary that honors HTTPS_PROXY (Go net/http) and resolves its API host + * from TRAE_CLI_API_HOST (chatmodel.resolveBaseURL); without it the + * enterprise gateway is console.enterprise.trae.cn. These hosts are + * cert-MITM'd so `bili trae` can compress the model traffic. */ +export const TRAE_DEFAULT_MODEL_HOSTS = [ + "console.enterprise.trae.cn", + "www.trae.cn", +]; + +/** Trae CLI keeps its config under TRAE_CONFIG_DIR (default ~/.trae): + * traecli.yaml, skills, session state. */ +export function resolveTraeHome(env: NodeJS.ProcessEnv): string { + const h = os.homedir(); + return nonEmpty(env.TRAE_CONFIG_DIR) ? env.TRAE_CONFIG_DIR! + : path.join(h, ".trae"); +} + +export function readTraeConfig(env: NodeJS.ProcessEnv): TraeConfig { + const result: TraeConfig = {}; + const host = nonEmpty(env.TRAE_CLI_API_HOST) + ? env.TRAE_CLI_API_HOST!.replace(/^https?:\/\//i, "").replace(/\/+$/, "") + : undefined; + if (host) result.modelApiHost = host; + return result; +} + export function readClaudeSettings(homeDir: string, cwd: string, env: NodeJS.ProcessEnv = process.env): ClaudeSettings { const files = [ path.join(homeDir, ".claude", "settings.json"), @@ -579,6 +827,9 @@ 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); + config.qoder = readQoderConfig(resolveQoderHome(env), env); + config.trae = readTraeConfig(env); return config; } @@ -586,7 +837,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" | "qoder" | "trae"; /** Collect per-model context windows from client configs the launcher can * read (pi models.json, omp models.yml, opencode opencode.json, codex @@ -608,11 +859,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..58e8b98e 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, resolveQoderHome, resolveTraeHome, nonEmpty, QODER_DEFAULT_MODEL_HOSTS, TRAE_DEFAULT_MODEL_HOSTS, type ClientConfig } from "./client-config.js"; const TTL_MS = 2000; @@ -43,6 +43,21 @@ 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); + } + if (config.qoder) { + // qoder's model hosts are binary-hardcoded (no config file), so the + // discovery set is the static default map — replaced entirely by an + // explicit QODER_MODEL_SERVER_HOST (qoder's own resolution order). + const hosts = nonEmpty(config.qoder.modelServerHost) ? [config.qoder.modelServerHost] : QODER_DEFAULT_MODEL_HOSTS; + for (const h of hosts) push(`https://${h}`); + } + if (config.trae) { + const hosts = nonEmpty(config.trae.modelApiHost) ? [config.trae.modelApiHost] : TRAE_DEFAULT_MODEL_HOSTS; + for (const h of hosts) push(`https://${h}`); + } return out; } @@ -50,12 +65,18 @@ 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"), + path.join(resolveQoderHome(env), "settings.json"), + path.join(resolveTraeHome(env), "traecli.yaml"), ]; } diff --git a/src/launcher.ts b/src/launcher.ts index 1af25962..8eb31df6 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -43,7 +43,7 @@ import { selfPackageRoot, isBiliPiEntry, ompPluginLoadedFrom } from "./plugin-in function selfDistFile(name: string): string { return path.join(selfPackageRoot(), "dist", name); } -import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider } from "./client-config.js"; +import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, loadClientConfig, collectModelWindows, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, type TraeConfig } from "./client-config.js"; import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, type ProviderRoutes } from "./config.js"; import { contextFromRegistry } from "./registry.js"; @@ -76,16 +76,29 @@ export { parseDshSettingsYaml, resolveDshHome, resolveCodexHome, + resolveTraeHome, + readTraeConfig, + TRAE_DEFAULT_MODEL_HOSTS, + type TraeConfig, resolveOpencodeConfigFile, readOpencodeConfig, type OpencodeConfig, type OpencodeProvider, + type CodebuddyConfig, + readCodebuddyConfig, + parseCodebuddyModelsJson, + resolveCodebuddyHome, + readQoderConfig, + resolveQoderHome, + qoderIsCnSite, + QODER_DEFAULT_MODEL_HOSTS, + type QoderConfig, } 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", "qoder", "trae", "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" | "qoder" | "trae"; const HEALTH_PATH = "/__bili/health"; const HEALTH_POLL_INTERVAL_MS = 200; @@ -281,6 +294,42 @@ 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 + // 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 { + 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); @@ -359,6 +408,39 @@ export function discoverRoutes(client: ClientName, config: ClientConfig): Discov // Unparseable endpoint: skip. } } + } else if (client === "qoder") { + // #653: qoder's model endpoint scheme is hardcoded https with no + // base-URL override env, so /bili/ rewrites cannot reach it — cert + // MITM is the only route (qoder honors HTTPS_PROXY + + // NODE_EXTRA_CA_CERTS). Whitelist is the binary's static host map + // (prod + regional + CN gateway); an explicit QODER_MODEL_SERVER_HOST + // REPLACES it (qoder's own resolution order: env > static map). + const hosts = nonEmpty(config.qoder?.modelServerHost) ? [config.qoder!.modelServerHost!] : QODER_DEFAULT_MODEL_HOSTS; + for (const host of hosts) { + const h = host.toLowerCase(); + if (h && !httpsSeen.has(h)) { + httpsSeen.add(h); + httpsDomains.push(h); + } + } + } else if (client === "trae") { + // #655: Trae CLI is a closed Go binary (no base-URL override) that + // honors HTTPS_PROXY; the model API host is TRAE_CLI_API_HOST or the + // default enterprise gateway. Whitelist the host(s) for cert-MITM so + // the proxy can compress the model traffic. No /bili/ rewrite (the + // scheme is hardcoded https). + const hosts = nonEmpty(config.trae?.modelApiHost) + ? [config.trae!.modelApiHost!] + : TRAE_DEFAULT_MODEL_HOSTS; + for (const h of hosts) { + // MITM whitelist matches the port-less SNI hostname (isMitmHost), so + // reduce host:port to its host or the entry never matches. + const host = h.split(":", 2)[0]!.toLowerCase(); + if (host && !httpsSeen.has(host)) { + httpsSeen.add(host); + httpsDomains.push(host); + } + } } else { for (const [name, prov] of Object.entries(config.codex?.providers ?? {})) { classify(prov.baseUrl, `model_providers.${name}.base_url`); @@ -409,6 +491,12 @@ export function buildCodexEnv(origin: string, caPath: string, baseEnv: NodeJS.Pr return { ...baseEnv, HTTPS_PROXY: origin, SSL_CERT_FILE: caPath, BILLION_CONTEXT_PROXY: origin }; } +export function buildTraeEnv(origin: string, caPath: string, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + // #655: trae is a Go binary like codex — the CA rides SSL_CERT_FILE (the + // combined bundle, since it replaces Go's system trust store). + return { ...baseEnv, HTTPS_PROXY: origin, SSL_CERT_FILE: caPath, BILLION_CONTEXT_PROXY: origin }; +} + export function buildCodexArgs( origin: string, httpRewrites: HttpRewrite[], @@ -543,6 +631,77 @@ 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; +} + +/** #653: qoder's model endpoint scheme is hardcoded https (no base-URL + * override env), so the launcher can only route it via cert MITM: its + * built-in undici stack honors HTTPS_PROXY, and NODE_EXTRA_CA_CERTS is + * ADDITIVE (unlike codex's SSL_CERT_FILE), so the plain root CA suffices. + * No base-URL rewrite of any kind. */ +export function buildQoderEnv(origin: string, caPath: string, baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...baseEnv, HTTPS_PROXY: origin, NODE_EXTRA_CA_CERTS: caPath, BILLION_CONTEXT_PROXY: origin }; +} + +/** + * #653: qoder's auto-compact window is a single env knob — + * `QODER_AUTOCOMPACT_WINDOW` (`QODERCN_` prefix on the CN site) caps the + * effective context window (`min(modelWindow, env)`), so injecting bili's + * window is always safe (same #321 pattern as claude). + * + * Returns {} (no injection) when: no model resolvable, the user already set + * an explicit auto-compact window (shell-exported env var), or bili resolves + * no window for the model. qoder's model catalog is server-driven, so its + * model names are usually absent from bili's configured limits and the + * models.dev registry — the common outcome is no injection, with the user's + * own `QODER_AUTOCOMPACT_WINDOW` as the fallback (issue #653 open question 3). + */ +export async function resolveQoderBudgetEnv(opts: { + model: string | undefined; + userAutoCompactWindow: string | undefined; + windowKey: string; + routes: ProviderRoutes; + upstreamUrl: string | undefined; +}): Promise { + const { model, userAutoCompactWindow, windowKey, routes, upstreamUrl } = opts; + if (!model || nonEmpty(userAutoCompactWindow)) return {}; + const window = await resolveLauncherWindow(model, routes, upstreamUrl); + if (!window) return {}; + return { [windowKey]: String(window) }; +} + // --- Launcher plugin mode (#162): inject the MCP shell + session hooks as // spawn-time flags, never touching host config files on disk. --- @@ -614,9 +773,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" || base === "qoder" || base === "trae") return false; if (env.BILI_LAUNCHER_PLUGIN === "0") return false; if (base === "codex" && env.BILI_LAUNCHER_PLUGIN === undefined && codexUpstream !== undefined && isPrivateUpstreamHost(codexUpstream)) { return false; @@ -1787,6 +1950,22 @@ 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: [] }; + } + if (client === "qoder") { + // npm bin names: `qoder` (primary) with `qodercli` as the alternate + // registration (both packages ship either). + const resolved = resolveOnPath("qoder", env) ?? resolveOnPath("qodercli", env); + return { command: resolved ?? "qoder", prefixArgs: [] }; + } + if (client === "trae") { + const traeBin = resolveOnPath("traecli", env) + ?? resolveOnPath("trae-cli", env) + ?? resolveOnPath("trae", env); + return { command: traeBin ?? "traecli", prefixArgs: [] }; + } const resolved = resolveOnPath(client, env); return { command: resolved ?? client, prefixArgs: [] }; } @@ -1887,6 +2066,10 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} console.error( "bili: direct-URL mode — claude's ANTHROPIC_BASE_URL is overridden to the proxy; a pre-configured relay is bypassed unless BILI_CLAUDE_UPSTREAM= is set. OAuth-subscription traffic requires the default MITM mode.", ); + } else if (base === "qoder") { + console.error( + "bili: BILI_LAUNCHER_DIRECT has no effect for qoder — its model endpoint scheme is hardcoded https with no base-URL override env, so qoder always runs in cert-MITM mode.", + ); } } const codexUpstream = base === "codex" ? codexUpstreamUrl(config.codex) : undefined; @@ -2017,6 +2200,37 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} // settings rewrite above), so it exists on every profile dsh boots. const dshAcpPatch = writeDshAcpPatch(dshHomeDir); if (dshAcpPatch) clientArgs = dshArgsWithPatch(clientArgs, dshAcpPatch); + } else if (base === "qoder") { + // #653: cert-MITM only — the model endpoint scheme is hardcoded https + // (no base-URL override env), so /bili/ rewrites cannot reach it. + // qoder's undici stack honors HTTPS_PROXY + NODE_EXTRA_CA_CERTS + // (additive, so the plain root CA suffices). Proxy vars are fully + // stripped (same contract as hermes). QODER_MODEL_TRANSPORT=http + // forces the OpenAI chat-completions wire: the default transport is a + // server feature-gate whose `legacy` fallback wire is unverified + // (#653 open question 1). The env prefix family follows the CN-site + // detection (qoderIsCnSite). + env = buildQoderEnv(origin, ca, stripInheritedProxy(process.env)); + const qoderPrefix = qoderIsCnSite(process.env) ? "QODERCN" : "QODER"; + env[`${qoderPrefix}_MODEL_TRANSPORT`] = "http"; + const qoderBudget = await resolveQoderBudgetEnv({ + model: nonEmpty(process.env[`${qoderPrefix}_MODEL`]) ? process.env[`${qoderPrefix}_MODEL`] : config.qoder?.model, + userAutoCompactWindow: process.env[`${qoderPrefix}_AUTOCOMPACT_WINDOW`], + windowKey: `${qoderPrefix}_AUTOCOMPACT_WINDOW`, + routes: biliRoutes, + upstreamUrl: `https://${config.qoder?.modelServerHost ?? QODER_DEFAULT_MODEL_HOSTS[0]}`, + }); + Object.assign(env, qoderBudget); + if (qoderBudget[`${qoderPrefix}_AUTOCOMPACT_WINDOW`] !== undefined) { + console.error(`bili: qoder budget aligned — ${qoderPrefix}_AUTOCOMPACT_WINDOW=${qoderBudget[`${qoderPrefix}_AUTOCOMPACT_WINDOW`]}`); + } + } else if (base === "trae") { + // #655: cert-MITM like codex/qoder (Go binary honors HTTPS_PROXY; CA + // via SSL_CERT_FILE combined bundle). No budget env (the CLI manages + // its own context window) and no transport forcing — the wire is the + // proprietary /api/ide/v2/llm_raw_chat, recognized as OpenAI by the + // proxy. + env = buildTraeEnv(origin, resolveCombinedCaPath(process.env), stripInheritedProxy(process.env)); } else if (base === "codex") { // Per-spawn conversation id for the MCP shell's headless // self-registration (codex provides no session id of its own). @@ -2049,6 +2263,19 @@ export async function runLaunch(params: RunLaunchParams, deps: LauncherDeps = {} Object.assign(env, inj.envPatch); if (inj.warning) console.error(`bili: ${inj.warning}`); } + } 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/src/server.ts b/src/server.ts index b95f2284..9141e546 100644 --- a/src/server.ts +++ b/src/server.ts @@ -116,7 +116,7 @@ export function logUnrecognizedPath(log: (level: string, msg: string) => void, u const n = (unrecognizedPathCounts.get(key) ?? 0) + 1; unrecognizedPathCounts.set(key, n); if (n <= 3) { - log("warn", `unrecognized path ${key} — not a known protocol (/chat/completions, /v1/messages, /responses, /responses/compact); forwarding unchanged`); + log("warn", `unrecognized path ${key} — not a known protocol (/chat/completions, /llm_raw_chat, /v1/messages, /responses, /responses/compact); forwarding unchanged`); } else if (n === 4) { log("info", `unrecognized path ${key}: forwarding unchanged; further occurrences suppressed`); } @@ -982,7 +982,7 @@ async function handle( protocol = route?.explicitProtocol ?? (req.method === "POST" && bodyBuffer.length > 0 - ? urlPath.endsWith("/chat/completions") + ? urlPath.endsWith("/chat/completions") || urlPath.endsWith("/llm_raw_chat") ? "openai" : urlPath.endsWith("/v1/messages") || urlPath.endsWith("/messages") ? "anthropic" @@ -2826,7 +2826,7 @@ function logUpstreamProxyDecision(opts: ProxyOptions, upstreamUrl: string | unde * checks in handleRequest; returns null when unknown (no rewrite). */ function inferWireProtocol(path: string): "openai" | "responses" | null { const p = path.split("?", 2)[0]; - if (p.endsWith("/chat/completions")) return "openai"; + if (p.endsWith("/chat/completions") || p.endsWith("/llm_raw_chat")) return "openai"; if (p.endsWith("/responses") || p.endsWith("/responses/compact")) return "responses"; return null; } diff --git a/tests/discover.test.ts b/tests/discover.test.ts index d8bf82e9..ee535ea6 100644 --- a/tests/discover.test.ts +++ b/tests/discover.test.ts @@ -10,7 +10,9 @@ import { } from "../src/discover.ts"; import { parseZcodeConfig, + TRAE_DEFAULT_MODEL_HOSTS, readZcodeConfig, + QODER_DEFAULT_MODEL_HOSTS, type ClientConfig, } from "../src/client-config.ts"; @@ -119,6 +121,34 @@ 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", + ]); +}); + +test("extractHttpsHosts: qoder → default model hosts; modelServerHost replaces them (#653)", () => { + assert.deepEqual(extractHttpsHosts({ qoder: {} }), QODER_DEFAULT_MODEL_HOSTS); + assert.deepEqual(extractHttpsHosts({ qoder: { modelServerHost: "my-relay.example.com" } }), ["my-relay.example.com"]); +}); + +test("extractHttpsHosts: trae → default model hosts; modelApiHost replaces them (#655)", () => { + assert.deepEqual(extractHttpsHosts({ trae: {} }), TRAE_DEFAULT_MODEL_HOSTS); + assert.deepEqual(extractHttpsHosts({ trae: { modelApiHost: "my-relay.example.com" } }), ["my-relay.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; @@ -130,6 +160,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 { @@ -163,6 +194,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"]); @@ -195,4 +246,4 @@ test("discoverMitmDomains: mtime change + TTL expiry triggers re-scan", async () assert.ok(!after.includes("v1.example.com"), `v1 gone: ${after.join(",")}`); assert.notStrictEqual(after, first); }); -}); +}); \ No newline at end of file diff --git a/tests/launcher.test.ts b/tests/launcher.test.ts index 46134db0..66686e88 100644 --- a/tests/launcher.test.ts +++ b/tests/launcher.test.ts @@ -39,6 +39,10 @@ import { resolveHermesHome, parseDshSettingsYaml, readDshConfig, + resolveTraeHome, + readTraeConfig, + buildTraeEnv, + TRAE_DEFAULT_MODEL_HOSTS, resolveDshHome, prepareDshHome, writeDshAcpPatch, @@ -55,8 +59,19 @@ import { resolveLauncherWindow, resolveCodexBudgetArgs, resolveClaudeBudgetEnv, + resolveQoderBudgetEnv, + buildQoderEnv, + readQoderConfig, + resolveQoderHome, + qoderIsCnSite, + QODER_DEFAULT_MODEL_HOSTS, + resolveCodebuddyBudgetEnv, + buildCodebuddyEnv, codexUpstreamUrl, readClaudeSettings, + readCodebuddyConfig, + parseCodebuddyModelsJson, + resolveCodebuddyHome, type SpawnChild, type SpawnFn, runLaunch, @@ -73,6 +88,8 @@ test("isLaunchClient: pi/claude/codex/omp/opencode/pi-test true, others false", assert.equal(isLaunchClient("opencode"), true); assert.equal(isLaunchClient("hermes"), true); assert.equal(isLaunchClient("dsh"), true); + assert.equal(isLaunchClient("trae"), true); + assert.equal(isLaunchClient("qoder"), true); assert.equal(isLaunchClient("pi-test"), true); assert.equal(isLaunchClient("start"), false); assert.equal(isLaunchClient(""), false); @@ -2454,3 +2471,695 @@ 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, process.platform === "win32" ? "fake-codebuddy.exe" : "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 }); + } +}); + +test("qoderIsCnSite: QODERCLI_SITE and CN-prefixed envs decide the site", () => { + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "cn" }), true); + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "CN " }), true); + assert.equal(qoderIsCnSite({ QODERCLI_SITE: "intl" }), false); + assert.equal(qoderIsCnSite({ QODERCN_CONFIG_DIR: "/tmp/qcn" }), true); + assert.equal(qoderIsCnSite({ QODERCN_CLI_HOME: "/tmp/qcn" }), true); + assert.equal(qoderIsCnSite({ QODER_CONFIG_DIR: "/tmp/q" }), false); +}); + +test("qoderIsCnSite: on-disk config dirs only break the tie (no dirs → intl)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-site-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + try { + assert.equal(qoderIsCnSite({}), false, "no config dirs → intl"); + fs.mkdirSync(path.join(home, ".qoder-cn")); + assert.equal(qoderIsCnSite({}), true, "only .qoder-cn present → cn"); + fs.mkdirSync(path.join(home, ".qoder")); + assert.equal(qoderIsCnSite({}), false, "both present → intl (default)"); + } finally { + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("resolveQoderHome: env override > CLI_HOME+dir name > site default", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-home-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + try { + assert.equal(resolveQoderHome({ QODER_CONFIG_DIR: "/tmp/qc" }), "/tmp/qc"); + assert.equal(resolveQoderHome({ QODERCLI_SITE: "cn", QODERCN_CONFIG_DIR: "/tmp/qcn" }), "/tmp/qcn"); + assert.equal(resolveQoderHome({ QODER_CLI_HOME: "/tmp/parent" }), path.join("/tmp/parent", ".qoder")); + assert.equal(resolveQoderHome({ QODER_CONFIG_DIR_NAME: ".qoder-x" }), path.join(home, ".qoder-x")); + assert.equal( + resolveQoderHome({ QODERCLI_SITE: "cn", QODERCN_CLI_HOME: "/tmp/parent", QODERCN_CONFIG_DIR_NAME: ".qcn" }), + path.join("/tmp/parent", ".qcn"), + ); + assert.equal(resolveQoderHome({}), path.join(home, ".qoder")); + assert.equal(resolveQoderHome({ QODERCLI_SITE: "cn" }), path.join(home, ".qoder-cn")); + } finally { + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("readQoderConfig: settings.json model (string + object) and model server host env", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-cfg-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + const dir = path.join(home, ".qoder"); + fs.mkdirSync(dir, { recursive: true }); + try { + assert.deepEqual(readQoderConfig(dir, {}), {}); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: { name: "qwen3-max" } })); + assert.deepEqual(readQoderConfig(dir, {}), { model: "qwen3-max" }); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: "qwen3-max", mcpServers: {} })); + assert.deepEqual(readQoderConfig(dir, {}), { model: "qwen3-max" }); + fs.writeFileSync(path.join(dir, "settings.json"), "not-json{"); + assert.deepEqual(readQoderConfig(dir, {}), {}); + fs.writeFileSync(path.join(dir, "settings.json"), JSON.stringify({ model: { name: "qwen3-max" } })); + const withHost = readQoderConfig(dir, { QODER_MODEL_SERVER_HOST: "https://my-relay.example.com:8443/" }); + assert.equal(withHost.model, "qwen3-max"); + assert.equal(withHost.modelServerHost, "my-relay.example.com:8443"); + const cnHost = readQoderConfig(dir, { QODERCLI_SITE: "cn", QODERCN_MODEL_SERVER_HOST: "cn-relay.example.com" }); + assert.equal(cnHost.modelServerHost, "cn-relay.example.com"); + const mixed = readQoderConfig(dir, { QODERCLI_SITE: "cn", QODER_MODEL_SERVER_HOST: "intl.example.com" }); + assert.equal(mixed.modelServerHost, undefined, "intl-prefixed host ignored on CN site"); + } finally { + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("discoverRoutes: qoder → default MITM hosts, no rewrites (#653)", () => { + const routes = discoverRoutes("qoder", {}); + assert.deepEqual(routes.httpsDomains, QODER_DEFAULT_MODEL_HOSTS); + assert.deepEqual(routes.httpRewrites, []); + assert.deepEqual(routes.httpsRewrites, []); + assert.deepEqual(routes.httpEnvRoutes, []); +}); + +test("discoverRoutes: qoder modelServerHost replaces the default map", () => { + const config: ClientConfig = { qoder: { model: "m", modelServerHost: "my-relay.example.com" } }; + const routes = discoverRoutes("qoder", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("buildQoderEnv: HTTPS_PROXY + NODE_EXTRA_CA_CERTS + BILLION_CONTEXT_PROXY, baseEnv preserved", () => { + const env = buildQoderEnv("http://127.0.0.1:8787", "/tmp/ca.pem", { FOO: "bar" }); + 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.FOO, "bar"); +}); + +test("resolveQoderBudgetEnv: injects the site-prefixed window key from bili's chain", async () => { + registrySetForTest({}); + try { + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), + { QODER_AUTOCOMPACT_WINDOW: "200000" }, + ); + const routes = { "https://relay.example.com": { models: { "qoder-x": { context: 123456 } } } }; + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "qoder-x", userAutoCompactWindow: undefined, windowKey: "QODERCN_AUTOCOMPACT_WINDOW", routes, upstreamUrl: "https://relay.example.com" }), + { QODERCN_AUTOCOMPACT_WINDOW: "123456" }, + ); + registrySetForTest({ "anthropic/bili-fallback-model": { limit: { context: 333333 } } }); + assert.deepEqual( + await resolveQoderBudgetEnv({ model: "bili-fallback-model", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api.anthropic.com" }), + { QODER_AUTOCOMPACT_WINDOW: "333333" }, + ); + } finally { + registryResetForTest(); + } +}); + +test("resolveQoderBudgetEnv: no injection when user self-aligned or unresolvable", async () => { + registrySetForTest({}); + try { + assert.deepEqual(await resolveQoderBudgetEnv({ model: "claude-sonnet-4-5", userAutoCompactWindow: "300000", windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + assert.deepEqual(await resolveQoderBudgetEnv({ model: undefined, userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + assert.deepEqual(await resolveQoderBudgetEnv({ model: "qoder-nonexistent-model-xyz", userAutoCompactWindow: undefined, windowKey: "QODER_AUTOCOMPACT_WINDOW", routes: {}, upstreamUrl: "https://api2-v2.qoder.sh" }), {}); + } finally { + registryResetForTest(); + } +}); + +test("resolveClientCommand: qoder resolves `qoder`, falls back to `qodercli`", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-bin-")); + try { + const env: NodeJS.ProcessEnv = { PATH: dir }; + assert.deepEqual(resolveClientCommand("qoder", env), { command: "qoder", prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "qodercli"), ""); + assert.deepEqual(resolveClientCommand("qoder", env), { command: path.join(dir, "qodercli"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "qoder"), ""); + assert.deepEqual(resolveClientCommand("qoder", env), { command: path.join(dir, "qoder"), prefixArgs: [] }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runLaunch qoder: cert-MITM envs, transport forced, budget aligned, default MITM whitelist (#653)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-qoder-launch-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevBin = process.env.BILI_CLIENT_BIN; + const prevModel = process.env.QODER_MODEL; + const prevWindow = process.env.QODER_AUTOCOMPACT_WINDOW; + const prevTransport = process.env.QODER_MODEL_TRANSPORT; + const prevNoProxy = process.env.NO_PROXY; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + delete process.env.QODER_MODEL; + delete process.env.QODER_AUTOCOMPACT_WINDOW; + delete process.env.QODER_MODEL_TRANSPORT; + const qoderDir = path.join(home, ".qoder"); + fs.mkdirSync(qoderDir, { recursive: true }); + fs.writeFileSync(path.join(qoderDir, "settings.json"), JSON.stringify({ model: { name: "claude-sonnet-4-5" } })); + const fakeQoder = path.join(home, process.platform === "win32" ? "fake-qoder.exe" : "fake-qoder"); + fs.writeFileSync(fakeQoder, ""); + process.env.BILI_CLIENT_BIN = fakeQoder; + process.env.NO_PROXY = "localhost,.corp"; + + const clientEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const proxyEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const spawnImpl: SpawnFn = (cmd, args, opts) => { + const env = (opts as { env?: NodeJS.ProcessEnv } | undefined)?.env; + if (cmd === fakeQoder) { + clientEnvs.push(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; + } + proxyEnvs.push(env); + return makeFakeChild(42424); + }; + const fetchImpl = async () => ({ ok: true }); + const prevExit = process.exit; + process.exit = (() => undefined) as typeof process.exit; + + try { + await runLaunch( + { client: "qoder", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + const seenEnv = clientEnvs[0]!; + const origin = seenEnv.BILLION_CONTEXT_PROXY; + assert.ok(/^http:\/\/127\.0\.0\.1:\d+$/.test(String(origin)), `origin: ${origin}`); + assert.equal(seenEnv.HTTPS_PROXY, origin); + assert.ok(String(seenEnv.NODE_EXTRA_CA_CERTS).endsWith(path.join("billion-context", "ca", "root-ca.pem")), String(seenEnv.NODE_EXTRA_CA_CERTS)); + assert.equal(seenEnv.HTTP_PROXY, undefined, "inherited HTTP_PROXY stripped"); + assert.equal(seenEnv.NO_PROXY, undefined, "inherited NO_PROXY stripped"); + assert.equal(seenEnv.QODER_MODEL_TRANSPORT, "http"); + assert.equal(seenEnv.QODER_AUTOCOMPACT_WINDOW, "200000", "budget aligned from built-in table"); + assert.ok(proxyEnvs.length > 0, "proxy child spawned"); + const mitm = String(proxyEnvs[0]!.BILI_MITM_DOMAINS).split(","); + for (const h of QODER_DEFAULT_MODEL_HOSTS) { + assert.ok(mitm.includes(h), `whitelist has ${h}: ${mitm.join(",")}`); + } + } finally { + process.exit = prevExit; + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevBin === undefined) delete process.env.BILI_CLIENT_BIN; + else process.env.BILI_CLIENT_BIN = prevBin; + if (prevModel === undefined) delete process.env.QODER_MODEL; + else process.env.QODER_MODEL = prevModel; + if (prevWindow === undefined) delete process.env.QODER_AUTOCOMPACT_WINDOW; + else process.env.QODER_AUTOCOMPACT_WINDOW = prevWindow; + if (prevTransport === undefined) delete process.env.QODER_MODEL_TRANSPORT; + else process.env.QODER_MODEL_TRANSPORT = prevTransport; + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("resolveTraeHome: TRAE_CONFIG_DIR override > ~/.trae", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-home-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + try { + assert.equal(resolveTraeHome({ TRAE_CONFIG_DIR: "/tmp/trae-cfg" }), "/tmp/trae-cfg"); + assert.equal(resolveTraeHome({}), path.join(home, ".trae")); + } finally { + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("readTraeConfig: TRAE_CLI_API_HOST (scheme + trailing slash stripped)", () => { + assert.deepEqual(readTraeConfig({}), {}); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: "https://my-relay.example.com:8443/" }), { modelApiHost: "my-relay.example.com:8443" }); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: "my-relay.example.com" }), { modelApiHost: "my-relay.example.com" }); + assert.deepEqual(readTraeConfig({ TRAE_CLI_API_HOST: " " }), {}); +}); + +test("discoverRoutes: trae → default MITM hosts, no rewrites (#655)", () => { + const routes = discoverRoutes("trae", {}); + assert.deepEqual(routes.httpsDomains, TRAE_DEFAULT_MODEL_HOSTS); + assert.deepEqual(routes.httpRewrites, []); + assert.deepEqual(routes.httpsRewrites, []); + assert.deepEqual(routes.httpEnvRoutes, []); +}); + +test("discoverRoutes: trae modelApiHost replaces the default map", () => { + const config: ClientConfig = { trae: { modelApiHost: "my-relay.example.com" } }; + const routes = discoverRoutes("trae", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("discoverRoutes: trae modelApiHost with :port → hostname only (MITM is SNI-based, #655)", () => { + const config: ClientConfig = { trae: { modelApiHost: "my-relay.example.com:8443" } }; + const routes = discoverRoutes("trae", config); + assert.deepEqual(routes.httpsDomains, ["my-relay.example.com"]); +}); + +test("buildTraeEnv: HTTPS_PROXY + SSL_CERT_FILE + BILLION_CONTEXT_PROXY, baseEnv preserved", () => { + const env = buildTraeEnv("http://127.0.0.1:8787", "/tmp/ca.pem", { FOO: "bar" }); + assert.equal(env.HTTPS_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.SSL_CERT_FILE, "/tmp/ca.pem"); + assert.equal(env.BILLION_CONTEXT_PROXY, "http://127.0.0.1:8787"); + assert.equal(env.FOO, "bar"); +}); + +test("resolveClientCommand: trae resolves `traecli`, falls back to `trae-cli` then `trae`", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-bin-")); + try { + const env: NodeJS.ProcessEnv = { PATH: dir }; + assert.deepEqual(resolveClientCommand("trae", env), { command: "traecli", prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "trae"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "trae"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "trae-cli"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "trae-cli"), prefixArgs: [] }); + fs.writeFileSync(path.join(dir, "traecli"), ""); + assert.deepEqual(resolveClientCommand("trae", env), { command: path.join(dir, "traecli"), prefixArgs: [] }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runLaunch trae: cert-MITM envs (SSL_CERT_FILE combined bundle), no budget/transport, default MITM whitelist (#655)", async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-trae-launch-")); + const prevHome = process.env.HOME; + const prevUserProfile = process.env.USERPROFILE; + const prevBin = process.env.BILI_CLIENT_BIN; + const prevNoProxy = process.env.NO_PROXY; + process.env.HOME = home; + if (prevUserProfile !== undefined) process.env.USERPROFILE = home; + const fakeTrae = path.join(home, process.platform === "win32" ? "fake-traecli.exe" : "fake-traecli"); + fs.writeFileSync(fakeTrae, ""); + process.env.BILI_CLIENT_BIN = fakeTrae; + process.env.NO_PROXY = "localhost,.corp"; + + const clientEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const proxyEnvs: (NodeJS.ProcessEnv | undefined)[] = []; + const spawnImpl: SpawnFn = (cmd, args, opts) => { + const env = (opts as { env?: NodeJS.ProcessEnv } | undefined)?.env; + if (cmd === fakeTrae) { + clientEnvs.push(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; + } + proxyEnvs.push(env); + return makeFakeChild(42424); + }; + const fetchImpl = async () => ({ ok: true }); + const prevExit = process.exit; + process.exit = (() => undefined) as typeof process.exit; + + try { + await runLaunch( + { client: "trae", clientArgs: [], overrides: {} }, + { fetchImpl, spawnImpl, sleep: () => Promise.resolve() }, + ); + assert.equal(clientEnvs.length, 1); + const seenEnv = clientEnvs[0]!; + const origin = seenEnv.BILLION_CONTEXT_PROXY; + assert.ok(/^http:\/\/127\.0\.0\.1:\d+$/.test(String(origin)), `origin: ${origin}`); + assert.equal(seenEnv.HTTPS_PROXY, origin); + assert.ok(String(seenEnv.SSL_CERT_FILE).endsWith(path.join("billion-context", "ca", "combined-ca.pem")), String(seenEnv.SSL_CERT_FILE)); + assert.equal(seenEnv.NODE_EXTRA_CA_CERTS, undefined, "trae uses SSL_CERT_FILE, not NODE_EXTRA_CA_CERTS"); + assert.equal(seenEnv.HTTP_PROXY, undefined, "inherited HTTP_PROXY stripped"); + assert.equal(seenEnv.NO_PROXY, undefined, "inherited NO_PROXY stripped"); + assert.ok(proxyEnvs.length > 0, "proxy child spawned"); + const mitm = String(proxyEnvs[0]!.BILI_MITM_DOMAINS).split(","); + for (const h of TRAE_DEFAULT_MODEL_HOSTS) { + assert.ok(mitm.includes(h), `whitelist has ${h}: ${mitm.join(",")}`); + } + } finally { + process.exit = prevExit; + process.env.HOME = prevHome; + if (prevUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = prevUserProfile; + if (prevBin === undefined) delete process.env.BILI_CLIENT_BIN; + else process.env.BILI_CLIENT_BIN = prevBin; + if (prevNoProxy === undefined) delete process.env.NO_PROXY; + else process.env.NO_PROXY = prevNoProxy; + fs.rmSync(home, { recursive: true, force: true }); + } +}); \ No newline at end of file