Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 22 additions & 107 deletions extensions/gentle-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -889,25 +901,6 @@ const CORE_MODEL_AGENT_NAMES = [
] as const;
const CORE_MODEL_AGENT_NAME_SET = new Set<string>(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<string, AgentRoutingEntry>;
type ModelConfigFileResult =
| { status: "missing" }
| { status: "invalid"; path: string }
| { status: "valid"; config: AgentModelConfig };
type AgentSource = "project" | "user" | "builtin";

interface AgentEntry {
Expand Down Expand Up @@ -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<ModelConfigFileResult> {
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<ModelConfigFileResult> {
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 {
Expand All @@ -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 });
Expand Down
133 changes: 133 additions & 0 deletions lib/model-routing-authority.ts
Original file line number Diff line number Diff line change
@@ -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<string, AgentRoutingEntry>;

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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

async function pathExists(path: string): Promise<boolean> {
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<string, unknown>): 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<ModelConfigFileResult> {
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<ModelConfigFileResult> {
const globalResult = await readModelConfigFileAsync(globalPath);
if (globalResult.status !== "missing") return globalResult;
return readModelConfigFileAsync(projectPath);
}
Loading
Loading