diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index dfc5a2954..965045b34 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -39,6 +39,18 @@ import { type SddPreflightPreferences, updatePackageManagedSddAgentOwnership, } from "../lib/sdd-preflight.ts"; +import { + THINKING_LEVELS, + normalizeModelConfig, + normalizeModelId, + normalizeRoutingEntry, + readSavedModelConfig as readModelRoutingAuthority, + readSavedModelConfigAsync as readModelRoutingAuthorityAsync, + type AgentModelConfig, + type AgentRoutingEntry, + type ModelConfigFileResult, + type ThinkingLevel, +} from "../lib/model-routing-authority.ts"; import { parseSddStatusCommandArgs, renderNativeSddPhasePrompt, @@ -889,25 +901,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 { @@ -1140,89 +1133,22 @@ 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; + const projectPath = legacyProjectModelConfigPath(cwd); + const result = readModelRoutingAuthority(modelConfigPath(cwd), projectPath); + return result.status === "invalid" && result.path === projectPath + ? { status: "valid", config: {} } + : result; } 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; + const projectPath = legacyProjectModelConfigPath(cwd); + const result = await readModelRoutingAuthorityAsync(modelConfigPath(cwd), projectPath); + return result.status === "invalid" && result.path === projectPath + ? { status: "valid", config: {} } + : result; } export function readModelConfig(cwd: string): AgentModelConfig { @@ -1237,17 +1163,6 @@ export async function readModelConfigAsync( 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 }); diff --git a/lib/model-routing-authority.ts b/lib/model-routing-authority.ts new file mode 100644 index 000000000..7fbf934a5 --- /dev/null +++ b/lib/model-routing-authority.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync } from "node:fs"; +import { access, readFile } from "node:fs/promises"; + +export const THINKING_LEVELS = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] as const; + +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +export interface AgentRoutingEntry { + model?: string; + thinking?: ThinkingLevel; +} + +export type AgentModelConfig = Record; + +export type ModelConfigFileResult = + | { status: "missing" } + | { status: "invalid"; path: string } + | { status: "valid"; config: AgentModelConfig }; + +const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/; +const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function pathExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export function isThinkingLevel(value: unknown): value is ThinkingLevel { + return ( + typeof value === "string" && + (THINKING_LEVELS as readonly string[]).includes(value) + ); +} + +export 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; +} + +export 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 }; +} + +export function normalizeModelConfig(value: unknown): AgentModelConfig | undefined { + if (!isRecord(value)) return undefined; + const cleaned: AgentModelConfig = {}; + for (const [name, entryValue] of Object.entries(value)) { + if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue; + const entry = normalizeRoutingEntry(entryValue); + if (entry) cleaned[name] = entry; + } + return cleaned; +} + +function parseModelConfigFileValue(value: Record): AgentModelConfig { + const config: AgentModelConfig = {}; + for (const [name, entryValue] of Object.entries(value)) { + const entry = normalizeRoutingEntry(entryValue); + if (entry) config[name] = entry; + } + return config; +} + +export 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 }; + return { status: "valid", config: parseModelConfigFileValue(parsed) }; + } catch { + return { status: "invalid", path }; + } +} + +export 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 }; + return { status: "valid", config: parseModelConfigFileValue(parsed) }; + } catch { + return { status: "invalid", path }; + } +} + +export function readSavedModelConfig( + globalPath: string, + projectPath: string, +): ModelConfigFileResult { + const globalResult = readModelConfigFile(globalPath); + if (globalResult.status !== "missing") return globalResult; + return readModelConfigFile(projectPath); +} + +export async function readSavedModelConfigAsync( + globalPath: string, + projectPath: string, +): Promise { + const globalResult = await readModelConfigFileAsync(globalPath); + if (globalResult.status !== "missing") return globalResult; + return readModelConfigFileAsync(projectPath); +} diff --git a/tests/model-routing-authority.test.ts b/tests/model-routing-authority.test.ts new file mode 100644 index 000000000..81ef079df --- /dev/null +++ b/tests/model-routing-authority.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { readModelConfig, readModelConfigAsync } from "../extensions/gentle-ai.ts"; + +test("model routing authority normalizes and preserves sync/async source status", async (t) => { + const loaded = await import("../lib/model-routing-authority.ts").then( + (module) => ({ module, error: undefined }), + (error) => ({ module: undefined, error }), + ); + assert.ok( + loaded.module, + `shared model routing authority must load: ${String(loaded.error)}`, + ); + const authority = loaded.module; + const root = mkdtempSync(join(tmpdir(), "gentle-pi-model-routing-authority-")); + const globalDir = join(root, "global"); + const projectDir = join(root, "project"); + const projectConfigDir = join(projectDir, ".pi", "gentle-ai"); + const agentsDir = join(root, "agents"); + mkdirSync(globalDir, { recursive: true }); + mkdirSync(projectConfigDir, { recursive: true }); + mkdirSync(agentsDir, { recursive: true }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + assert.equal(authority.normalizeModelId(" openai/gpt-5 "), "openai/gpt-5"); + assert.equal(authority.normalizeModelId("bad model"), undefined); + assert.deepEqual(authority.normalizeRoutingEntry(" inherit "), { model: "inherit" }); + assert.deepEqual( + authority.normalizeRoutingEntry({ model: " anthropic/opus ", thinking: "high" }), + { model: "anthropic/opus", thinking: "high" }, + ); + assert.deepEqual(authority.normalizeRoutingEntry(null), undefined); + assert.deepEqual( + authority.normalizeModelConfig({ + worker: " openai/gpt-5 ", + clear: {}, + "not valid": "ignored", + nullValue: null, + }), + { worker: { model: "openai/gpt-5" }, clear: {} }, + ); + + const missingPath = join(globalDir, "missing.json"); + assert.deepEqual(authority.readModelConfigFile(missingPath), { status: "missing" }); + assert.deepEqual(await authority.readModelConfigFileAsync(missingPath), { status: "missing" }); + + const validGlobalPath = join(globalDir, "valid.json"); + writeFileSync( + validGlobalPath, + JSON.stringify({ worker: "openai/gpt-5", reviewer: { thinking: "medium" }, "not valid": "openai/gpt-4" }), + ); + const validSync = authority.readModelConfigFile(validGlobalPath); + const validAsync = await authority.readModelConfigFileAsync(validGlobalPath); + assert.deepEqual(validSync, { + status: "valid", + config: { + worker: { model: "openai/gpt-5" }, + reviewer: { model: undefined, thinking: "medium" }, + "not valid": { model: "openai/gpt-4" }, + }, + }); + assert.deepEqual(validAsync, validSync); + + const invalidGlobalPath = join(globalDir, "invalid.json"); + writeFileSync(invalidGlobalPath, "[]"); + assert.deepEqual(authority.readModelConfigFile(invalidGlobalPath), { + status: "invalid", + path: invalidGlobalPath, + }); + assert.deepEqual(await authority.readModelConfigFileAsync(invalidGlobalPath), { + status: "invalid", + path: invalidGlobalPath, + }); + + const projectPath = join(projectConfigDir, "models.json"); + writeFileSync(projectPath, JSON.stringify({ project: "google/gemini" })); + assert.deepEqual( + authority.readSavedModelConfig(missingPath, projectPath), + { status: "valid", config: { project: { model: "google/gemini" } } }, + ); + assert.deepEqual( + await authority.readSavedModelConfigAsync(missingPath, projectPath), + await authority.readSavedModelConfig(missingPath, projectPath), + ); + assert.deepEqual(authority.readSavedModelConfig(invalidGlobalPath, projectPath), { + status: "invalid", + path: invalidGlobalPath, + }); + assert.deepEqual(await authority.readSavedModelConfigAsync(invalidGlobalPath, projectPath), { + status: "invalid", + path: invalidGlobalPath, + }); + + const previousConfigHome = process.env.GENTLE_PI_CONFIG_HOME; + process.env.GENTLE_PI_CONFIG_HOME = globalDir; + t.after(() => { + if (previousConfigHome === undefined) delete process.env.GENTLE_PI_CONFIG_HOME; + else process.env.GENTLE_PI_CONFIG_HOME = previousConfigHome; + }); + writeFileSync(projectPath, JSON.stringify({ project: "google/gemini" })); + assert.deepEqual(readModelConfig(projectDir), { project: { model: "google/gemini" } }); + assert.deepEqual(await readModelConfigAsync(projectDir), readModelConfig(projectDir)); + + writeFileSync(projectPath, "[]"); + assert.deepEqual(authority.readModelConfigFile(projectPath), { status: "invalid", path: projectPath }); + assert.deepEqual(await authority.readModelConfigFileAsync(projectPath), { status: "invalid", path: projectPath }); + assert.deepEqual(readModelConfig(projectDir), {}); + assert.deepEqual(await readModelConfigAsync(projectDir), {}); + + writeFileSync(join(globalDir, "models.json"), JSON.stringify({ global: "openai/gpt-5" })); + assert.deepEqual(readModelConfig(projectDir), { global: { model: "openai/gpt-5" } }); + assert.deepEqual(await readModelConfigAsync(projectDir), readModelConfig(projectDir)); + + writeFileSync(join(globalDir, "models.json"), "[]"); + assert.deepEqual(readModelConfig(projectDir), {}); + assert.deepEqual(await readModelConfigAsync(projectDir), {}); +});