Skip to content
Closed
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/qoder)
│ ├── 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
7 changes: 4 additions & 3 deletions 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 qoder`)

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 @@ -176,8 +176,9 @@ bili claude # launch claude through the proxy
bili omp # pi-style, file-free (#535): env + extension registerProvider + compaction cancel, real ~/.omp untouched
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 pi --mitm-domain api.foo.com # add a domain to the MITM whitelist
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 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 pi --mitm-domain api.foo.com # add a domain to the MITM whitelist
```

### Option 2 — URL change (`/bili/` prefix)
Expand Down
13 changes: 8 additions & 5 deletions 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 qoder [opts --] [args] start a proxy + launch qoder 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)
Expand All @@ -81,12 +82,13 @@ 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 qoder):
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 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
Expand All @@ -96,8 +98,9 @@ Launcher (bili pi / bili codex / bili claude / bili omp / bili opencode / bili h
bili claude # launch claude through the proxy
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 test pi # quick end-to-end check of the pi path
bili dsh --profile web "task" # launch deepseek-harness through the proxy (/bili/ rewrite of ~/.dsh/settings.yaml)
bili qoder # launch qoder through the proxy (cert-MITM; model endpoint is hardcoded https, so no /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
88 changes: 87 additions & 1 deletion src/client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ export interface DshConfig {
baseUrls: 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 ClientConfig {
claude?: ClaudeSettings;
codex?: CodexConfig;
Expand All @@ -102,6 +112,81 @@ export interface ClientConfig {
opencode?: OpencodeConfig;
hermes?: HermesConfig;
dsh?: DshConfig;
qoder?: QoderConfig;
}

/** 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 `<configDir>/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<string, unknown>).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 {
Expand Down Expand Up @@ -579,14 +664,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.qoder = readQoderConfig(resolveQoderHome(env), 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" | "qoder";

/** Collect per-model context windows from client configs the launcher can
* read (pi models.json, omp models.yml, opencode opencode.json, codex
Expand Down
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, resolveQoderHome, nonEmpty, QODER_DEFAULT_MODEL_HOSTS, type ClientConfig } from "./client-config.js";

const TTL_MS = 2000;

Expand Down Expand Up @@ -43,6 +43,13 @@ export function extractHttpsHosts(config: ClientConfig): string[] {
if (config.zcode) {
for (const prov of Object.values(config.zcode.providers)) push(prov.baseURL);
}
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}`);
}
return out;
}

Expand All @@ -56,6 +63,7 @@ function configFilePaths(env: NodeJS.ProcessEnv): string[] {
path.join(codexHome, "config.toml"),
path.join(resolvePiHome(env), "models.json"),
path.join(zcodeHome, "v2", "config.json"),
path.join(resolveQoderHome(env), "settings.json"),
];
}

Expand Down
Loading
Loading