Skip to content
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ lib/
deferred-provider-models.ts models of providers an extension registers only at session_start, re-added to listings
enabled-models.ts pure minimal-edit engine for the enabledModels pattern list
enabled-models-runtime.ts SDK adapter for enabledModels: pattern resolution, provider kinds, settings IO
model-context.ts client-safe: managed models' contextWindow overrides in the models.json draft, the Save check
subagent-settings.ts read/write ~/.pi/agent/agents/settings.json
file-access.ts allowed file roots for /api/files and worktrees
file-upload-client.ts browser upload to /api/files ?type=upload, shared by the explorer and chat drops
Expand Down Expand Up @@ -192,6 +193,7 @@ components/
MarkdownBody.tsx markdown renderer
ModelsConfig.tsx Settings › Models: models.json editor
EnabledModelsSection.tsx model switches inside ModelsConfig (enabledModels)
ModelContextWindow.tsx a managed model row's context-window field (blank: no override)
OAuthPastePanel.tsx paste box for a sign-in's redirected address or code (Models, MCP)
ProjectTrustDialog.tsx trust confirmation listing the project's MCP servers
AgentsConfig.tsx built-in subagent toggle + agent profile editor
Expand Down Expand Up @@ -238,7 +240,7 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every
- [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-override.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`.
- [mcp-test-sign-in.md](docs/agents/mcp-test-sign-in.md): Settings › MCP Test (route checks, bounded connection, `!command` queue, redaction, status store) and OAuth sign-in / sign-out. Files: `app/api/mcp/test/**`, `app/api/mcp/sign-in/**`, `lib/mcp-test.ts`, `lib/mcp-entry-request.ts`, `lib/mcp-status.ts`, `lib/mcp-sign-in.ts`, `lib/mcp-sign-out.ts`, `components/McpSignIn.tsx`, `components/mcp-sign-in-helpers.ts`, `components/OAuthPastePanel.tsx`.
- [mcp-add.md](docs/agents/mcp-add.md): Settings › MCP add (paste re-parsed on the server, host-variable confirmation, literal secrets kept global, fresh-folder trust, the add pane) and the paste importer's escaping and grammars. Files: `lib/mcp-add.ts`, `lib/mcp-import*.ts`, `lib/shell-words.ts`, fresh-folder trust in `lib/project-trust.ts`, `components/McpAddServer.tsx`, `components/mcp-add-helpers.ts`, the `add` action of `app/api/mcp/route.ts`.
- [models.md](docs/agents/models.md): default model and reasoning level, providers registered at session_start, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-catalog-refresh.ts`, `lib/deferred-provider-models.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`.
- [models.md](docs/agents/models.md): default model and reasoning level, providers registered at session_start, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, managed models' context-window overrides, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-context.ts`, `lib/model-catalog-refresh.ts`, `lib/deferred-provider-models.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelContextWindow.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`.
- [files-and-access.md](docs/agents/files-and-access.md): worktrees and project grouping, the file access allow-list (the `/api/files` security boundary), file tree visibility and its open directories per folder, uploads and chat file drops, the source view's languages by extension, the CSV/TSV table view, web password throttling. Files: `app/api/files/**`, `app/api/cwd/**`, `app/api/worktrees/**`, `app/api/file-index/**`, `app/api/web-auth/**`, `proxy.ts`, `lib/path-security.ts`, `lib/file-access.ts`, `lib/linked-directory.ts`, `lib/session-file-references*.ts`, `lib/file-tree-visibility.ts`, `lib/file-tree-expansion.ts`, `lib/file-upload-client.ts`, `lib/worktree.ts`, `lib/paths.ts`, `lib/auth-throttle.ts`, `components/ProjectWorktreePicker.tsx`, `components/NewSessionContextBar.tsx`, `components/FileExplorer.tsx`, `lib/delimited-table.ts`, `components/DelimitedTable.tsx`, the table branch of `components/FileViewer.tsx`, `hooks/useDragDrop.ts`.
- [settings-ui.md](docs/agents/settings-ui.md): General's sidebar file-browser choice, Plugins and Skills routes, sidebar group switches, the shared `SettingsUi` blocks every settings panel and add pane uses. Files: `app/api/plugins/**`, `app/api/skills/**`, `components/SettingsUi.tsx`, `components/settings-ui-helpers.ts`, `components/SkillsConfig.tsx`, `components/PluginsConfig.tsx`, `hooks/useFilesPlacement.ts`; also before adding a settings section or add pane.
- [subagents.md](docs/agents/subagents.md): the built-in subagent setting, profiles and their files, run status, completion notifications, the subagent viewer tab. Files: `lib/subagent*.ts`, `app/api/subagents/**`, `components/AgentsConfig.tsx`, `components/SubagentViewer.tsx`, `components/subagent-viewer-state.ts`.
Expand Down
49 changes: 49 additions & 0 deletions app/api/models-config/route.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,52 @@ test("a commented models.json loads with its providers", async () => {
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { providers: { acme: { models: [{ id: "a" }] } } });
});

test("invalid context overrides are rejected without changing the saved configuration", async () => {
const config = { providers: { "openai-codex": { modelOverrides: { "gpt-6-astra": { contextWindow: 500000 } } } } };
await writeFile(modelsPath, JSON.stringify(config));
const invalid = structuredClone(config);
invalid.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow = 0;
const response = await PUT(put(invalid));
assert.equal(response.status, 400);
assert.deepEqual(await (await GET()).json(), config);
});

test("saved login-model context overrides reach the SDK; resetting restores the catalog without changing credentials", async () => {
const { ModelRuntime } = await import("@earendil-works/pi-coding-agent");
const authPath = join(testAgentDir, "auth.json");
const credentials = JSON.stringify({ "openai-codex": { type: "oauth", access: "test-only", refresh: "test-only", expires: 9999999999999 } });
await writeFile(authPath, credentials);
const options = { authPath, modelsPath, modelsStorePath: join(testAgentDir, "models-store.json"), refreshOnCreate: false };
const config = { providers: { "openai-codex": { modelOverrides: { "gpt-6-astra": { contextWindow: "500000", maxTokens: 128000 } } } } };
assert.equal((await PUT(put(config))).status, 200);
const saved = await (await GET()).json();
assert.equal(saved.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow, 500000);
const runtime = await ModelRuntime.create(options);
assert.equal(runtime.getModel("openai-codex", "gpt-6-astra").contextWindow, 500000);
delete config.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow;
assert.equal((await PUT(put(config))).status, 200);
const reset = await ModelRuntime.create(options);
const baseline = await ModelRuntime.create({ ...options, modelsPath: null });
assert.equal(reset.getModel("openai-codex", "gpt-6-astra").contextWindow, baseline.getModel("openai-codex", "gpt-6-astra").contextWindow);
assert.equal(reset.getModel("openai-codex", "gpt-6-astra").maxTokens, 128000);
assert.equal(await readFile(authPath, "utf8"), credentials);
});

test("a hand-edited override pi accepts does not block saving an unrelated provider", async () => {
const { ModelRuntime } = await import("@earendil-works/pi-coding-agent");
const config = { providers: {
"openai-codex": { modelOverrides: { "gpt-6-astra": { contextWindow: 400000.5 } } },
relay: { baseUrl: "http://127.0.0.1:1/v1", api: "openai-completions", apiKey: "TEST_ONLY_KEY", models: [{ id: "a" }] },
} };
await writeFile(modelsPath, JSON.stringify(config));
const options = { authPath: join(testAgentDir, "auth.json"), modelsPath, modelsStorePath: join(testAgentDir, "models-store.json"), refreshOnCreate: false };
const runtime = await ModelRuntime.create(options);
assert.equal(runtime.getError(), undefined);
assert.equal(runtime.getModel("openai-codex", "gpt-6-astra").contextWindow, 400000.5);
const edited = structuredClone(config);
edited.providers.relay.models.push({ id: "b" });
const response = await PUT(put(edited));
assert.equal(response.status, 200);
assert.deepEqual(await (await GET()).json(), edited);
});
6 changes: 5 additions & 1 deletion app/api/models-config/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { ModelsConfigReadError, readModelsConfig, writeModelsConfig } from "@/lib/models-config-store";
import { ContextWindowError, normalizeContextWindowOverrides } from "@/lib/model-context";

export const dynamic = "force-dynamic";

Expand All @@ -17,9 +18,12 @@ export async function GET() {
export async function PUT(req: Request) {
try {
const body = await req.json() as Record<string, unknown>;
writeModelsConfig(body);
writeModelsConfig(normalizeContextWindowOverrides(body));
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof ContextWindowError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof ModelsConfigReadError) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
Expand Down
26 changes: 26 additions & 0 deletions app/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,32 @@
border-top: 1px solid var(--border);
}

/* Eight digits wide (ch follows the phones' 16px form text), and no taller
than the name and id beside it, so the row keeps its height. */
.model-context-input {
flex-shrink: 0;
width: calc(8ch + 16px);
height: 24px;
box-sizing: border-box;
padding: 0 7px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
font-size: 11px;
text-align: right;
}

.model-context-input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}

.model-context-input::placeholder { color: var(--text-dim); }
.model-context-input[aria-invalid="true"] { border-color: #ef4444; }
.model-context-input:disabled { opacity: 0.6; }

.enabled-models-row-text {
min-width: 0;
flex: 1;
Expand Down
18 changes: 18 additions & 0 deletions components/EnabledModelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isLastEnabledModel,
} from "./enabled-models-helpers";
import { ConfigButton, ConfigSwitch } from "./SettingsUi";
import { ModelContextWindow, type ModelContextSettings } from "./ModelContextWindow";

/**
* Model switches backed by pi's `enabledModels` setting.
Expand Down Expand Up @@ -361,9 +362,11 @@ function useCatalogRefresh(providerId: string, onChanged: () => void) {
export function EnabledModelsSection({
providerId,
controller,
contextSettings,
}: {
providerId: string;
controller: EnabledModelsController;
contextSettings?: ModelContextSettings;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
Expand Down Expand Up @@ -392,6 +395,7 @@ export function EnabledModelsSection({
const editable = view?.editable === true;
const scopeNoteId = `${noteId}-scope`;
const lastModelNoteId = `${noteId}-last`;
const contextNoteId = `${noteId}-context`;
const disableBlockedByLastModel = editable && !bulk.canDisable && bulk.disableRefs.length > 0;
const showLastModelNote = disableBlockedByLastModel || (editable && shown.some((model) => isLastEnabledModel(view, model)));
// A provider-wide action is resolved server-side so models the browser has
Expand Down Expand Up @@ -453,11 +457,15 @@ export function EnabledModelsSection({
className="enabled-models-filter"
/>
)}
{contextSettings && <div id={contextNoteId} className="enabled-models-note">{t("models.contextOverrideHelp")}</div>}
<div className="enabled-models-list">
{shown.length === 0 ? (
<div className="enabled-models-empty">{t("models.enabledNoMatches")}</div>
) : shown.map((model) => {
const lastOne = isLastEnabledModel(view, model);
const override = contextSettings?.overrides?.[model.id];
const contextValue = override && typeof override === "object" && !Array.isArray(override)
? (override as Record<string, unknown>).contextWindow : undefined;
return (
<div key={model.ref} className="enabled-models-row">
<span className="enabled-models-row-text">
Expand All @@ -469,6 +477,16 @@ export function EnabledModelsSection({
{model.thinkingPin}
</span>
)}
{contextSettings && (
<ModelContextWindow
modelName={model.name}
effective={model.contextWindow}
value={contextValue}
disabled={contextSettings.disabled}
describedBy={contextNoteId}
onChange={(value) => contextSettings.onChange(model.id, value)}
/>
)}
<ConfigSwitch
checked={model.enabled}
loading={pending === model.ref}
Expand Down
32 changes: 32 additions & 0 deletions components/ModelContextWindow.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, { tsconfigPaths: true, jsx: { runtime: "automatic" } });
const { ModelContextWindow } = await jiti.import("./ModelContextWindow.tsx");
const { I18nProvider } = await jiti.import("@/hooks/useI18n");

function render(value) {
return renderToStaticMarkup(createElement(I18nProvider, null,
createElement(ModelContextWindow, { modelName: "GPT Example", effective: 272000, value, onChange() {} })));
}

test("a login model's row gets one named input whose placeholder is the window pi resolves", () => {
const blank = render(undefined);
assert.match(blank, /^<input [^>]*\/>$/, "no label, button or note beside the input");
assert.match(blank, /value=""/);
assert.match(blank, /placeholder="272000"/);
assert.match(blank, /inputmode="numeric"/i);
assert.match(blank, /aria-label="Context window for GPT Example"/);
assert.match(render("500000"), /value="500000"/);
});

test("a typed draft that is not a whole number is marked without being truncated", () => {
const html = render("500k");
assert.match(html, /value="500k"/);
assert.match(html, /aria-invalid="true"/);
// A value pi accepts in models.json is not flagged, though the field would not take it typed.
assert.doesNotMatch(render(400000.5), /aria-invalid/);
});
41 changes: 41 additions & 0 deletions components/ModelContextWindow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import { useI18n } from "@/hooks/useI18n";
import { parseContextWindow } from "@/lib/model-context";

export interface ModelContextSettings {
overrides?: Record<string, unknown>;
disabled: boolean;
onChange: (modelId: string, value: string | undefined) => void;
}

/**
* Like custom-model fields, this edits the Models panel's draft, not the file.
* Blank means no override; the placeholder is the window pi resolves now.
*/
export function ModelContextWindow({ modelName, effective, value, onChange, disabled = false, describedBy }: {
modelName: string;
effective?: number;
value: unknown;
onChange: (value: string | undefined) => void;
disabled?: boolean;
describedBy?: string;
}) {
const { t } = useI18n();
const invalid = value !== undefined && parseContextWindow(value) === undefined;
return (
<input
type="text"
inputMode="numeric"
className="model-context-input"
value={value === undefined ? "" : String(value)}
placeholder={effective === undefined ? undefined : String(effective)}
title={t("models.contextWindow")}
aria-label={t("models.contextForModel", { model: modelName })}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
disabled={disabled}
onChange={(event) => onChange(event.target.value.trim() === "" ? undefined : event.target.value)}
/>
);
}
Loading
Loading