Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <client>` launchers (pi/codex/claude/omp/opencode/hermes)
│ ├── launcher.ts # `bili <client>` 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)
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (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
```

Expand Down
4 changes: 3 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
136 changes: 135 additions & 1 deletion src/client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ 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 ClientConfig {
claude?: ClaudeSettings;
codex?: CodexConfig;
Expand All @@ -102,6 +120,7 @@ export interface ClientConfig {
opencode?: OpencodeConfig;
hermes?: HermesConfig;
dsh?: DshConfig;
codebuddy?: CodebuddyConfig;
}

export function nonEmpty(s: unknown): s is string {
Expand Down Expand Up @@ -159,6 +178,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<string>();
const collect = (id: unknown, entry: unknown): void => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
const e = entry as Record<string, unknown>;
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<string, unknown>;
collect(it.id ?? it.name, it);
}
return out;
}
if (typeof obj !== "object") return out;
const root = obj as Record<string, unknown>;
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<string, unknown>;
collect(it.id ?? it.name, it);
}
return out;
}
if (modelsField && typeof modelsField === "object") {
for (const [id, val] of Object.entries(modelsField as Record<string, unknown>)) 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):
* - <configDir>/settings.json: `env.CODEBUDDY_BASE_URL` (OpenAI chat
* completions endpoint), top-level `model`, top-level `autoCompactWindow`;
* - two-tier <configDir>/models.json + <cwd>/.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<string, unknown>;
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<string, number>();
const urls: string[] = [];
const seenUrl = new Set<string>();
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
Expand Down Expand Up @@ -579,14 +710,15 @@ 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;
}

/** The client a launcher run targets. Scopes model-window collection so a
* 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
Expand All @@ -608,11 +740,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;
}
10 changes: 9 additions & 1 deletion src/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -43,19 +43,27 @@ 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;
}

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"),
];
}

Expand Down
Loading
Loading