diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index bacca0012..835bb8a26 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -151,6 +151,22 @@ import { import type { ReviewCollectInputV3, ReviewConsentEnvelope, ReviewStatusV3 } from "../lib/review-integration-v2.ts"; import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts"; import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts"; +import { + agentModelProfileConfigPath, modelConfigPath, + normalizeModelConfig, + normalizeModelId, + normalizeRoutingEntry, + readModelConfig, + readModelConfigAsync, + readSavedModelConfig, + readSavedModelConfigAsync, + writeModelConfig, writeModelConfigAsync, atomicSync, atomic, + THINKING_LEVELS, + type AgentModelConfig, + type AgentRoutingEntry, + type AgentSource, + type ThinkingLevel, +} from "../lib/model-routing-authority.ts"; const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations"; import { @@ -927,27 +943,6 @@ const CORE_MODEL_AGENT_NAMES = [ ] as const; const CORE_MODEL_AGENT_NAME_SET = new Set(CORE_MODEL_AGENT_NAMES); -const THINKING_LEVELS = [ - "off", - "minimal", - "low", - "medium", - "high", - "xhigh", - "max", -] as const; -type ThinkingLevel = (typeof THINKING_LEVELS)[number]; -interface AgentRoutingEntry { - model?: string; - thinking?: ThinkingLevel; -} -type AgentModelConfig = Record; -type ModelConfigFileResult = - | { status: "missing" } - | { status: "invalid"; path: string } - | { status: "valid"; config: AgentModelConfig }; -type AgentSource = "project" | "user" | "builtin"; - interface AgentEntry { name: string; source: AgentSource; @@ -1125,10 +1120,6 @@ function gentleAiConfigHome(): string { return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai"); } -function modelConfigPath(_cwd: string): string { - return join(gentleAiConfigHome(), "models.json"); -} - function modelExportPath(_cwd: string): string { return join(gentleAiConfigHome(), "models.export.json"); } @@ -1136,10 +1127,6 @@ function modelExportPath(_cwd: string): string { const MODEL_EXPORT_KIND = "gentle-pi.agent_model_routing"; const MODEL_EXPORT_VERSION = 1; -function legacyProjectModelConfigPath(cwd: string): string { - return join(cwd, ".pi", "gentle-ai", "models.json"); -} - function projectPersonaConfigPath(cwd: string): string { return join(cwd, ".pi", "gentle-ai", "persona.json"); } @@ -1178,128 +1165,6 @@ function writePersonaMode(cwd: string, mode: PersonaMode): string[] { return paths; } -function isThinkingLevel(value: unknown): value is ThinkingLevel { - return ( - typeof value === "string" && - (THINKING_LEVELS as readonly string[]).includes(value) - ); -} - -const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/; - -function normalizeModelId(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const model = value.trim(); - if (model.length === 0) return undefined; - if (!SAFE_MODEL_ID_PATTERN.test(model)) return undefined; - return model; -} - -function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined { - if (typeof value === "string") { - const model = normalizeModelId(value); - return model ? { model } : undefined; - } - if (!isRecord(value)) return undefined; - const model = normalizeModelId(value.model); - const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined; - if (!model && !thinking) { - return Object.keys(value).length === 0 ? {} : undefined; - } - return { model, thinking }; -} - -function readModelConfigFile(path: string): ModelConfigFileResult { - if (!existsSync(path)) return { status: "missing" }; - try { - const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); - if (!isRecord(parsed)) return { status: "invalid", path }; - const config: AgentModelConfig = {}; - for (const [name, value] of Object.entries(parsed)) { - const entry = normalizeRoutingEntry(value); - if (entry) config[name] = entry; - } - return { status: "valid", config }; - } catch { - return { status: "invalid", path }; - } -} - -async function readModelConfigFileAsync( - path: string, -): Promise { - if (!(await pathExists(path))) return { status: "missing" }; - try { - const parsed: unknown = JSON.parse(await readFile(path, "utf8")); - if (!isRecord(parsed)) return { status: "invalid", path }; - const config: AgentModelConfig = {}; - for (const [name, value] of Object.entries(parsed)) { - const entry = normalizeRoutingEntry(value); - if (entry) config[name] = entry; - } - return { status: "valid", config }; - } catch { - return { status: "invalid", path }; - } -} - -function readSavedModelConfig(cwd: string): ModelConfigFileResult { - const globalResult = readModelConfigFile(modelConfigPath(cwd)); - if (globalResult.status !== "missing") return globalResult; - const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd)); - if (legacyResult.status === "invalid") return { status: "valid", config: {} }; - return legacyResult; -} - -async function readSavedModelConfigAsync( - cwd: string, -): Promise { - const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd)); - if (globalResult.status !== "missing") return globalResult; - const legacyResult = await readModelConfigFileAsync( - legacyProjectModelConfigPath(cwd), - ); - if (legacyResult.status === "invalid") return { status: "valid", config: {} }; - return legacyResult; -} - -export function readModelConfig(cwd: string): AgentModelConfig { - const result = readSavedModelConfig(cwd); - return result.status === "valid" ? result.config : {}; -} - -export async function readModelConfigAsync( - cwd: string, -): Promise { - const result = await readSavedModelConfigAsync(cwd); - return result.status === "valid" ? result.config : {}; -} - -function normalizeModelConfig(value: unknown): AgentModelConfig | undefined { - if (!isRecord(value)) return undefined; - const cleaned: AgentModelConfig = {}; - for (const [name, entryValue] of Object.entries(value)) { - if (!/^[A-Za-z0-9._:@/+%-]+$/.test(name)) continue; - const entry = normalizeRoutingEntry(entryValue); - if (entry) cleaned[name] = entry; - } - return cleaned; -} - -function writeModelConfig(cwd: string, config: AgentModelConfig): void { - const path = modelConfigPath(cwd); - mkdirSync(dirname(path), { recursive: true }); - const cleaned = normalizeModelConfig(config) ?? {}; - writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`); -} - -async function writeModelConfigAsync(cwd: string, config: AgentModelConfig): Promise { - const path = modelConfigPath(cwd); - await mkdir(dirname(path), { recursive: true }); - const cleaned = normalizeModelConfig(config) ?? {}; - await writeFile(path, `${JSON.stringify(cleaned, null, 2)}\n`); -} - function parseModelExport(value: unknown): AgentModelConfig | undefined { if (!isRecord(value)) return undefined; if (value.kind !== MODEL_EXPORT_KIND || value.version !== MODEL_EXPORT_VERSION) return undefined; @@ -1541,12 +1406,6 @@ function isClearRoutingEntry(entry: AgentRoutingEntry): boolean { return entry.model === undefined && entry.thinking === undefined; } -function agentModelProfileConfigPath(cwd: string, source: AgentSource): string { - return source === "project" - ? join(cwd, ".pi", "subagents.json") - : join(gentlePiAgentHome(), "subagents.json"); -} - function modelProfileForRoutingEntry( entry: AgentRoutingEntry | undefined, ): Record | undefined { @@ -1567,23 +1426,30 @@ function updateSubagentModelProfileAtPath( if (existsSync(path)) { try { const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); - if (isRecord(parsed)) config = { ...parsed }; - } catch { - config = {}; + if (!isRecord(parsed)) throw new Error("document must be an object"); + config = { ...parsed }; + } catch (error) { + throw new Error(`Invalid subagent config ${path}: ${error instanceof Error ? error.message : String(error)}`); } } - const modelProfiles = isRecord(config.model_profiles) - ? { ...config.model_profiles } - : {}; + const modelProfiles = config.model_profiles === undefined + ? {} + : isRecord(config.model_profiles) + ? { ...config.model_profiles } + : (() => { throw new Error(`Invalid subagent config ${path}: model_profiles must be an object`); })(); const profile = modelProfileForRoutingEntry(entry); if (profile) { if (options.preserveExisting && isRecord(modelProfiles[name])) return false; - modelProfiles[name] = profile; + const next = isRecord(modelProfiles[name]) ? { ...modelProfiles[name] } : {}; + Object.assign(next, profile); + if (!entry?.model) delete next.model; + if (!entry?.thinking) delete next.effort; + modelProfiles[name] = next; } else delete modelProfiles[name]; if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; else delete config.model_profiles; mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); + atomicSync(path, `${JSON.stringify(config, null, 2)}\n`); return true; } @@ -1597,23 +1463,30 @@ async function updateSubagentModelProfileAtPathAsync( if (await pathExists(path)) { try { const parsed: unknown = JSON.parse(await readFile(path, "utf8")); - if (isRecord(parsed)) config = { ...parsed }; - } catch { - config = {}; + if (!isRecord(parsed)) throw new Error("document must be an object"); + config = { ...parsed }; + } catch (error) { + throw new Error(`Invalid subagent config ${path}: ${error instanceof Error ? error.message : String(error)}`); } } - const modelProfiles = isRecord(config.model_profiles) - ? { ...config.model_profiles } - : {}; + const modelProfiles = config.model_profiles === undefined + ? {} + : isRecord(config.model_profiles) + ? { ...config.model_profiles } + : (() => { throw new Error(`Invalid subagent config ${path}: model_profiles must be an object`); })(); const profile = modelProfileForRoutingEntry(entry); if (profile) { if (options.preserveExisting && isRecord(modelProfiles[name])) return false; - modelProfiles[name] = profile; + const next = isRecord(modelProfiles[name]) ? { ...modelProfiles[name] } : {}; + Object.assign(next, profile); + if (!entry?.model) delete next.model; + if (!entry?.thinking) delete next.effort; + modelProfiles[name] = next; } else delete modelProfiles[name]; if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; else delete config.model_profiles; await mkdir(dirname(path), { recursive: true }); - await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); + await atomic(path, `${JSON.stringify(config, null, 2)}\n`); return true; } diff --git a/lib/model-routing-authority.ts b/lib/model-routing-authority.ts new file mode 100644 index 000000000..040c98386 --- /dev/null +++ b/lib/model-routing-authority.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; +export type ModelConfigTarget = "global" | "project"; +export type AgentSource = "project" | "user" | "builtin"; +export interface AgentRoutingEntry { model?: string; thinking?: ThinkingLevel } +export type AgentModelConfig = Record; +export type ModelConfigFileResult = { status: "missing" } | { status: "invalid"; path: string } | { status: "valid"; config: AgentModelConfig }; +export interface ModelRoutingRoots { configHome?: string; agentHome?: string } +export class ModelRoutingPersistenceError extends Error { + readonly path: string; + constructor(message: string, path: string) { super(message); this.name = "ModelRoutingPersistenceError"; this.path = path; } +} +const AGENT_NAME = /^[A-Za-z0-9._:@/+%-]+$/; +const MODEL_ID = /^[A-Za-z0-9._~:@/+%-]+$/; +const object = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : undefined; +const invalid = (path: string) => new ModelRoutingPersistenceError(`model routing document is invalid: ${path}`, path); + +export function normalizeModelId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const model = value.trim(); + return model && MODEL_ID.test(model) ? model : undefined; +} +export function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined { + if (value === null || value === "inherit") return {}; + const input = typeof value === "string" ? { model: value } : object(value); + if (!input) return undefined; + const rawModel = input.model; + const rawThinking = input.thinking ?? input.effort; + const model = rawModel === undefined || rawModel === null || rawModel === "inherit" ? undefined : normalizeModelId(rawModel); + const thinking = rawThinking === undefined || rawThinking === null || rawThinking === "inherit" ? undefined : THINKING_LEVELS.includes(rawThinking as ThinkingLevel) ? rawThinking as ThinkingLevel : undefined; + if (rawModel !== undefined && rawModel !== null && rawModel !== "inherit" && !model) return undefined; + if (rawThinking !== undefined && rawThinking !== null && rawThinking !== "inherit" && !thinking) return undefined; + return { ...(model ? { model } : {}), ...(thinking ? { thinking } : {}) }; +} +export function normalizeModelConfig(value: unknown): AgentModelConfig | undefined { + const input = object(value); + if (!input) return undefined; + const config: AgentModelConfig = {}; + for (const [name, raw] of Object.entries(input)) if (AGENT_NAME.test(name)) { + const entry = normalizeRoutingEntry(raw); + if (!entry) return undefined; + config[name] = entry; + } + return config; +} +function parse(path: string, content: string): ModelConfigFileResult { + try { + const config = normalizeModelConfig(JSON.parse(content)); + return config ? { status: "valid", config } : { status: "invalid", path }; + } catch { return { status: "invalid", path }; } +} +export function readModelConfigFile(path: string): ModelConfigFileResult { + if (!existsSync(path)) return { status: "missing" }; + try { return parse(path, readFileSync(path, "utf8")); } catch { return { status: "invalid", path }; } +} +export async function readModelConfigFileAsync(path: string): Promise { + try { return parse(path, await readFile(path, "utf8")); } + catch { return existsSync(path) ? { status: "invalid", path } : { status: "missing" }; } +} +function configHome(roots?: ModelRoutingRoots): string { return roots?.configHome ?? process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai"); } +function agentHome(roots?: ModelRoutingRoots): string { return roots?.agentHome ?? process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent"); } +export function resolveModelRoutingTarget(cwd: string, target: ModelConfigTarget, roots?: ModelRoutingRoots) { + return target === "project" + ? { target, configPath: join(cwd, ".pi", "gentle-ai", "models.json"), profilePath: join(cwd, ".pi", "subagents.json") } + : { target, configPath: join(configHome(roots), "models.json"), profilePath: join(agentHome(roots), "subagents.json") }; +} +export function modelConfigPath(cwd: string, target: ModelConfigTarget = "global"): string { return resolveModelRoutingTarget(cwd, target).configPath; } +export function agentModelProfileConfigPath(cwd: string, source: AgentSource, roots?: ModelRoutingRoots): string { + return resolveModelRoutingTarget(cwd, source === "project" ? "project" : "global", roots).profilePath; +} +export function readSavedModelConfig(cwd: string): ModelConfigFileResult { + const global = readModelConfigFile(modelConfigPath(cwd)); + return global.status === "missing" ? readModelConfigFile(modelConfigPath(cwd, "project")) : global; +} +export async function readSavedModelConfigAsync(cwd: string): Promise { + const global = await readModelConfigFileAsync(modelConfigPath(cwd)); + return global.status === "missing" ? readModelConfigFileAsync(modelConfigPath(cwd, "project")) : global; +} +export function readModelConfig(cwd: string): AgentModelConfig { const result = readSavedModelConfig(cwd); return result.status === "valid" ? result.config : {}; } +export async function readModelConfigAsync(cwd: string): Promise { const result = await readSavedModelConfigAsync(cwd); return result.status === "valid" ? result.config : {}; } +export function readModelConfigForTarget(cwd: string, target: ModelConfigTarget): ModelConfigFileResult { return readModelConfigFile(modelConfigPath(cwd, target)); } +export function readModelConfigForTargetAsync(cwd: string, target: ModelConfigTarget): Promise { return readModelConfigFileAsync(modelConfigPath(cwd, target)); } + +function atomicSync(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { writeFileSync(temporary, content, "utf8"); renameSync(temporary, path); } + catch (error) { + try { rmSync(temporary, { force: true }); } catch { /* preserve replacement failure */ } + throw new ModelRoutingPersistenceError(`failed to replace model routing document ${path}: ${error instanceof Error ? error.message : String(error)}`, path); + } +} +async function atomic(path: string, content: string): Promise { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + try { await writeFile(temporary, content, "utf8"); await rename(temporary, path); } + catch (error) { + try { await rm(temporary, { force: true }); } catch { /* preserve replacement failure */ } + throw new ModelRoutingPersistenceError(`failed to replace model routing document ${path}: ${error instanceof Error ? error.message : String(error)}`, path); + } +} +function assertExisting(path: string): void { if (readModelConfigFile(path).status === "invalid") throw invalid(path); } +export function writeModelConfigFile(path: string, value: unknown): void { + const config = normalizeModelConfig(value); + if (!config) throw invalid(path); + assertExisting(path); + atomicSync(path, `${JSON.stringify(config, null, 2)}\n`); +} +export async function writeModelConfigFileAsync(path: string, value: unknown): Promise { + const config = normalizeModelConfig(value); + if (!config) throw invalid(path); + if ((await readModelConfigFileAsync(path)).status === "invalid") throw invalid(path); + await atomic(path, `${JSON.stringify(config, null, 2)}\n`); +} +export function writeModelConfig(cwd: string, value: unknown, target: ModelConfigTarget = "global"): void { writeModelConfigFile(modelConfigPath(cwd, target), value); } +export function writeModelConfigAsync(cwd: string, value: unknown, target: ModelConfigTarget = "global"): Promise { return writeModelConfigFileAsync(modelConfigPath(cwd, target), value); } +export { atomicSync, atomic }; diff --git a/tests/model-routing-authority.test.ts b/tests/model-routing-authority.test.ts new file mode 100644 index 000000000..da327fef6 --- /dev/null +++ b/tests/model-routing-authority.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { atomicSync, modelConfigPath, normalizeModelConfig, normalizeRoutingEntry, readModelConfigFile, resolveModelRoutingTarget, writeModelConfigFile } from "../lib/model-routing-authority.ts"; +function withScratch(run: (cwd: string) => void): void { + const cwd = mkdtempSync(join(tmpdir(), "gentle-pi-routing-authority-")); + try { run(cwd); } finally { rmSync(cwd, { recursive: true, force: true }); } +} +test("normalizes legacy values and inherit omission without dropping unknown agents", () => { + assert.deepEqual(normalizeRoutingEntry("openai/gpt-5"), { model: "openai/gpt-5" }); + assert.deepEqual(normalizeRoutingEntry({ model: "inherit", effort: "high" }), { thinking: "high" }); + assert.deepEqual(normalizeModelConfig({ unknown: { model: "other/provider" }, known: { thinking: "low" } }), { unknown: { model: "other/provider" }, known: { thinking: "low" } }); + assert.equal(normalizeModelConfig({ known: { thinking: "invalid" } }), undefined); +}); +test("resolves isolated targets and updates only the explicit document", () => withScratch((cwd) => { + const roots = { configHome: join(cwd, "home", "gentle-ai"), agentHome: join(cwd, "home", "pi") }; + const global = resolveModelRoutingTarget(cwd, "global", roots), project = resolveModelRoutingTarget(cwd, "project", roots); + assert.equal(global.configPath, join(roots.configHome, "models.json")); + assert.equal(project.configPath, join(cwd, ".pi", "gentle-ai", "models.json")); + assert.equal(global.profilePath, join(roots.agentHome, "subagents.json")); + assert.equal(project.profilePath, join(cwd, ".pi", "subagents.json")); + writeModelConfigFile(global.configPath, { global: { model: "global/provider" } }); + writeModelConfigFile(project.configPath, { project: { model: "project/provider" } }); + const globalBytes = readFileSync(global.configPath, "utf8"), projectBytes = readFileSync(project.configPath, "utf8"); + writeModelConfigFile(project.configPath, { project: { model: "updated/provider" } }); + assert.equal(readFileSync(global.configPath, "utf8"), globalBytes); + assert.notEqual(readFileSync(project.configPath, "utf8"), projectBytes); +})); +test("fails closed on malformed existing config and cleans failed replacements", () => withScratch((cwd) => { + const path = modelConfigPath(cwd, "project"); + mkdirSync(join(cwd, ".pi", "gentle-ai"), { recursive: true }); + writeFileSync(path, "{ malformed", "utf8"); + assert.deepEqual(readModelConfigFile(path), { status: "invalid", path }); + assert.throws(() => writeModelConfigFile(path, { known: { model: "safe/provider" } }), /invalid/i); + assert.equal(readFileSync(path, "utf8"), "{ malformed"); + const blockedPath = join(cwd, "blocked-models.json"); + mkdirSync(blockedPath); + assert.throws(() => atomicSync(blockedPath, "{}\n"), /failed to replace/i); + assert.deepEqual(readdirSync(cwd).filter((entry) => entry.startsWith("blocked-models.json.") && entry.endsWith(".tmp")), []); +})); +test("atomically round-trips unrelated assignment keys", () => withScratch((cwd) => { + const path = join(cwd, "models.json"); + writeFileSync(path, JSON.stringify({ known: { model: "old/provider" }, unknown: { model: "keep/provider" } })); + const saved = readModelConfigFile(path); + assert.equal(saved.status, "valid"); + if (saved.status !== "valid") return; + saved.config.known = { model: "new/provider" }; + writeModelConfigFile(path, saved.config); + assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { known: { model: "new/provider" }, unknown: { model: "keep/provider" } }); +}));