Files
pi-agent-integrated/pi-web/app/api/models/route.ts
T

124 lines
4.8 KiB
TypeScript

import { stat } from "fs/promises";
import { resolve } from "path";
import { createAgentSessionServices, getAgentDir, type SettingsManager } from "@earendil-works/pi-coding-agent";
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { loadModelsWithCache, withModelRuntimeError, type ModelsData } from "@/lib/models-cache";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { projectTrustReloadOptions } from "@/lib/project-trust";
import { createAppSettingsManager, getAppResourceLoaderOptions } from "@/lib/app-runtime";
export const dynamic = "force-dynamic";
const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function compareModelEntries(
a: { id: string; name: string; provider: string },
b: { id: string; name: string; provider: string }
): number {
return modelNameCollator.compare(a.name || a.id, b.name || b.id)
|| modelNameCollator.compare(a.provider, b.provider)
|| modelNameCollator.compare(a.id, b.id);
}
const THINKING_SUFFIXES = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
function stripThinkingSuffix(modelRef: string): string {
const trimmed = modelRef.trim();
const colonIndex = trimmed.lastIndexOf(":");
if (colonIndex === -1) return trimmed;
const suffix = trimmed.substring(colonIndex + 1);
return THINKING_SUFFIXES.has(suffix) ? trimmed.substring(0, colonIndex) : trimmed;
}
function filterByExactEnabledModels<T extends { id: string; provider: string }>(
available: readonly T[],
enabledModels: string[] | undefined,
): readonly T[] {
if (!enabledModels || enabledModels.length === 0) return available;
const refs = new Set(enabledModels.map(stripThinkingSuffix).filter(Boolean));
const visible = available.filter((m) => refs.has(`${m.provider}/${m.id}`) || refs.has(m.id));
return visible.length > 0 ? visible : available;
}
async function loadModels(cwd: string): Promise<ModelsData> {
const nameMap = new Map<string, string>();
let modelList: { id: string; name: string; provider: string }[] = [];
let defaultModel: { provider: string; modelId: string } | null = null;
const thinkingLevels: Record<string, string[]> = {};
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
const agentDir = getAgentDir();
// Gate untrusted project extensions: enumerating models still imports and
// runs a repository's .pi/extensions factories, so honor project trust here
// too (see lib/project-trust.ts, #236).
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
const services = await createAgentSessionServices({
cwd,
agentDir,
settingsManager: createAppSettingsManager(cwd, agentDir),
resourceLoaderOptions: getAppResourceLoaderOptions(),
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
});
const available = await services.modelRuntime.getAvailable();
const modelError = services.modelRuntime.getError();
const settings: SettingsManager = services.settingsManager;
const enabledModels = settings.getEnabledModels();
const visible = filterByExactEnabledModels(available, enabledModels);
modelList = visible.map((m: { id: string; name: string; provider: string }) => ({
id: m.id,
name: m.name,
provider: m.provider,
})).sort(compareModelEntries);
for (const m of visible) {
const key = `${m.provider}:${m.id}`;
nameMap.set(key, m.name);
thinkingLevels[key] = getSupportedThinkingLevels(m);
if (m.thinkingLevelMap) thinkingLevelMaps[key] = m.thinkingLevelMap;
}
const provider = settings.getDefaultProvider();
const modelId = settings.getDefaultModel();
if (provider && modelId && visible.some((m) => m.provider === provider && m.id === modelId)) {
defaultModel = { provider, modelId };
}
return withModelRuntimeError(
{ models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, thinkingLevelMaps },
modelError,
);
}
const EMPTY_MODELS: ModelsData = {
models: {},
modelList: [],
defaultModel: null,
thinkingLevels: {},
thinkingLevelMaps: {},
};
export async function GET(req: Request) {
const requestedCwd = new URL(req.url).searchParams.get("cwd") || process.cwd();
const cwd = resolve(requestedCwd);
let cwdStat;
try {
cwdStat = await stat(cwd);
} catch {
return Response.json({ error: `Directory does not exist: ${cwd}` }, { status: 400 });
}
if (!cwdStat.isDirectory()) {
return Response.json({ error: `Not a directory: ${cwd}` }, { status: 400 });
}
const allowedRoots = await getAllowedFileRoots();
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
return Response.json({ error: "Access denied" }, { status: 403 });
}
try {
return Response.json(await loadModelsWithCache(cwd, () => loadModels(cwd)));
} catch {
return Response.json(EMPTY_MODELS);
}
}