feat(backend): read Pi enabled model scope
This commit is contained in:
@@ -0,0 +1,78 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export interface PiEnabledModelsResult {
|
||||||
|
ids: string[];
|
||||||
|
warnings: string[];
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadOptions {
|
||||||
|
harnessDir: string;
|
||||||
|
agentDir?: string;
|
||||||
|
read?: (path: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Settings = Record<string, unknown>;
|
||||||
|
const INVALID = Symbol("invalid-settings");
|
||||||
|
|
||||||
|
function readSettings(
|
||||||
|
path: string,
|
||||||
|
optional: boolean,
|
||||||
|
read: (path: string) => string,
|
||||||
|
warnings: string[],
|
||||||
|
): Settings | undefined | typeof INVALID {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(read(path));
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
||||||
|
return value as Settings;
|
||||||
|
} catch (error) {
|
||||||
|
if (optional && (error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
|
||||||
|
warnings.push(`Pi settings are unreadable or invalid: ${path}`);
|
||||||
|
return INVALID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExactCompositeId(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string" || /[\s*?:]/.test(value)) return false;
|
||||||
|
const parts = value.split("/");
|
||||||
|
return parts.length === 2 && parts.every((part) => part.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPiEnabledModels(opts: LoadOptions): PiEnabledModelsResult {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const read = opts.read ?? ((path: string) => readFileSync(path, "utf8"));
|
||||||
|
const agentDir = opts.agentDir ?? join(homedir(), ".pi", "agent");
|
||||||
|
const globalPath = join(agentDir, "settings.json");
|
||||||
|
const projectPath = join(opts.harnessDir, ".pi", "settings.json");
|
||||||
|
const globalSettings = readSettings(globalPath, false, read, warnings);
|
||||||
|
const projectSettings = readSettings(projectPath, true, read, warnings);
|
||||||
|
if (globalSettings === INVALID || projectSettings === INVALID) return { ids: [], warnings };
|
||||||
|
|
||||||
|
const projectDefinesScope = projectSettings
|
||||||
|
? Object.prototype.hasOwnProperty.call(projectSettings, "enabledModels")
|
||||||
|
: false;
|
||||||
|
const settings = projectDefinesScope ? projectSettings : globalSettings;
|
||||||
|
const source = projectDefinesScope ? projectPath : globalPath;
|
||||||
|
const raw = settings?.enabledModels;
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
warnings.push(`Pi enabledModels is missing or empty: ${source}`);
|
||||||
|
return { ids: [], warnings, source };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
raw.forEach((value, index) => {
|
||||||
|
if (!isExactCompositeId(value)) {
|
||||||
|
warnings.push(`Ignoring non-exact enabledModels entry at index ${index}: ${source}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!seen.has(value)) {
|
||||||
|
seen.add(value);
|
||||||
|
ids.push(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (ids.length === 0) warnings.push(`Pi enabledModels contains no exact model IDs: ${source}`);
|
||||||
|
return { ids, warnings, source };
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { loadPiEnabledModels } from "../src/pi/enabled-models.js";
|
||||||
|
|
||||||
|
function fixture(globalValue: unknown, projectValue?: unknown) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "tht-enabled-models-"));
|
||||||
|
const agentDir = join(root, "agent");
|
||||||
|
const harnessDir = join(root, "harness");
|
||||||
|
mkdirSync(agentDir, { recursive: true });
|
||||||
|
mkdirSync(join(harnessDir, ".pi"), { recursive: true });
|
||||||
|
writeFileSync(join(agentDir, "settings.json"), JSON.stringify(globalValue));
|
||||||
|
if (projectValue !== undefined) {
|
||||||
|
writeFileSync(join(harnessDir, ".pi", "settings.json"), JSON.stringify(projectValue));
|
||||||
|
}
|
||||||
|
return { root, agentDir, harnessDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("loads exact global enabledModels in configured order", () => {
|
||||||
|
const f = fixture({ enabledModels: [
|
||||||
|
"zai/glm-5.2",
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
"local-qwen/qwen3.6-35b-a3b",
|
||||||
|
] });
|
||||||
|
try {
|
||||||
|
expect(loadPiEnabledModels(f)).toMatchObject({
|
||||||
|
ids: [
|
||||||
|
"zai/glm-5.2",
|
||||||
|
"deepseek/deepseek-v4-flash",
|
||||||
|
"local-qwen/qwen3.6-35b-a3b",
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project enabledModels overrides global enabledModels", () => {
|
||||||
|
const f = fixture(
|
||||||
|
{ enabledModels: ["zai/glm-5.2", "zai/glm-5v-turbo"] },
|
||||||
|
{ enabledModels: ["local-qwen/qwen3.6-35b-a3b"] },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
expect(loadPiEnabledModels(f).ids).toEqual(["local-qwen/qwen3.6-35b-a3b"]);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("project settings without enabledModels fall back to global settings", () => {
|
||||||
|
const f = fixture({ enabledModels: ["zai/glm-5.2"] }, { theme: "thothii-mono" });
|
||||||
|
try {
|
||||||
|
expect(loadPiEnabledModels(f).ids).toEqual(["zai/glm-5.2"]);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid entries are ignored, deduplicated, and reported without their content", () => {
|
||||||
|
const f = fixture({ enabledModels: [
|
||||||
|
"zai/glm-5.2", "zai/glm-5.2", "glm-5.2", "zai/*", "zai/glm-5.2:high", 7,
|
||||||
|
] });
|
||||||
|
try {
|
||||||
|
const result = loadPiEnabledModels(f);
|
||||||
|
expect(result.ids).toEqual(["zai/glm-5.2"]);
|
||||||
|
expect(result.warnings).toHaveLength(4);
|
||||||
|
expect(result.warnings.join(" ")).not.toContain("glm-5.2:high");
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["missing field", {}],
|
||||||
|
["empty field", { enabledModels: [] }],
|
||||||
|
["wrong type", { enabledModels: "zai/glm-5.2" }],
|
||||||
|
])("%s fails closed", (_name, settings) => {
|
||||||
|
const f = fixture(settings);
|
||||||
|
try {
|
||||||
|
const result = loadPiEnabledModels(f);
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
expect(result.warnings.length).toBeGreaterThan(0);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed project settings fail closed instead of exposing global models", () => {
|
||||||
|
const f = fixture({ enabledModels: ["zai/glm-5.2"] });
|
||||||
|
writeFileSync(join(f.harnessDir, ".pi", "settings.json"), "{");
|
||||||
|
try {
|
||||||
|
const result = loadPiEnabledModels(f);
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
expect(result.warnings).toHaveLength(1);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("missing global settings fail closed", () => {
|
||||||
|
const f = fixture({ enabledModels: ["zai/glm-5.2"] });
|
||||||
|
rmSync(join(f.agentDir, "settings.json"));
|
||||||
|
try {
|
||||||
|
const result = loadPiEnabledModels(f);
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
expect(result.warnings).toHaveLength(1);
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user