feat(coding-agent): support path-scoped model config

Fixes #947
This commit is contained in:
can1357
2026-05-06 17:12:22 +02:00
parent 4e3ff73d89
commit d438c4e8a0
3 changed files with 117 additions and 1 deletions
+17
View File
@@ -377,6 +377,23 @@ For `enabledModels` and CLI `--models`:
- explicit `provider/modelId` entries stay exact
- globs and fuzzy matches still operate on concrete models
Global `enabledModels` and `disabledProviders` entries may also be scoped to a path prefix:
```yaml
enabledModels:
- claude-sonnet-4-5
- path: ~/work
models:
- anthropic/claude-opus-4-5
disabledProviders:
- ollama
- path: ~/private
providers:
- anthropic
```
String entries apply everywhere. Scoped entries apply when the current working directory is the configured path or one of its subdirectories. Use `path`, `paths`, `pathPrefix`, or `pathPrefixes`; use `models` for `enabledModels`, `providers` for `disabledProviders`, or `values` for either.
## `/model` and `--list-models`
Both surfaces keep provider-prefixed models visible and selectable.
+71 -1
View File
@@ -12,6 +12,7 @@
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
getAgentDbPath,
@@ -98,6 +99,74 @@ function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
current[segments[segments.length - 1]] = value;
}
const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
type PathScopedStringArrayEntry = {
path?: unknown;
paths?: unknown;
pathPrefix?: unknown;
pathPrefixes?: unknown;
values?: unknown;
items?: unknown;
models?: unknown;
providers?: unknown;
};
function normalizePathPrefix(prefix: string): string {
const expanded =
prefix === "~" ? os.homedir() : prefix.startsWith("~/") ? path.join(os.homedir(), prefix.slice(2)) : prefix;
return path.resolve(expanded);
}
function pathMatchesPrefix(cwd: string, prefix: string): boolean {
const relative = path.relative(normalizePathPrefix(prefix), path.resolve(cwd));
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
}
function stringArrayFromUnknown(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
return [];
}
function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
const resolved: string[] = [];
for (const entry of value) {
if (typeof entry === "string") {
resolved.push(entry);
continue;
}
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const scoped = entry as PathScopedStringArrayEntry;
const prefixes = [
...stringArrayFromUnknown(scoped.path),
...stringArrayFromUnknown(scoped.paths),
...stringArrayFromUnknown(scoped.pathPrefix),
...stringArrayFromUnknown(scoped.pathPrefixes),
];
if (prefixes.length === 0 || !prefixes.some(prefix => pathMatchesPrefix(cwd, prefix))) continue;
const values =
settingPath === "enabledModels"
? [
...stringArrayFromUnknown(scoped.values),
...stringArrayFromUnknown(scoped.items),
...stringArrayFromUnknown(scoped.models),
]
: [
...stringArrayFromUnknown(scoped.values),
...stringArrayFromUnknown(scoped.items),
...stringArrayFromUnknown(scoped.providers),
];
resolved.push(...values);
}
return resolved;
}
// ═══════════════════════════════════════════════════════════════════════════
// Settings Class
// ═══════════════════════════════════════════════════════════════════════════
@@ -201,7 +270,8 @@ export class Settings {
const segments = path.split(".");
const value = getByPath(this.#merged, segments);
if (value !== undefined) {
return value as SettingValue<P>;
const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd);
return (pathScopedValue ?? value) as SettingValue<P>;
}
return getDefault(path);
}
@@ -80,6 +80,35 @@ describe("Settings", () => {
expect((savedSettings.modelRoles as { default?: string } | undefined)?.default).toBe("claude-sonnet");
});
it("filters model allow-list and disabled providers by current path prefix", async () => {
const workDir = path.join(projectDir, "work", "service");
const privateDir = path.join(projectDir, "private", "app");
fs.mkdirSync(workDir, { recursive: true });
fs.mkdirSync(privateDir, { recursive: true });
await writeSettings({
enabledModels: [
"claude-sonnet-4-5",
{ path: path.join(projectDir, "work"), values: ["anthropic/claude-opus-4-5"] },
{ path: path.join(projectDir, "private"), values: ["openai/gpt-5.2-codex"] },
],
disabledProviders: [
"ollama",
{ path: path.join(projectDir, "work"), values: ["openai"] },
{ path: path.join(projectDir, "private"), values: ["anthropic"] },
],
});
const workSettings = await Settings.init({ cwd: workDir, agentDir });
expect(workSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-5"]);
expect(workSettings.get("disabledProviders")).toEqual(["ollama", "openai"]);
_resetSettingsForTest();
const privateSettings = await Settings.init({ cwd: privateDir, agentDir });
expect(privateSettings.get("enabledModels")).toEqual(["claude-sonnet-4-5", "openai/gpt-5.2-codex"]);
expect(privateSettings.get("disabledProviders")).toEqual(["ollama", "anthropic"]);
});
it("should preserve custom settings when changing theme", async () => {
await writeSettings({
modelRoles: { default: "claude-sonnet" },