Skip to content
Open
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
132 changes: 132 additions & 0 deletions app/api/models-visibility/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { stat } from "fs/promises";
import { resolve } from "path";
import { createAgentSessionServices, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { invalidateModelsCache } from "@/lib/models-cache";
import { resolveVisibleModels } from "@/lib/model-scope";
import { sanitizeEnabledPatterns } from "@/lib/model-visibility";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { projectTrustReloadOptions } from "@/lib/project-trust";

export const dynamic = "force-dynamic";

const modelNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });

interface VisibilityModelEntry {
id: string;
name: string;
provider: string;
}

/**
* `enabledModels` is global, but enumerating models loads project extensions,
* so a cwd selects which project's extension-registered providers are listed.
* Without one, the agent dir stands in as cwd: global extensions only, no
* allow-list check needed for our own directory (same as /api/auth/*).
*/
async function resolveCwd(requested: string | undefined): Promise<string | Response> {
const trimmed = requested?.trim();
if (!trimmed) return getAgentDir();
const cwd = resolve(trimmed);
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 });
}
return cwd;
}

export async function GET(req: Request) {
const requestedCwd = new URL(req.url).searchParams.get("cwd") ?? undefined;
const cwd = await resolveCwd(requestedCwd);
if (cwd instanceof Response) return cwd;

try {
const agentDir = getAgentDir();
// Enumerating models runs a repository's .pi/extensions factories, so honor
// project trust like /api/models does (see lib/project-trust.ts, #236).
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
const services = await createAgentSessionServices({
cwd,
agentDir,
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
});
const available = await services.modelRuntime.getAvailable();
const models: VisibilityModelEntry[] = available
.map((model) => ({ id: model.id, name: model.name, provider: model.provider }))
.sort((a, b) => modelNameCollator.compare(a.name || a.id, b.name || b.id)
|| modelNameCollator.compare(a.provider, b.provider)
|| modelNameCollator.compare(a.id, b.id));
const patterns = services.settingsManager.getEnabledModels() ?? null;
// Resolve the scope server-side with pi's own matcher so the dialog never
// reimplements glob / fuzzy / thinking-pin semantics.
const scope = await resolveVisibleModels(services.modelRuntime, patterns ?? undefined);
const visible = scope.visible.map((model) => ({ provider: model.provider, id: model.id }));
return Response.json({
patterns,
models,
visible,
...(services.modelRuntime.getError() ? { modelError: services.modelRuntime.getError() } : {}),
});
} catch (error) {
return Response.json({ error: String(error) }, { status: 500 });
}
}

export async function PUT(req: Request) {
let body: { cwd?: unknown; patterns?: unknown };
try {
body = await req.json() as { cwd?: unknown; patterns?: unknown };
} catch {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}

const sanitized = sanitizeEnabledPatterns(body.patterns);
if ("error" in sanitized) {
return Response.json({ error: sanitized.error }, { status: 400 });
}

const requestedCwd = typeof body.cwd === "string" ? body.cwd : undefined;
const cwd = await resolveCwd(requestedCwd);
if (cwd instanceof Response) return cwd;

try {
// A scope that resolves to zero visible models makes pi fall back to
// showing everything, silently undoing the edit — reject it instead
// (same safety net as the scoped-models UI).
if (sanitized.patterns) {
const agentDir = getAgentDir();
const trustReloadOptions = projectTrustReloadOptions(cwd, agentDir);
const services = await createAgentSessionServices({
cwd,
agentDir,
...(trustReloadOptions ? { resourceLoaderReloadOptions: trustReloadOptions } : {}),
});
const scope = await resolveVisibleModels(services.modelRuntime, sanitized.patterns);
// resolveVisibleModels falls back to every available model when the
// scope matches nothing, so the guard is the resolved scope itself.
if (scope.scopedModels.length === 0) {
return Response.json(
{ error: "The selection matches no available models; refusing to save an empty scope." },
{ status: 400 },
);
}
}
// enabledModels is a global setting; the cwd only selects which project
// settings file the manager loads alongside it.
const settings = SettingsManager.create(cwd, getAgentDir());
settings.setEnabledModels(sanitized.patterns);
await settings.flush();
invalidateModelsCache();
return Response.json({ success: true, patterns: sanitized.patterns ?? null });
} catch (error) {
return Response.json({ error: String(error) }, { status: 500 });
}
}
57 changes: 57 additions & 0 deletions components/ModelVisibilityDialog.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, {
jsx: { runtime: "automatic" },
tsconfigPaths: true,
});
const React = await jiti.import("react");
const { renderToStaticMarkup } = await jiti.import("react-dom/server");
const { ModelVisibilityDialog } = await jiti.import("./ModelVisibilityDialog.tsx");
const { I18nProvider } = await jiti.import("@/hooks/useI18n");

test("visibility dialog renders its shell with a disabled save while models load", () => {
const html = renderToStaticMarkup(
React.createElement(
I18nProvider,
null,
React.createElement(ModelVisibilityDialog, {
cwd: "/tmp/project",
onClose() {},
}),
),
);

assert.match(html, /Model visibility/);
assert.match(html, /enabledModels/);
assert.match(html, /Cancel/);
assert.match(html, /Save/);
assert.match(html, /disabled=""/);
});

test("the dialog seeds its selection from the server-resolved scope, not the chat selector", () => {
const source = readFileSync(new URL("./ModelVisibilityDialog.tsx", import.meta.url), "utf8");
assert.match(source, /const visible = data\.visible \?\? \[\];/);
assert.match(source, /setCheckedKeys\(new Set\(visible\.map\(modelRefKey\)\)\)/);
assert.doesNotMatch(source, /visibleModels:\s*readonly VisibleModelRef\[\];/);
});

test("Settings → Models hosts the visibility dialog; the chat selector does not", () => {
const modelsConfig = readFileSync(new URL("./ModelsConfig.tsx", import.meta.url), "utf8");
assert.match(modelsConfig, /setVisibilityOpen\(true\)/);
assert.match(modelsConfig, /models\.visibilityManage/);
assert.match(modelsConfig, /<ModelVisibilityDialog/);

const selector = readFileSync(new URL("./ModelSelector.tsx", import.meta.url), "utf8");
assert.doesNotMatch(selector, /visibilityManage|onManage/);
const chatWindow = readFileSync(new URL("./ChatWindow.tsx", import.meta.url), "utf8");
assert.doesNotMatch(chatWindow, /ModelVisibilityDialog/);
});

test("visibility dialog saves exact provider refs and clears on show-all", () => {
const source = readFileSync(new URL("./ModelVisibilityDialog.tsx", import.meta.url), "utf8");
assert.match(source, /computeVisibilitySave\(/);
assert.match(source, /patterns: save\.type === "clear" \? null : save\.patterns/);
});
Loading
Loading