From 1b78f72b646153a4cd9a86ddb2d581fee5a55e2a Mon Sep 17 00:00:00 2001 From: User Date: Tue, 14 Jul 2026 18:27:55 +0200 Subject: [PATCH] feat(backend): read Pi enabled model scope --- backend/src/pi/enabled-models.ts | 78 +++++++++++++++++++++++ backend/test/enabled-models.test.ts | 98 +++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 backend/src/pi/enabled-models.ts create mode 100644 backend/test/enabled-models.test.ts diff --git a/backend/src/pi/enabled-models.ts b/backend/src/pi/enabled-models.ts new file mode 100644 index 00000000..4f544451 --- /dev/null +++ b/backend/src/pi/enabled-models.ts @@ -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; +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(); + 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 }; +} diff --git a/backend/test/enabled-models.test.ts b/backend/test/enabled-models.test.ts new file mode 100644 index 00000000..df8842e7 --- /dev/null +++ b/backend/test/enabled-models.test.ts @@ -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 }); } +});