diff --git a/AGENTS.md b/AGENTS.md index 807b28a7ab..0bdeaaebee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 @@ -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`. diff --git a/app/api/models-config/route.test.mjs b/app/api/models-config/route.test.mjs index 3168ed3ad7..0af3edafd7 100644 --- a/app/api/models-config/route.test.mjs +++ b/app/api/models-config/route.test.mjs @@ -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); +}); diff --git a/app/api/models-config/route.ts b/app/api/models-config/route.ts index b5dcee23d6..7cca3c3cad 100644 --- a/app/api/models-config/route.ts +++ b/app/api/models-config/route.ts @@ -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"; @@ -17,9 +18,12 @@ export async function GET() { export async function PUT(req: Request) { try { const body = await req.json() as Record; - 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 }); } diff --git a/app/settings.css b/app/settings.css index 0bf21ebf30..6b75b67efc 100644 --- a/app/settings.css +++ b/app/settings.css @@ -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; diff --git a/components/EnabledModelsSection.tsx b/components/EnabledModelsSection.tsx index 6da2880f42..93d9350177 100644 --- a/components/EnabledModelsSection.tsx +++ b/components/EnabledModelsSection.tsx @@ -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. @@ -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(""); @@ -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 @@ -453,11 +457,15 @@ export function EnabledModelsSection({ className="enabled-models-filter" /> )} + {contextSettings &&
{t("models.contextOverrideHelp")}
}
{shown.length === 0 ? (
{t("models.enabledNoMatches")}
) : 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).contextWindow : undefined; return (
@@ -469,6 +477,16 @@ export function EnabledModelsSection({ {model.thinkingPin} )} + {contextSettings && ( + contextSettings.onChange(model.id, value)} + /> + )} { + const blank = render(undefined); + assert.match(blank, /^]*\/>$/, "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/); +}); diff --git a/components/ModelContextWindow.tsx b/components/ModelContextWindow.tsx new file mode 100644 index 0000000000..090781af11 --- /dev/null +++ b/components/ModelContextWindow.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useI18n } from "@/hooks/useI18n"; +import { parseContextWindow } from "@/lib/model-context"; + +export interface ModelContextSettings { + overrides?: Record; + 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 ( + onChange(event.target.value.trim() === "" ? undefined : event.target.value)} + /> + ); +} diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 9f6b556c15..3cfaabbe79 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -4,6 +4,8 @@ import { useState, useEffect, useCallback, useId, useRef } from "react"; import { useI18n } from "@/hooks/useI18n"; import type { ModelCatalogPreset, ModelCatalogRecommendation } from "@/lib/model-catalog"; import type { DiscoveredModel } from "@/lib/model-discovery"; +import { ContextWindowError, normalizeContextWindowOverrides, withContextWindowOverride } from "@/lib/model-context"; +import type { ModelContextSettings } from "./ModelContextWindow"; import { getLastSettingsSelection, setLastSettingsSelection, @@ -1301,8 +1303,9 @@ function ModelDetail({ // ── OAuth detail ────────────────────────────────────────────────────────────── -function OAuthDetail({ provider, onRefresh, enabledModels }: { +function OAuthDetail({ provider, onRefresh, enabledModels, contextSettings }: { provider: OAuthProvider; onRefresh: () => void; enabledModels: EnabledModelsController; + contextSettings: ModelContextSettings; }) { const [loginState, setLoginState] = useState({ phase: "idle" }); const { t } = useI18n(); @@ -1551,15 +1554,16 @@ function OAuthDetail({ provider, onRefresh, enabledModels }: { - {provider.loggedIn && } + {provider.loggedIn && }
); } // ── API Key detail ──────────────────────────────────────────────────────────── -function ApiKeyDetail({ provider, onRefresh, enabledModels }: { +function ApiKeyDetail({ provider, onRefresh, enabledModels, contextSettings }: { provider: ApiKeyProvider; onRefresh: () => void; enabledModels: EnabledModelsController; + contextSettings: ModelContextSettings; }) { const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); @@ -1686,7 +1690,7 @@ function ApiKeyDetail({ provider, onRefresh, enabledModels }: { - {provider.configured && } + {provider.configured && }
); } @@ -1852,6 +1856,8 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { const [selection, setSelection] = useState(readRememberedSelection); const [oauthProviders, setOauthProviders] = useState([]); const [apiKeyProviders, setApiKeyProviders] = useState([]); + /** Set once the provider lists have answered; until then an overrides-only entry may be a managed provider's. */ + const [authListed, setAuthListed] = useState(false); const [pickerOpen, setPickerOpen] = useState(false); /** Provider ids as models.json has them on disk, and where renames moved them. */ const savedProvidersRef = useRef>(new Set()); @@ -1875,7 +1881,8 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { if (Array.isArray(d.oauthProviders)) setOauthProviders(d.oauthProviders); if (Array.isArray(d.apiKeyProviders)) setApiKeyProviders(d.apiKeyProviders); }) - .catch(() => {}); + .catch(() => {}) + .finally(() => setAuthListed(true)); }, []); useEffect(() => { @@ -1906,6 +1913,32 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { if (selection) setLastSettingsSelection("models", JSON.stringify(selection)); }, [selection]); + // An overrides-only entry belongs to its managed provider, not a duplicate custom provider. + useEffect(() => { + if (selection?.type !== "provider") return; + const entry = config.providers?.[selection.name]; + if (!entry || Object.keys(entry).length !== 1 || !entry.modelOverrides) return; + if (oauthProviders.some((provider) => provider.id === selection.name)) { + setSelection({ type: "oauth", providerId: selection.name }); + } else if (apiKeyProviders.some((provider) => provider.id === selection.name)) { + setSelection({ type: "apikey", providerId: selection.name }); + } + }, [apiKeyProviders, config.providers, oauthProviders, selection]); + + const contextSettings = (providerId: string): ModelContextSettings => ({ + overrides: config.providers?.[providerId]?.modelOverrides, + disabled: loading || saving || loadError !== null, + onChange: (modelId, value) => { + try { + setConfig(withContextWindowOverride(config, providerId, modelId, value)); + setSavedOk(false); + setSaveError(null); + } catch (error) { + setSaveError(String(error)); + } + }, + }); + const addCustomProvider = useCallback(() => { let finalName = "new-provider"; let n = 1; @@ -2033,6 +2066,7 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { draft = renamed; } try { + draft = normalizeContextWindowOverrides(draft); const res = await fetch("/api/models-config", { method: "PUT", headers: { "Content-Type": "application/json" }, @@ -2054,13 +2088,17 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { enabledModels.resync(renames, modelRenames); } } catch (e) { - setSaveError(String(e)); + setSaveError(e instanceof ContextWindowError ? t("models.contextInvalid", { model: e.model }) : String(e)); } finally { setSaving(false); } }, [applyProviderRename, config, enabledModels, loadError, providerNameDraft, t]); - const providers = Object.entries(config.providers ?? {}); + const providers = Object.entries(config.providers ?? {}).filter(([id, entry]) => { + if (Object.keys(entry).length !== 1 || !entry.modelOverrides) return true; + const managed = oauthProviders.some((provider) => provider.id === id) || apiKeyProviders.some((provider) => provider.id === id); + return authListed && !managed; + }); // `12/40` next to a provider makes a narrowed selector visible at a glance. const scopeBadge = (providerId: string) => { const label = providerBadgeLabel(enabledModels.view, providerId); @@ -2075,16 +2113,17 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { if (selection.type === "oauth") { const p = oauthProviders.find((p) => p.id === selection.providerId); if (!p) return null; - return ; + return ; } if (selection.type === "apikey") { const p = apiKeyProviders.find((p) => p.id === selection.providerId); if (!p) return null; - return ; + return ; } if (selection.type === "provider") { const provider = config.providers?.[selection.name]; - if (!provider) return null; + // Only an entry the list shows as custom: an overrides-only one waits for the provider lists. + if (!provider || !providers.some(([id]) => id === selection.name)) return null; return ( { + if (route.request().method() === "PUT") { + writes++; + if (rejectSave) return route.fulfill({ json: { error: "Fixture save unavailable" } }); + saved = route.request().postDataJSON(); + return route.fulfill({ json: { success: true } }); + } + return route.fulfill({ json: saved }); + }); + await page.route(routes[1], (route) => route.fulfill({ json: { + oauthProviders: [{ id: "openai-codex", name: "OpenAI Codex", loggedIn: true }], apiKeyProviders: [], + } })); + await page.route(routes[2], (route) => route.fulfill({ json: { + allEnabled: true, patterns: null, stalePatterns: [], enabledTotal: 1, availableTotal: 1, + scope: "global", settingsPath: "models-fixture/settings.json", editable: true, + providers: [{ id: "openai-codex", name: "OpenAI Codex", kind: "builtin", enabledCount: 1, models: [{ + id: "gpt-6-astra", name: "GPT-6 Astra", ref: "openai-codex/gpt-6-astra", enabled: true, + contextWindow: saved.providers["openai-codex"]?.modelOverrides?.["gpt-6-astra"]?.contextWindow ?? 272000, + }] }], + } })); + await page.route(routes[3], (route) => route.fulfill({ json: { providers: [] } })); + const settings = page.getByRole("dialog", { name: "Settings", exact: true }); + const open = async () => { + const sidebar = page.getByRole("button", { name: "Show sidebar", exact: true }); + if (await sidebar.isVisible()) await sidebar.click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + const section = settings.getByRole("combobox", { name: "Settings", exact: true }); + if (await section.isVisible()) await section.selectOption("models"); + else await settings.getByRole("button", { name: "Models", exact: true }).click(); + await settings.locator(".config-sidebar-item").filter({ hasText: "OpenAI Codex" }).click(); + }; + const input = settings.getByRole("textbox", { name: "Context window for GPT-6 Astra", exact: true }); + const save = async () => { + const response = page.waitForResponse((reply) => reply.url().endsWith("/api/models-config") && reply.request().method() === "PUT"); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await response; + await input.waitFor({ state: "visible" }); + }; + try { + await open(); + assert.equal(await input.getAttribute("placeholder"), "272000", "A blank field shows the window pi resolves"); + const name = input.locator("xpath=..").locator(".enabled-models-row-text"); + const [field, text] = await Promise.all([input.boundingBox(), name.boundingBox()]); + assert.ok(Math.abs(field.y + field.height / 2 - (text.y + text.height / 2)) < 4 && field.height <= text.height, + "The input sits on the model's line without making the row taller"); + await input.fill("500000"); + assert.equal(writes, 0, "Editing must wait for the shared Save button"); + await save(); + assert.equal(saved.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow, 500000); + assert.deepEqual(saved.providers.relay, { models: [{ id: "keep-me" }] }); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + await open(); + assert.equal(await input.inputValue(), "500000"); + assert.equal(await settings.locator(".config-sidebar-item").filter({ hasText: /^openai-codex$/ }).count(), 0, + "An override-only entry must not create a duplicate custom provider"); + + await input.fill("500k"); + assert.equal(await input.getAttribute("aria-invalid"), "true"); + await settings.getByRole("button", { name: "Save", exact: true }).click(); + await settings.getByText("Context window of openai-codex/gpt-6-astra: enter a positive whole number of tokens.", { exact: true }).waitFor(); + assert.equal(writes, 1, "Invalid input must not reach the writer"); + assert.equal(await input.inputValue(), "500k", "Invalid drafts must not be truncated to 500"); + + await input.fill("600000"); + rejectSave = true; + await save(); + await settings.getByText("Fixture save unavailable", { exact: true }).waitFor(); + assert.equal(await input.inputValue(), "600000", "A failed save preserves the editable draft"); + assert.equal(saved.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow, 500000); + rejectSave = false; + await save(); + assert.equal(saved.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow, 600000); + + const bounds = await input.boundingBox(); + assert.ok(bounds && bounds.x >= 0 && bounds.x + bounds.width <= page.viewportSize().width, + "The input remains within the viewport at desktop and phone widths"); + await input.fill(""); + await save(); + assert.deepEqual(saved, { providers: { relay: { models: [{ id: "keep-me" }] } } }); + // The placeholder is what pi resolves, so it shows the default again once the cleared field is saved. + await settings.locator('input[aria-label="Context window for GPT-6 Astra"][placeholder="272000"]').waitFor(); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + console.log("PASS: account context edit/save/reopen/validation/retry/clear preserve other model settings"); + } finally { + for (const route of routes) await page.unroute(route); + } +} diff --git a/e2e/run.mjs b/e2e/run.mjs index cb78e353e9..10a469c261 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -14,6 +14,7 @@ import { checkExtensionDialogSizing, checkExtensionDialogs, extensionSource } fr import { checkChatAppearance } from "./chat-appearance.mjs"; import { checkCodeBackground } from "./code-background.mjs"; import { checkModelDiscovery } from "./model-discovery.mjs"; +import { checkModelContext } from "./model-context.mjs"; import { checkFilesPlacement, showSidebarSessions } from "./sidebar.mjs"; import { checkSessionNavigation } from "./session-navigation.mjs"; import { checkProjectNavigation, createProjectNavigationFixtures } from "./project-navigation.mjs"; @@ -500,6 +501,7 @@ try { console.log("PASS: files below the sessions by default, tabs from Settings and back"); await checkModelDiscovery(page); } + await checkModelContext(page); await page.goto(`${base}/?session=${CODE_BACKGROUND}`, { waitUntil: "domcontentloaded" }); await page.locator(".markdown-code-block pre").waitFor(); await checkCodeBackground(page); diff --git a/lib/enabled-models-runtime.test.mjs b/lib/enabled-models-runtime.test.mjs index 3fe064399c..2e2e609298 100644 --- a/lib/enabled-models-runtime.test.mjs +++ b/lib/enabled-models-runtime.test.mjs @@ -99,6 +99,7 @@ test("the view groups models by provider and marks custom ones", async () => { [["claude-opus-4-1", true], ["claude-sonnet-4-6", false]], ); assert.equal(view.editable, true); + assert.equal(view.providers[0].models[0].contextWindow, 1000); }); test("a stale-only scope reads as everything enabled", async () => { diff --git a/lib/enabled-models-runtime.ts b/lib/enabled-models-runtime.ts index 323cf0ea51..40f500e086 100644 --- a/lib/enabled-models-runtime.ts +++ b/lib/enabled-models-runtime.ts @@ -159,6 +159,7 @@ export function buildEnabledModelsView({ name: model.name || model.id, ref, enabled: enabled.has(ref), + ...(model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow }), ...(pin ? { thinkingPin: pin } : {}), }; const list = byProvider.get(model.provider); diff --git a/lib/enabled-models.ts b/lib/enabled-models.ts index dfbe5d9278..a88ad57da5 100644 --- a/lib/enabled-models.ts +++ b/lib/enabled-models.ts @@ -77,6 +77,8 @@ export interface EnabledModelsModelView { /** `provider/modelId`, the reference used by every edit. */ ref: string; enabled: boolean; + /** The context budget currently resolved by pi, including models.json overrides. */ + contextWindow?: number; /** Thinking level pinned by a `:level` pattern; read-only in pi-web. */ thinkingPin?: string; } diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 22d6a74b69..6580597c65 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -1292,6 +1292,9 @@ export const enLocale: LocalePlugin = { "models.imageInput": "Image input", "models.modelSpecs": "Model specifications", "models.contextWindow": "Context window (tokens)", + "models.contextForModel": "Context window for {model}", + "models.contextInvalid": "Context window of {model}: enter a positive whole number of tokens.", + "models.contextOverrideHelp": "Context window in tokens; blank uses the default. Restart Pi Web to update loaded sessions.", "models.maxOutputTokens": "Max output tokens", "models.costPerMillion": "Cost per million tokens", "models.costInput": "Input", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 23317097e3..494c9ede5b 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -1292,6 +1292,9 @@ export const zhCNLocale: LocalePlugin = { "models.imageInput": "图片输入", "models.modelSpecs": "模型规格", "models.contextWindow": "上下文窗口(tokens)", + "models.contextForModel": "{model} 的上下文窗口", + "models.contextInvalid": "{model} 的上下文窗口:请输入正整数 tokens 数量。", + "models.contextOverrideHelp": "上下文窗口(tokens),留空使用默认值。已加载的会话需重启 Pi Web 才会更新。", "models.maxOutputTokens": "最大输出 tokens", "models.costPerMillion": "每百万 tokens 价格", "models.costInput": "输入", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index cee8ae5615..88af98e860 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -1292,6 +1292,9 @@ export const zhTWLocale: LocalePlugin = { "models.imageInput": "圖片輸入", "models.modelSpecs": "模型規格", "models.contextWindow": "上下文視窗(Token)", + "models.contextForModel": "{model} 的上下文視窗", + "models.contextInvalid": "{model} 的上下文視窗:請輸入正整數 Token 數量。", + "models.contextOverrideHelp": "上下文視窗(Token),留空使用預設值。已載入的工作階段需重新啟動 Pi Web 才會更新。", "models.maxOutputTokens": "最大輸出 Token 數", "models.costPerMillion": "每百萬 Token 費用", "models.costInput": "輸入", diff --git a/lib/model-context.test.mjs b/lib/model-context.test.mjs new file mode 100644 index 0000000000..831526881a --- /dev/null +++ b/lib/model-context.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { ContextWindowError, withContextWindowOverride, normalizeContextWindowOverrides } = await jiti.import("./model-context.ts"); + +test("editing a login model's context preserves provider credentials, other overrides and custom models", () => { + const original = { + providers: { + "openai-codex": { + headers: { "x-example": "keep" }, + modelOverrides: { "gpt-6-astra": { maxTokens: 128000 }, other: { contextWindow: 200000 } }, + }, + custom: { apiKey: "$EXAMPLE_KEY", models: [{ id: "custom-model", contextWindow: 1000000 }] }, + }, + }; + const draft = withContextWindowOverride(original, "openai-codex", "gpt-6-astra", "500000"); + const saved = normalizeContextWindowOverrides(draft); + assert.deepEqual(saved.providers["openai-codex"], { + headers: { "x-example": "keep" }, + modelOverrides: { "gpt-6-astra": { maxTokens: 128000, contextWindow: 500000 }, other: { contextWindow: 200000 } }, + }); + assert.deepEqual(saved.providers.custom, original.providers.custom); + assert.equal(original.providers["openai-codex"].modelOverrides["gpt-6-astra"].contextWindow, undefined); +}); + +test("reset removes only the context override and cleans up containers it leaves empty", () => { + const config = { providers: { login: { modelOverrides: { + a: { contextWindow: 500000, maxTokens: 128000 }, b: { contextWindow: 500000 }, + } } } }; + const reset = withContextWindowOverride(config, "login", "a", undefined); + assert.deepEqual(reset.providers.login.modelOverrides.a, { maxTokens: 128000 }); + assert.deepEqual(withContextWindowOverride({ providers: { login: { modelOverrides: { b: { contextWindow: 500000 } } } } }, "login", "b", undefined), { providers: {} }); + assert.deepEqual(withContextWindowOverride({ providers: {} }, "login", "missing", undefined), { providers: {} }); +}); + +test("a typed context budget must be a whole number of tokens; a refusal names the model", () => { + const typed = (value) => ({ providers: { login: { modelOverrides: { model: { contextWindow: value } } } } }); + for (const value of ["", " ", "500k", "500000junk", "1e6", "0x100", "1.5", "-1", "0", String(Number.MAX_SAFE_INTEGER + 2)]) { + assert.throws(() => normalizeContextWindowOverrides(typed(value)), (error) => error instanceof ContextWindowError && error.model === "login/model", value); + } + assert.equal(normalizeContextWindowOverrides(typed(" 1050000 ")).providers.login.modelOverrides.model.contextWindow, 1050000); +}); + +test("a value already in models.json only has to pass pi's check, so it never blocks another save", () => { + const config = { providers: { + login: { modelOverrides: { a: { contextWindow: 400000.5 }, b: { contextWindow: "500000" } } }, + custom: { models: [{ id: "edited" }] }, + } }; + assert.deepEqual(normalizeContextWindowOverrides(config).providers.login.modelOverrides, { a: { contextWindow: 400000.5 }, b: { contextWindow: 500000 } }); + // pi refuses these too: writing one would break loading models.json. + for (const value of [0, -1, null, true, {}]) { + assert.throws(() => normalizeContextWindowOverrides({ providers: { login: { modelOverrides: { a: { contextWindow: value } } } } }), ContextWindowError); + } +}); diff --git a/lib/model-context.ts b/lib/model-context.ts new file mode 100644 index 0000000000..dd82cf11ae --- /dev/null +++ b/lib/model-context.ts @@ -0,0 +1,71 @@ +/** Client/server helpers for the model directory's context-window overrides. */ +export interface ModelContextConfig { + providers?: Record }>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function withContextWindowOverride( + config: T, + providerId: string, + modelId: string, + value: string | number | undefined, +): T { + const providers = { ...config.providers }; + const provider = { ...(Object.hasOwn(providers, providerId) ? providers[providerId] : {}) }; + const modelOverrides = { ...provider.modelOverrides }; + const previous = Object.hasOwn(modelOverrides, modelId) ? modelOverrides[modelId] : undefined; + if (previous !== undefined && !isRecord(previous)) throw new Error("Model override must be an object"); + const override: Record = { ...previous as Record | undefined }; + if (value === undefined) delete override.contextWindow; + else override.contextWindow = value; + if (Object.keys(override).length) Object.defineProperty(modelOverrides, modelId, { value: override, enumerable: true, configurable: true }); + else delete modelOverrides[modelId]; + if (Object.keys(modelOverrides).length) provider.modelOverrides = modelOverrides; + else delete provider.modelOverrides; + if (Object.keys(provider).length) Object.defineProperty(providers, providerId, { value: provider, enumerable: true, configurable: true }); + else delete providers[providerId]; + return { ...config, providers }; +} + +/** `model` is the override's `provider/modelId`, so a refused save can name the field. */ +export class ContextWindowError extends Error { + readonly model: string; + + constructor(model: string) { + super(`Context window of ${model} must be a positive number of tokens`); + this.name = "ContextWindowError"; + this.model = model; + } +} + +/** + * The field's draft is text and must be a whole token count. A value already + * in models.json only has to pass pi's own check, any positive number + * (400000.5 loads), so one hand-edited override never blocks an unrelated save. + * Undefined when the value is neither. + */ +export function parseContextWindow(value: unknown): number | undefined { + if (typeof value === "string") { + const parsed = /^\d+$/.test(value.trim()) ? Number(value.trim()) : 0; + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; + } + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined; +} + +export function normalizeContextWindowOverrides(config: T): T { + const result = structuredClone(config); + if (!isRecord(result.providers)) return result; + for (const [providerId, provider] of Object.entries(result.providers)) { + if (!isRecord(provider) || !isRecord(provider.modelOverrides)) continue; + for (const [modelId, override] of Object.entries(provider.modelOverrides)) { + if (!isRecord(override) || !Object.hasOwn(override, "contextWindow")) continue; + const parsed = parseContextWindow(override.contextWindow); + if (parsed === undefined) throw new ContextWindowError(`${providerId}/${modelId}`); + override.contextWindow = parsed; + } + } + return result; +}