Skip to content
Closed
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
36 changes: 34 additions & 2 deletions app/api/sessions/[id]/auto-name/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { generateSessionTitle } from "@/lib/session-title";
import type { Api, Model } from "@earendil-works/pi-ai";
import { generateSessionTitle, type TitleModelOverride } from "@/lib/session-title";
import { readTitleModelPreference } from "@/lib/title-model-preference";
import { getRpcSession, startRpcSession } from "@/lib/rpc-manager";
import { invalidateSessionListCache, resolveSessionPath } from "@/lib/session-reader";

Expand All @@ -24,7 +26,37 @@ export async function POST(
// globalThis keeps wrappers alive across dev hot reloads; older instances
// may predate waitUntilReady(), but those have already completed startup.
await session.waitUntilReady?.();
const result = await generateSessionTitle(session.inner as unknown as AgentSession);

// The instance's own choice, made from the Generate title button's menu (see
// lib/title-model-preference.ts); the session's own model and its cheapest
// thinking level when there is none.
const preference = readTitleModelPreference();
let override: TitleModelOverride | undefined;
if (preference?.provider && preference.modelId) {
const runtime = session.inner.modelRuntime;
let model = runtime.getModel(preference.provider, preference.modelId);
if (!model) {
// A provider an extension registers at session_start, or a models.json
// edited since the choice was made: reload once before giving up on it.
await runtime.refresh({ allowNetwork: false });
model = runtime.getModel(preference.provider, preference.modelId);
}
if (model) {
override = {
model: model as unknown as Model<Api>,
...(preference.thinkingLevel ? { thinkingLevel: preference.thinkingLevel } : {}),
};
} else {
// A stale choice, not a broken title: name the session anyway, keeping a
// level the user asked for.
console.warn(`[pi-web] the title model ${preference.provider}/${preference.modelId} is not available; naming with the session's own model`);
if (preference.thinkingLevel) override = { thinkingLevel: preference.thinkingLevel };
}
} else if (preference?.thinkingLevel) {
override = { thinkingLevel: preference.thinkingLevel };
}

const result = await generateSessionTitle(session.inner as unknown as AgentSession, override);

if (!session.isAlive()) {
return NextResponse.json(
Expand Down
80 changes: 80 additions & 0 deletions app/api/title-model/route.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createJiti } from "jiti";

const agentDir = await mkdtemp(path.join(os.tmpdir(), "pi-web-title-model-route-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
test.after(async () => {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
await rm(agentDir, { recursive: true, force: true });
});

const jiti = createJiti(import.meta.url, {
alias: { "@": process.cwd() },
interopDefault: true,
moduleCache: false,
});
const { GET, PUT } = await jiti.import("./route.ts");

const preferencePath = path.join(agentDir, "pi-web-title-model.json");

function put(body, headers = { host: "localhost", "Content-Type": "application/json" }) {
return PUT(new Request("http://localhost/api/title-model", {
method: "PUT",
headers,
body: typeof body === "string" ? body : JSON.stringify(body),
}));
}

test("keeps the instance's title model until a choice replaces it whole", async () => {
assert.deepEqual(await (await GET()).json(), { titleModel: null });

const model = await put({ titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" } });
assert.equal(model.status, 200);
assert.deepEqual(await model.json(), { titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" } });
assert.deepEqual(JSON.parse(await readFile(preferencePath, "utf8")), {
provider: "cheap",
modelId: "small",
thinkingLevel: "high",
});
assert.deepEqual(await (await GET()).json(), {
titleModel: { provider: "cheap", modelId: "small", thinkingLevel: "high" },
});

// A level without a model names the session's own: the old model choice goes.
const level = await put({ titleModel: { thinkingLevel: "medium" } });
assert.deepEqual(await level.json(), { titleModel: { thinkingLevel: "medium" } });

// Nothing to prefer is no file at all: naming follows the session's own model.
const cleared = await put({ titleModel: null });
assert.deepEqual(await cleared.json(), { titleModel: null });
assert.equal(existsSync(preferencePath), false);
assert.deepEqual(await (await GET()).json(), { titleModel: null });
});

test("drops a field that is not one, and refuses a body that is no choice at all", async () => {
await writeFile(preferencePath, JSON.stringify({ provider: "cheap", modelId: "small" }));

// A field that is not a usable string is dropped; the rest of the choice stands.
assert.deepEqual(await (await put({ titleModel: { provider: 5, thinkingLevel: "max" } })).json(), {
titleModel: { thinkingLevel: "max" },
});

assert.equal((await put({ titleModel: "cheap/small" })).status, 400);
assert.equal((await put({ titleModel: ["cheap"] })).status, 400);
// No `titleModel` key at all must not clear a stored choice.
assert.equal((await put({})).status, 400);
assert.equal((await put("{")).status, 400);
assert.equal((await put({ titleModel: { provider: "cheap" } }, { host: "localhost", "Content-Type": "text/plain" })).status, 415);
assert.equal((await put(
{ titleModel: { provider: "cheap" } },
{ host: "localhost", origin: "https://evil.example", "Content-Type": "application/json" },
)).status, 403);
assert.deepEqual(await (await GET()).json(), { titleModel: { thinkingLevel: "max" } });
});
54 changes: 54 additions & 0 deletions app/api/title-model/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { hasJsonContentType, isApiRequestAllowed } from "@/lib/request-security";
import {
parseTitleModelPreference,
readTitleModelPreference,
writeTitleModelPreference,
} from "@/lib/title-model-preference";

export const dynamic = "force-dynamic";

// Which model the Generate title button names a session with: one choice for
// this pi-web instance, so every browser and device names sessions alike, and
// empty until the button's menu sets one. The rules of that value, its file and
// what an empty one means live in lib/title-model-preference.ts.

function errorResponse(error: unknown) {
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}

export async function GET() {
try {
return NextResponse.json({ titleModel: readTitleModelPreference() });
} catch (error) {
return errorResponse(error);
}
}

export async function PUT(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}
if (!hasJsonContentType(req)) {
return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
// null clears the choice; a body that is neither null nor an object is a
// client mistake, and clearing the choice over it would lose it silently.
const requested = (body as { titleModel?: unknown } | null)?.titleModel;
if (requested !== null && (typeof requested !== "object" || Array.isArray(requested))) {
return NextResponse.json({ error: "titleModel must be an object or null" }, { status: 400 });
}
try {
const titleModel = parseTitleModelPreference(requested);
writeTitleModelPreference(titleModel);
return NextResponse.json({ titleModel });
} catch (error) {
return errorResponse(error);
}
}
Loading