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
89 changes: 78 additions & 11 deletions src/client-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,20 @@ export interface AiderConfig {
* plain HTTP instead: built-in openai/anthropic via their `*_HOST` env
* overrides, custom declarative providers via a regenerated config overlay. */
export interface GooseConfig {
/** `active_provider` from config.toml (legacy GOOSE_PROVIDER env wins). */
/** `active_provider` from goose's `config.yaml` (its `config.toml` spelling
* and the legacy GOOSE_PROVIDER env are read as fallbacks). */
activeProvider?: string;
/** Custom declarative providers: file name (without .toml) → raw
* `base_url` from `<config_dir>/custom_providers/<name>.toml`. */
/** Custom declarative providers: file name (without extension) → raw
* `base_url` from `<config_dir>/custom_providers/<name>.json` — goose
* writes declarative providers as JSON (its `declarative.rs` loader
* filters `extension == "json"`, and `config/declarative_providers.rs`
* writes `{id}.json`). The `.toml` spelling is kept as a fallback for any
* build that migrates formats. */
customProviders: Record<string, string>;
/** Per-model context windows the declarative providers declare
* (`models[].context_limit`) — the launcher's window denominator for
* `bili goose`, same role as every other scoped client's model windows. */
models?: ModelWindow[];
}

/** crush.json discovery result — kept module-local: nobody imports the type
Expand Down Expand Up @@ -1358,32 +1367,85 @@ export function resolveGooseDirs(env: NodeJS.ProcessEnv = process.env): GooseDir
return { configDir, dataDir, stateDir, agentsDir: path.join(dataDir, ".agents") };
}

/** goose's `config.yaml` top-level scalar (no YAML dependency — same targeted
* discipline as parseMcodeYaml). Only unindented lines qualify: goose keeps its
* settings flat, and an indented key of the same name belongs to some block. */
function gooseYamlTopLevel(text: string, key: string): string | undefined {
for (const raw of text.split(/\r?\n/)) {
if (/^\s/.test(raw)) continue;
const line = yamlLineContent(raw);
if (!line) continue;
const m = new RegExp(`^${key}\\s*:\\s*(.+)$`).exec(line);
const value = m?.[1] ? unquoteYaml(m[1]) : undefined;
if (value) return value;
}
return undefined;
}

/** goose's declarative provider files carry `base_url` at the top level. Read
* from the line rather than by JSON.parse, so a half-written file still yields
* its url: the `^` branch matches goose's `to_string_pretty` spelling (the key
* on its own line), the `[,{]` branch a hand-written compact one. launcher.ts
* rewrites with this SAME pattern — what gets discovered here must always be
* something the overlay can re-point, or the route would mislead. */
export const GOOSE_JSON_BASE_URL_RE = /(^[\s]*|[,{][\s]*)"base_url"(\s*:\s*)("[^"]*")/;

/** First `base_url` string in a goose provider JSON, line-scanned so the match
* shape stays identical to the launcher's rewrite. */
function gooseJsonBaseUrl(text: string): string | undefined {
for (const line of text.split(/\r?\n/)) {
const m = GOOSE_JSON_BASE_URL_RE.exec(line);
const url = m?.[3]?.slice(1, -1);
if (url) return url;
}
return undefined;
}

export function readGooseConfig(dirs: GooseDirs, env: NodeJS.ProcessEnv = process.env): GooseConfig {
const result: GooseConfig = { customProviders: {} };
if (nonEmpty(env.GOOSE_PROVIDER)) {
result.activeProvider = env.GOOSE_PROVIDER!;
} else {
try {
const text = fs.readFileSync(path.join(dirs.configDir, "config.toml"), "utf8");
const m = /^active_provider\s*=\s*["']([^"']+)["']/m.exec(text);
if (m?.[1]) result.activeProvider = m[1];
const yaml = gooseYamlTopLevel(fs.readFileSync(path.join(dirs.configDir, "config.yaml"), "utf8"), "active_provider");
if (yaml) {
result.activeProvider = yaml;
}
} catch {}
if (!result.activeProvider) {
try {
const text = fs.readFileSync(path.join(dirs.configDir, "config.toml"), "utf8");
const m = /^active_provider\s*=\s*["']([^"']+)["']/m.exec(text);
if (m?.[1]) result.activeProvider = m[1];
} catch {}
}
}
let entries: string[] = [];
try {
entries = fs.readdirSync(path.join(dirs.configDir, "custom_providers"));
} catch {}
const windows: ModelWindow[] = [];
for (const name of entries.sort()) {
if (!name.endsWith(".toml")) continue;
const json = name.endsWith(".json");
if (!json && !name.endsWith(".toml")) continue;
const file = path.join(dirs.configDir, "custom_providers", name);
let txt: string;
try {
txt = fs.readFileSync(path.join(dirs.configDir, "custom_providers", name), "utf8");
txt = fs.readFileSync(file, "utf8");
} catch {
continue;
}
const m = /^\s*base_url\s*=\s*["']([^"']+)["']/m.exec(txt);
if (m?.[1]) result.customProviders[name.replace(/\.toml$/, "")] = m[1];
const url = json ? gooseJsonBaseUrl(txt) : /^\s*base_url\s*=\s*["']([^"']+)["']/m.exec(txt)?.[1];
if (url) result.customProviders[name.replace(/\.(json|toml)$/, "")] = url;
if (!json) continue;
const obj = readJsonObject(file);
for (const model of Array.isArray(obj?.models) ? obj!.models as unknown[] : []) {
const rec = model && typeof model === "object" && !Array.isArray(model) ? (model as Record<string, unknown>) : {};
const win = toModelWindow(rec.name, rec.context_limit);
if (win) windows.push(win);
}
}
if (windows.length > 0) result.models = windows;
return result;
}

Expand Down Expand Up @@ -2143,7 +2205,8 @@ type ModelWindowScope = "claude" | "codex" | "pi" | "omp" | "opencode" | "hermes

/** Collect per-model context windows from client configs the launcher can
* read (pi models.json, omp models.yml, opencode opencode.json, codex
* config.toml). With `scope`, ONLY that client's declarations are collected —
* config.toml, goose custom_providers JSON). With `scope`, ONLY that client's
* declarations are collected —
* the launched client's config is authoritative for its own proxy (#436:
* cross-client max-wins silently discarded the user's smaller configured
* window). Without `scope`, merges every client (legacy behavior). Same model
Expand All @@ -2164,6 +2227,7 @@ export function collectModelWindows(config: ClientConfig, scope?: ModelWindowSco
else if (scope === "codebuddy") add(config.codebuddy?.models);
else if (scope === "kimi") add(config.kimi?.models);
else if (scope === "mcode") add(config.mcode?.models);
else if (scope === "goose") add(config.goose?.models);
return out;
}
for (const p of Object.values(config.pi?.providers ?? {})) add(p.models);
Expand All @@ -2173,6 +2237,7 @@ export function collectModelWindows(config: ClientConfig, scope?: ModelWindowSco
add(config.codebuddy?.models);
add(config.kimi?.models);
add(config.mcode?.models);
add(config.goose?.models);
return out;
}

Expand All @@ -2195,6 +2260,7 @@ export function collectModelMaxOutputs(config: ClientConfig, scope?: ModelWindow
else if (scope === "codebuddy") add(config.codebuddy?.models);
else if (scope === "kimi") add(config.kimi?.models);
else if (scope === "mcode") add(config.mcode?.models);
else if (scope === "goose") add(config.goose?.models);
return out;
}
for (const p of Object.values(config.pi?.providers ?? {})) add(p.models);
Expand All @@ -2204,5 +2270,6 @@ export function collectModelMaxOutputs(config: ClientConfig, scope?: ModelWindow
add(config.codebuddy?.models);
add(config.kimi?.models);
add(config.mcode?.models);
add(config.goose?.models);
return out;
}
23 changes: 18 additions & 5 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import { winCmdUnsafeToken, winCmdRefusalError } from "./win-cmd.js";
function selfDistFile(name: string): string {
return path.join(selfPackageRoot(), "dist", name);
}
import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, resolveCodexEffectiveView, loadClientConfig, collectModelWindows, collectModelMaxOutputs, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, readOpencodeConfigRoot, opencodePluginBaseDir, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, JCODE_DEFAULT_MODEL_HOSTS, type TraeConfig, resolveKimiHome, readKimiConfig, parseKimiToml, KIMI_DEFAULT_MODEL_HOSTS, QWEN_DEFAULT_MODEL_HOSTS, type KimiConfig, type KimiProvider, readOpencodeProjectLayer, type OpencodeProjectLayer, readMcodeConfig, resolveMcodeInstallDir, MCODE_DEFAULT_MODEL_HOSTS, type McodeConfig, discoverAiderArgUrls, AIDER_DEFAULT_MODEL_HOSTS, COPILOT_DEFAULT_MODEL_HOSTS, AMP_DEFAULT_MODEL_HOSTS, CRUSH_DEFAULT_MODEL_HOSTS, ZED_DEFAULT_MODEL_HOSTS, readZedConfig, resolveGooseDirs, readGooseConfig, type GooseConfig, type GooseDirs } from "./client-config.js";
import { nonEmpty, resolvePiHome, resolveOmpHome, resolveDshHome, resolveCodexHome, resolveCodexEffectiveView, loadClientConfig, collectModelWindows, collectModelMaxOutputs, type ClientConfig, type CodexConfig, resolveOpencodeConfigFile, readOpencodeConfigRoot, opencodePluginBaseDir, type OpencodeConfig, type OpencodeProvider, type HermesConfig, type HermesProvider, qoderIsCnSite, QODER_DEFAULT_MODEL_HOSTS, resolveTraeHome, readTraeConfig, TRAE_DEFAULT_MODEL_HOSTS, JCODE_DEFAULT_MODEL_HOSTS, type TraeConfig, resolveKimiHome, readKimiConfig, parseKimiToml, KIMI_DEFAULT_MODEL_HOSTS, QWEN_DEFAULT_MODEL_HOSTS, type KimiConfig, type KimiProvider, readOpencodeProjectLayer, type OpencodeProjectLayer, readMcodeConfig, resolveMcodeInstallDir, MCODE_DEFAULT_MODEL_HOSTS, type McodeConfig, discoverAiderArgUrls, AIDER_DEFAULT_MODEL_HOSTS, COPILOT_DEFAULT_MODEL_HOSTS, AMP_DEFAULT_MODEL_HOSTS, CRUSH_DEFAULT_MODEL_HOSTS, ZED_DEFAULT_MODEL_HOSTS, readZedConfig, resolveGooseDirs, readGooseConfig, GOOSE_JSON_BASE_URL_RE, type GooseConfig, type GooseDirs } from "./client-config.js";
import { loadRoutes, resolveConfiguredContextLimit, lookupContextLimit, resolveNativeAttachExternal, resolveNativeAttachLanePriority, resolveNativeAttachPolicy, resolveNativeAttachUrl, resolveMitmDomains, resolveNonHttpProviders, resolveRecordedMitmDomains, resolveRecordedPassthrough, type ProviderRoutes } from "./config.js";
import { discoverMitmDomains } from "./discover.js";
import { contextFromRegistry } from "./registry.js";
Expand Down Expand Up @@ -2964,7 +2964,10 @@ export function prepareGooseHome(env: NodeJS.ProcessEnv, origin: string, rewrite
cpEntries = fs.readdirSync(path.join(cfg, "custom_providers"));
} catch {}
for (const name of cpEntries) {
if (!name.endsWith(".toml")) continue;
// goose writes declarative providers as `<id>.json` (its loader filters
// `extension == "json"`); the `.toml` spelling is kept as a fallback.
const json = name.endsWith(".json");
if (!json && !name.endsWith(".toml")) continue;
const file = path.join(cfg, "custom_providers", name);
let txt: string;
try {
Expand All @@ -2975,11 +2978,21 @@ export function prepareGooseHome(env: NodeJS.ProcessEnv, origin: string, rewrite
const eol = txt.includes("\r\n") ? "\r\n" : "\n";
let changed = false;
const lines = txt.split(/\r?\n/).map((line) => {
if (json) {
const m = GOOSE_JSON_BASE_URL_RE.exec(line);
if (!m) return line;
const rawUrl = m[3].slice(1, -1);
if (!/^https?:\/\//i.test(rawUrl)) return line;
const real = unwrapUpstream(rawUrl);
if (!wrapSet.has(real)) return line;
changed = true;
const valueStart = m.index + m[0].length - m[3].length;
return line.slice(0, valueStart) + '"' + wrapUpstream(origin, real) + '"' + line.slice(m.index + m[0].length);
}
const m = /^(\s*base_url\s*=\s*)(["'])([^"']+)\2(\s*(?:#.*)?)$/.exec(line);
if (!m) return line;
const rawUrl = m[3];
if (!/^https?:\/\//i.test(rawUrl)) return line;
const real = unwrapUpstream(rawUrl);
if (!/^https?:\/\//i.test(m[3])) return line;
const real = unwrapUpstream(m[3]);
if (!wrapSet.has(real)) return line;
changed = true;
return `${m[1]}${m[2]}${wrapUpstream(origin, real)}${m[2]}${m[4] ?? ""}`;
Expand Down
77 changes: 77 additions & 0 deletions tests/launcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6884,6 +6884,83 @@ test("readGooseConfig: active_provider + custom_providers base_urls; GOOSE_PROVI
}
});

// goose's shipped layout is `config.yaml` + `custom_providers/<id>.json` (its
// loader filters `extension == "json"`, and config/declarative_providers.rs
// writes `{id}.json`). The TOML-only reader discovered nothing for it, so the
// launcher launched with no route for the custom provider (#1049 follow-up).
test("readGooseConfig: config.yaml + custom_providers/*.json (goose's real layout), incl. model windows", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-json-"));
try {
const cfgDir = path.join(root, "config");
fs.mkdirSync(path.join(cfgDir, "custom_providers"), { recursive: true });
fs.writeFileSync(path.join(cfgDir, "config.yaml"), [
"# goose settings",
"GOOSE_MODEL: some-model",
"active_provider: mine # trailing comment",
"extensions:",
" active_provider: nested-not-a-setting",
].join("\n"));
fs.writeFileSync(path.join(cfgDir, "custom_providers", "mine.json"), JSON.stringify({
name: "mine",
engine: "openai",
api_key_env: "MINE_KEY",
base_url: "http://192.168.1.178:8080",
models: [{ name: "qwen-local", context_limit: 131072 }, { name: "no-limit" }, "junk"],
}, null, 2) + "\n");
fs.writeFileSync(path.join(cfgDir, "custom_providers", "halfwritten.json"), '{ "base_url": "https://half.example/v1",');
const dirs = resolveGooseDirs({ GOOSE_PATH_ROOT: root });
const cfg = readGooseConfig(dirs, {});
assert.equal(cfg.activeProvider, "mine", "config.yaml is goose's settings file; an indented key of the same name is not");
assert.deepEqual(cfg.customProviders, { halfwritten: "https://half.example/v1", mine: "http://192.168.1.178:8080" });
assert.deepEqual(cfg.models, [{ id: "qwen-local", contextWindow: 131072 }], "context_limit feeds the launcher's window denominator; a model without one is skipped");
assert.equal(readGooseConfig(dirs, { GOOSE_PROVIDER: "override" }).activeProvider, "override", "GOOSE_PROVIDER env still wins");
const empty = readGooseConfig(resolveGooseDirs({ GOOSE_PATH_ROOT: "/nonexistent-bili-test" }), {});
assert.equal(empty.activeProvider, undefined);
assert.equal(empty.models, undefined, "no declared windows → no models key, so the proxy keeps its own resolution");
} finally {
rmrf(root);
}
});

test("prepareGooseHome/finalizeGooseHome: JSON provider overlay rewrite + config.yaml merge-back (goose's real layout)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-json-home-"));
try {
const cfgDir = path.join(home, ".config", "goose");
fs.mkdirSync(path.join(cfgDir, "custom_providers"), { recursive: true });
const provider = (baseUrl: string): string => JSON.stringify({
name: "x",
engine: "openai",
api_key_env: "X_KEY",
base_url: baseUrl,
models: [{ name: "m", context_limit: 131072 }],
}, null, 2) + "\n";
fs.writeFileSync(path.join(cfgDir, "config.yaml"), "active_provider: mine\n");
fs.writeFileSync(path.join(cfgDir, "custom_providers", "mine.json"), provider("http://192.168.1.178:8080"));
fs.writeFileSync(path.join(cfgDir, "custom_providers", "other.json"), provider("https://keep.example/v1"));
fs.writeFileSync(path.join(cfgDir, "custom_providers", "tight.json"), '{"name":"x","engine":"openai","base_url":"https://tight.example/v1"}');
const origin = "http://127.0.0.1:9999";
const env: NodeJS.ProcessEnv = { XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".local", "share"), XDG_STATE_HOME: path.join(home, ".local", "state") };
const overlay = prepareGooseHome(env, origin, [{ key: "mine", realUpstream: "http://192.168.1.178:8080" }, { key: "tight", realUpstream: "https://tight.example/v1" }]);
assert.ok(overlay, "overlay prepared");
const patchedRaw = fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "mine.json"), "utf8");
const patched = JSON.parse(patchedRaw) as { base_url: string; engine: string; models: unknown[] };
assert.equal(patched.base_url, wrapUpstream(origin, "http://192.168.1.178:8080"), "base_url re-pointed at the proxy, stays valid JSON");
assert.equal(patched.engine, "openai");
assert.deepEqual(patched.models, [{ name: "m", context_limit: 131072 }], "everything else in the provider untouched");
assert.ok(fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "other.json"), "utf8").includes("https://keep.example/v1"), "provider outside the rewrite set untouched");
const tight = JSON.parse(fs.readFileSync(path.join(overlay!.root, "config", "custom_providers", "tight.json"), "utf8")) as { base_url: string };
assert.equal(tight.base_url, wrapUpstream(origin, "https://tight.example/v1"), "compact single-line provider rewritten in place, stays valid JSON");
const realBefore = fs.readFileSync(path.join(cfgDir, "custom_providers", "mine.json"), "utf8");
assert.ok(realBefore.includes("http://192.168.1.178:8080"), "real config untouched by prepare");
fs.writeFileSync(path.join(overlay!.root, "config", "config.yaml"), "active_provider: other\n");
finalizeGooseHome(overlay!);
assert.ok(fs.readFileSync(path.join(cfgDir, "config.yaml"), "utf8").includes("active_provider: other"), "yaml edit merged back");
assert.ok(realBefore === fs.readFileSync(path.join(cfgDir, "custom_providers", "mine.json"), "utf8"), "patched provider file did NOT leak into the real config");
} finally {
rmrf(home);
}
});

test("prepareGooseHome/finalizeGooseHome: overlay layout, patched urls, merge-back round-trip (#1049)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "bili-goose-home-"));
try {
Expand Down
9 changes: 9 additions & 0 deletions tests/model-windows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,12 @@ test("anthropicBetaContextWindow: rejects malformed / non-context tokens", () =>
assert.equal(anthropicBetaContextWindow({ "anthropic-beta": "context-1x-2025-08-07" }), undefined);
assert.equal(anthropicBetaContextWindow({ "anthropic-beta": "my-context-1m-2025-08-07" }), undefined);
});

test("collectModelWindows: goose scope (custom_providers models[].context_limit)", () => {
const cfg: ClientConfig = {
goose: { customProviders: { mine: "http://192.168.1.178:8080" }, models: [{ id: "qwen-local", contextWindow: 131072 }] },
};
assert.deepEqual(collectModelWindows(cfg, "goose"), { "qwen-local": 131072 }, "goose declares its windows like every other scoped client");
assert.deepEqual(collectModelWindows(cfg), { "qwen-local": 131072 });
assert.deepEqual(collectModelWindows(cfg, "codex"), {}, "another client's scope must not pick up goose's windows (#436)");
});
Loading