From 6b50d7326a30b9ac72246a71a42b7dbc45d93dde Mon Sep 17 00:00:00 2001 From: Cocker <22zyfeng@gmail.com> Date: Tue, 13 Oct 2026 20:59:44 +0800 Subject: [PATCH 1/7] feat: edit managed-model context windows in settings --- app/api/models-config/route.test.mjs | 31 +++++++++ app/api/models-config/route.ts | 6 +- app/settings.css | 42 ++++++++++++ components/EnabledModelsSection.tsx | 16 +++++ components/ModelContextWindow.test.mjs | 29 +++++++++ components/ModelContextWindow.tsx | 53 +++++++++++++++ components/ModelsConfig.tsx | 50 +++++++++++--- docs/agents/models.md | 5 ++ e2e/model-context.mjs | 90 ++++++++++++++++++++++++++ e2e/run.mjs | 5 +- lib/enabled-models-runtime.test.mjs | 1 + lib/enabled-models-runtime.ts | 1 + lib/enabled-models.ts | 2 + lib/i18n/messages/en.ts | 5 ++ lib/i18n/messages/zh-CN.ts | 5 ++ lib/i18n/messages/zh-TW.ts | 5 ++ lib/model-context.test.mjs | 44 +++++++++++++ lib/model-context.ts | 58 +++++++++++++++++ 18 files changed, 438 insertions(+), 10 deletions(-) create mode 100644 components/ModelContextWindow.test.mjs create mode 100644 components/ModelContextWindow.tsx create mode 100644 e2e/model-context.mjs create mode 100644 lib/model-context.test.mjs create mode 100644 lib/model-context.ts diff --git a/app/api/models-config/route.test.mjs b/app/api/models-config/route.test.mjs index 3168ed3ad7..2119e2964d 100644 --- a/app/api/models-config/route.test.mjs +++ b/app/api/models-config/route.test.mjs @@ -52,3 +52,34 @@ 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); +}); 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..33ce30f54d 100644 --- a/app/settings.css +++ b/app/settings.css @@ -430,6 +430,48 @@ border-top: 1px solid var(--border); } +.model-context-field { + display: flex; + flex-direction: column; + gap: 4px; + flex: 0 0 180px; + min-width: 0; + color: var(--text-muted); + font-size: 10px; +} + +.model-context-controls { + display: flex; + gap: 5px; +} + +.model-context-controls input { + width: 100%; + min-width: 0; + height: 28px; + box-sizing: border-box; + padding: 3px 7px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font: 12px var(--font-mono); +} + +.model-context-controls input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.model-context-controls input[aria-invalid="true"] { border-color: #ef4444; } +.model-context-controls input:disabled { opacity: 0.6; } +.model-context-effective { color: var(--text-dim); } + +@media (max-width: 640px) { + .enabled-models-row { flex-wrap: wrap; } + .model-context-field { flex-basis: 100%; order: 1; } +} + .enabled-models-row-text { min-width: 0; flex: 1; diff --git a/components/EnabledModelsSection.tsx b/components/EnabledModelsSection.tsx index 6da2880f42..cbff030f6d 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(""); @@ -453,11 +456,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 +476,15 @@ export function EnabledModelsSection({ {model.thinkingPin} )} + {contextSettings && ( + contextSettings.onChange(model.id, value)} + /> + )} { + const html = render("500000"); + assert.match(html, /value="500000"/); + assert.match(html, /inputmode="numeric"/i); + assert.match(html, /GPT Example/); + assert.match(html, /272,000/); +}); + +test("an invalid draft is visible without truncating it to a different context size", () => { + const html = render("500k"); + assert.match(html, /value="500k"/); + assert.match(html, /aria-invalid="true"/); + assert.match(html, /role="alert"/); +}); diff --git a/components/ModelContextWindow.tsx b/components/ModelContextWindow.tsx new file mode 100644 index 0000000000..9c499f2431 --- /dev/null +++ b/components/ModelContextWindow.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useId } from "react"; +import { useI18n } from "@/hooks/useI18n"; +import { parseContextWindow } from "@/lib/model-context"; +import { ConfigButton } from "./SettingsUi"; + +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. */ +export function ModelContextWindow({ modelName, effective, value, onChange, disabled = false }: { + modelName: string; + effective?: number; + value: unknown; + onChange: (value: string | undefined) => void; + disabled?: boolean; +}) { + const { t, locale } = useI18n(); + const id = useId(); + let invalid = false; + if (value !== undefined) { + try { parseContextWindow(value); } catch { invalid = true; } + } + return ( +
+ +
+ onChange(event.target.value === "" ? undefined : event.target.value)} + /> + onChange(undefined)}> + {t("models.contextDefault")} + +
+ + {invalid ? t("models.contextInvalid") : effective === undefined ? "" : t("models.contextEffective", { tokens: effective.toLocaleString(locale) })} + +
+ ); +} diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 9f6b556c15..5357a3e341 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 && }
); } @@ -1906,6 +1910,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 +2063,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 +2085,16 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { enabledModels.resync(renames, modelRenames); } } catch (e) { - setSaveError(String(e)); + setSaveError(e instanceof ContextWindowError ? t("models.contextInvalid") : 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]) => { + const managed = oauthProviders.some((provider) => provider.id === id) || apiKeyProviders.some((provider) => provider.id === id); + return !managed || Object.keys(entry).length !== 1 || !entry.modelOverrides; + }); // `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,12 +2109,12 @@ 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]; diff --git a/docs/agents/models.md b/docs/agents/models.md index 9d1e8a34e9..70369a9b9a 100644 --- a/docs/agents/models.md +++ b/docs/agents/models.md @@ -27,6 +27,11 @@ The Models panel edits it only through `/api/models/enabled`, never with pattern - `op: "prune"` is the only operation that drops unmatched entries (cleanup after a catalog rename); `op: "clear"` removes the setting; every other operation preserves them. - Saving models.json calls `op: "resync"`, the only operation that repairs entries (a toggle never touches what the user did not switch): it rewrites renamed models, then renamed providers (model references still spell the old provider id), cuts back entries whose provider prefix no longer scopes them, and re-asserts providers fully enabled before the save. Otherwise a rename corrupts the scope: pi also matches the bare `modelId`, so a provider renamed to `stepfun` makes `stepfun/*` enable `commandcode`'s `stepfun/Step-5-Preview`; a model renamed to an id with a slash leaves `provider/*`; a renamed sole entry resolves to nothing, i.e. every model. `ModelsConfig` must mirror every draft array move in `savedModelIdsRef` so `collectModelRenames()` can tell a rename from an add or a delete. +## Context-window overrides +Managed providers' model rows edit `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models. Save normalizes positive safe integers on both client and server; invalid drafts stay visible. Clearing the field or Default removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. + +The row's current budget comes from the SDK-resolved model, not from the draft. New runtimes pick up the saved budget; loaded sessions need a Pi Web restart. Pi 1.2's `/reload` reloads resources but retains the session's model metadata, so do not promise that it applies this edit. This changes Pi's context accounting, not the provider's actual limit; `enabledModels` switches still save separately and immediately. + ## Auth and model config - `ModelsConfig` combines `~/.pi/agent/models.json` with provider auth status from pi's `ModelRuntime` (`/api/auth/providers`, through `createModelRuntimeWithExtensions()`). - Keep an omitted provider `api` unset in the editor and discovery request: pi resolves the protocol for built-in providers. Opening a provider must not persist a guessed protocol. New custom providers still start with an explicit `openai-completions` default. diff --git a/e2e/model-context.mjs b/e2e/model-context.mjs new file mode 100644 index 0000000000..41d155f944 --- /dev/null +++ b/e2e/model-context.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; + +// Keep account credentials and upstream services outside this UI boundary. +// The route tests separately exercise the real writer and SDK model resolution. +export async function checkModelContext(page) { + let saved = { providers: { relay: { models: [{ id: "keep-me" }] } } }; + let rejectSave = false; + let writes = 0; + const routes = ["**/api/models-config", "**/api/auth/providers", "**/api/models/enabled*", "**/api/provider-usage/query"]; + await page.route(routes[0], (route) => { + 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(); + 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.getByRole("alert").first().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 settings.getByRole("button", { name: "Default", exact: true }).click(); + assert.equal(await input.inputValue(), ""); + await save(); + assert.deepEqual(saved, { providers: { relay: { models: [{ id: "keep-me" }] } } }); + await settings.getByRole("button", { name: "Close", exact: true }).click(); + console.log("PASS: account context edit/save/reopen/validation/retry/default 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 9b5228d0c8..cfb8bec8b0 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"; @@ -239,7 +240,8 @@ try { browser = await chromium.launch(); for (const viewport of [{ width: 1280, height: 800 }, { width: 390, height: 844 }]) { - context = await browser.newContext({ viewport, locale: "en-US" }); + // Route fixtures must intercept requests in production builds too. + context = await browser.newContext({ viewport, locale: "en-US", serviceWorkers: "block" }); await context.tracing.start({ screenshots: true, snapshots: true }); page = await context.newPage(); page.setDefaultTimeout(30_000); @@ -497,6 +499,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 cd39a31b63..e0349c29af 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -1290,6 +1290,11 @@ export const enLocale: LocalePlugin = { "models.imageInput": "Image input", "models.modelSpecs": "Model specifications", "models.contextWindow": "Context window (tokens)", + "models.contextDefault": "Default", + "models.contextForModel": "Context window for {model}", + "models.contextEffective": "Currently: {tokens}", + "models.contextInvalid": "Enter a positive whole number of tokens.", + "models.contextOverrideHelp": "Blank uses the catalog default. Save below; newly started sessions use the change. Restart Pi Web to update loaded sessions. This does not increase the provider's actual limit.", "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 96c75c327e..07d216f657 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -1290,6 +1290,11 @@ export const zhCNLocale: LocalePlugin = { "models.imageInput": "图片输入", "models.modelSpecs": "模型规格", "models.contextWindow": "上下文窗口(tokens)", + "models.contextDefault": "默认", + "models.contextForModel": "{model} 的上下文窗口", + "models.contextEffective": "当前配置:{tokens}", + "models.contextInvalid": "请输入正整数 tokens 数量。", + "models.contextOverrideHelp": "留空使用模型目录默认值。点击底部「保存」后,新启动的会话使用新值;已加载的会话需重启 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 d8b6d41aa3..15b3464210 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -1290,6 +1290,11 @@ export const zhTWLocale: LocalePlugin = { "models.imageInput": "圖片輸入", "models.modelSpecs": "模型規格", "models.contextWindow": "上下文視窗(Token)", + "models.contextDefault": "預設", + "models.contextForModel": "{model} 的上下文視窗", + "models.contextEffective": "目前設定:{tokens}", + "models.contextInvalid": "請輸入正整數 Token 數量。", + "models.contextOverrideHelp": "留空使用模型目錄預設值。點擊底部「儲存」後,新啟動的工作階段使用新值;已載入的工作階段需重新啟動 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..73dd5b32ea --- /dev/null +++ b/lib/model-context.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { 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("invalid context budgets cannot be saved, including fractions, overflow and partial numeric strings", () => { + for (const value of [0, -1, 1.5, null, true, "", "500k", "500000junk", "1e6", "0x100", "1.5", NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => normalizeContextWindowOverrides({ providers: { login: { modelOverrides: { model: { contextWindow: value } } } } }), /positive safe integer/); + } + const normalized = normalizeContextWindowOverrides({ providers: { login: { modelOverrides: { model: { contextWindow: " 1050000 " } } } } }); + assert.equal(normalized.providers.login.modelOverrides.model.contextWindow, 1050000); +}); diff --git a/lib/model-context.ts b/lib/model-context.ts new file mode 100644 index 0000000000..c6f1b4bc9f --- /dev/null +++ b/lib/model-context.ts @@ -0,0 +1,58 @@ +/** 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 }; +} + +export class ContextWindowError extends Error { + constructor() { + super("Context window must be a positive safe integer (tokens)"); + this.name = "ContextWindowError"; + } +} + +export function parseContextWindow(value: unknown): number { + const parsed = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value; + if (typeof parsed !== "number" || !Number.isSafeInteger(parsed) || parsed <= 0) throw new ContextWindowError(); + return parsed; +} + +export function normalizeContextWindowOverrides(config: T): T { + const result = structuredClone(config); + if (!isRecord(result.providers)) return result; + for (const provider of Object.values(result.providers)) { + if (!isRecord(provider) || !isRecord(provider.modelOverrides)) continue; + for (const override of Object.values(provider.modelOverrides)) { + if (isRecord(override) && Object.hasOwn(override, "contextWindow")) { + override.contextWindow = parseContextWindow(override.contextWindow); + } + } + } + return result; +} From 7e5d7df7042acdfb6ce6d05ada46ce2345a9a335 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 14 Oct 2026 22:52:18 +0900 Subject: [PATCH 2/7] fix(models): let a context override pi accepts through a save The context-window normalizer refused any value in models.json that was not a positive safe integer, on the client and in the route. pi itself accepts any positive number (400000.5 loads and resolves), so one hand-edited override blocked every later Save, even an edit to an unrelated custom provider. parseContextWindow() now keeps the field's rule for what the user types (text must be a whole token count) and only pi's own check for a number already in the file. A value pi refuses still stops the save, since pi drops the whole models.json on a schema error, and the message names the model's provider/id so the field can be found. Co-Authored-By: Claude Opus 5.5 --- app/api/models-config/route.test.mjs | 18 ++++++++++++++ components/ModelContextWindow.tsx | 7 ++---- components/ModelsConfig.tsx | 2 +- docs/agents/models.md | 2 +- lib/i18n/messages/en.ts | 2 +- lib/i18n/messages/zh-CN.ts | 2 +- lib/i18n/messages/zh-TW.ts | 2 +- lib/model-context.test.mjs | 24 ++++++++++++++----- lib/model-context.ts | 35 +++++++++++++++++++--------- 9 files changed, 67 insertions(+), 27 deletions(-) diff --git a/app/api/models-config/route.test.mjs b/app/api/models-config/route.test.mjs index 2119e2964d..0af3edafd7 100644 --- a/app/api/models-config/route.test.mjs +++ b/app/api/models-config/route.test.mjs @@ -83,3 +83,21 @@ test("saved login-model context overrides reach the SDK; resetting restores the 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/components/ModelContextWindow.tsx b/components/ModelContextWindow.tsx index 9c499f2431..1c1ed60bc0 100644 --- a/components/ModelContextWindow.tsx +++ b/components/ModelContextWindow.tsx @@ -21,10 +21,7 @@ export function ModelContextWindow({ modelName, effective, value, onChange, disa }) { const { t, locale } = useI18n(); const id = useId(); - let invalid = false; - if (value !== undefined) { - try { parseContextWindow(value); } catch { invalid = true; } - } + const invalid = value !== undefined && parseContextWindow(value) === undefined; return (
@@ -46,7 +43,7 @@ export function ModelContextWindow({ modelName, effective, value, onChange, disa
- {invalid ? t("models.contextInvalid") : effective === undefined ? "" : t("models.contextEffective", { tokens: effective.toLocaleString(locale) })} + {invalid ? t("models.contextInvalid", { model: modelName }) : effective === undefined ? "" : t("models.contextEffective", { tokens: effective.toLocaleString(locale) })} ); diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 5357a3e341..dee2de9504 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -2085,7 +2085,7 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { enabledModels.resync(renames, modelRenames); } } catch (e) { - setSaveError(e instanceof ContextWindowError ? t("models.contextInvalid") : String(e)); + setSaveError(e instanceof ContextWindowError ? t("models.contextInvalid", { model: e.model }) : String(e)); } finally { setSaving(false); } diff --git a/docs/agents/models.md b/docs/agents/models.md index 70369a9b9a..e5e0229c10 100644 --- a/docs/agents/models.md +++ b/docs/agents/models.md @@ -28,7 +28,7 @@ The Models panel edits it only through `/api/models/enabled`, never with pattern - Saving models.json calls `op: "resync"`, the only operation that repairs entries (a toggle never touches what the user did not switch): it rewrites renamed models, then renamed providers (model references still spell the old provider id), cuts back entries whose provider prefix no longer scopes them, and re-asserts providers fully enabled before the save. Otherwise a rename corrupts the scope: pi also matches the bare `modelId`, so a provider renamed to `stepfun` makes `stepfun/*` enable `commandcode`'s `stepfun/Step-5-Preview`; a model renamed to an id with a slash leaves `provider/*`; a renamed sole entry resolves to nothing, i.e. every model. `ModelsConfig` must mirror every draft array move in `savedModelIdsRef` so `collectModelRenames()` can tell a rename from an add or a delete. ## Context-window overrides -Managed providers' model rows edit `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models. Save normalizes positive safe integers on both client and server; invalid drafts stay visible. Clearing the field or Default removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. +Managed providers' model rows edit `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models. Save (client, then the route) checks them with `parseContextWindow()`: text typed in the field must be a whole token count, while a number already in the file only has to pass pi's own check (`exclusiveMinimum: 0`, so `400000.5` loads), or one hand-edited value would block every unrelated save. A value pi refuses (`0`, `null`, a non-numeric string) is refused with the model's `provider/id`: pi drops the whole models.json on a schema error. Invalid drafts stay visible. Clearing the field or Default removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. The row's current budget comes from the SDK-resolved model, not from the draft. New runtimes pick up the saved budget; loaded sessions need a Pi Web restart. Pi 1.2's `/reload` reloads resources but retains the session's model metadata, so do not promise that it applies this edit. This changes Pi's context accounting, not the provider's actual limit; `enabledModels` switches still save separately and immediately. diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index e0349c29af..5d119fe9cf 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -1293,7 +1293,7 @@ export const enLocale: LocalePlugin = { "models.contextDefault": "Default", "models.contextForModel": "Context window for {model}", "models.contextEffective": "Currently: {tokens}", - "models.contextInvalid": "Enter a positive whole number of tokens.", + "models.contextInvalid": "Context window of {model}: enter a positive whole number of tokens.", "models.contextOverrideHelp": "Blank uses the catalog default. Save below; newly started sessions use the change. Restart Pi Web to update loaded sessions. This does not increase the provider's actual limit.", "models.maxOutputTokens": "Max output tokens", "models.costPerMillion": "Cost per million tokens", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 07d216f657..5c25e2d9f0 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -1293,7 +1293,7 @@ export const zhCNLocale: LocalePlugin = { "models.contextDefault": "默认", "models.contextForModel": "{model} 的上下文窗口", "models.contextEffective": "当前配置:{tokens}", - "models.contextInvalid": "请输入正整数 tokens 数量。", + "models.contextInvalid": "{model} 的上下文窗口:请输入正整数 tokens 数量。", "models.contextOverrideHelp": "留空使用模型目录默认值。点击底部「保存」后,新启动的会话使用新值;已加载的会话需重启 Pi Web。这不会提高服务商的实际上限。", "models.maxOutputTokens": "最大输出 tokens", "models.costPerMillion": "每百万 tokens 价格", diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index 15b3464210..872de043e3 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -1293,7 +1293,7 @@ export const zhTWLocale: LocalePlugin = { "models.contextDefault": "預設", "models.contextForModel": "{model} 的上下文視窗", "models.contextEffective": "目前設定:{tokens}", - "models.contextInvalid": "請輸入正整數 Token 數量。", + "models.contextInvalid": "{model} 的上下文視窗:請輸入正整數 Token 數量。", "models.contextOverrideHelp": "留空使用模型目錄預設值。點擊底部「儲存」後,新啟動的工作階段使用新值;已載入的工作階段需重新啟動 Pi Web。這不會提高服務商的實際上限。", "models.maxOutputTokens": "最大輸出 Token 數", "models.costPerMillion": "每百萬 Token 費用", diff --git a/lib/model-context.test.mjs b/lib/model-context.test.mjs index 73dd5b32ea..831526881a 100644 --- a/lib/model-context.test.mjs +++ b/lib/model-context.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url); -const { withContextWindowOverride, normalizeContextWindowOverrides } = await jiti.import("./model-context.ts"); +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 = { @@ -35,10 +35,22 @@ test("reset removes only the context override and cleans up containers it leaves assert.deepEqual(withContextWindowOverride({ providers: {} }, "login", "missing", undefined), { providers: {} }); }); -test("invalid context budgets cannot be saved, including fractions, overflow and partial numeric strings", () => { - for (const value of [0, -1, 1.5, null, true, "", "500k", "500000junk", "1e6", "0x100", "1.5", NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { - assert.throws(() => normalizeContextWindowOverrides({ providers: { login: { modelOverrides: { model: { contextWindow: value } } } } }), /positive safe integer/); +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); } - const normalized = normalizeContextWindowOverrides({ providers: { login: { modelOverrides: { model: { contextWindow: " 1050000 " } } } } }); - assert.equal(normalized.providers.login.modelOverrides.model.contextWindow, 1050000); }); diff --git a/lib/model-context.ts b/lib/model-context.ts index c6f1b4bc9f..dd82cf11ae 100644 --- a/lib/model-context.ts +++ b/lib/model-context.ts @@ -30,28 +30,41 @@ export function withContextWindowOverride( return { ...config, providers }; } +/** `model` is the override's `provider/modelId`, so a refused save can name the field. */ export class ContextWindowError extends Error { - constructor() { - super("Context window must be a positive safe integer (tokens)"); + readonly model: string; + + constructor(model: string) { + super(`Context window of ${model} must be a positive number of tokens`); this.name = "ContextWindowError"; + this.model = model; } } -export function parseContextWindow(value: unknown): number { - const parsed = typeof value === "string" && /^\d+$/.test(value.trim()) ? Number(value.trim()) : value; - if (typeof parsed !== "number" || !Number.isSafeInteger(parsed) || parsed <= 0) throw new ContextWindowError(); - return parsed; +/** + * 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 provider of Object.values(result.providers)) { + for (const [providerId, provider] of Object.entries(result.providers)) { if (!isRecord(provider) || !isRecord(provider.modelOverrides)) continue; - for (const override of Object.values(provider.modelOverrides)) { - if (isRecord(override) && Object.hasOwn(override, "contextWindow")) { - override.contextWindow = parseContextWindow(override.contextWindow); - } + 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; From 20d5faa41db8e22239f11f4c890a2ba320d3b6a6 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 14 Oct 2026 23:03:48 +0900 Subject: [PATCH 3/7] refactor(models): reduce the context-window editor to one input per row Each managed model row carried a 180px block: a repeated "Context window (tokens)" label, an input, a Default button and a "Currently: N" line, which took rows from 43.5px to 78px (117.5px on a phone, where the block wrapped under the name) and nearly doubled OpenRouter's 393-row list. The row now holds one compact numeric text field between the name and the switch. Its placeholder is the window pi resolves for the model, so a blank field reads as "no override" without a Default button or a current-value line; clearing it removes the override as before. The model's name stays in its aria-label, a title says the unit, and the single note above the list says what the fields are and that loaded sessions need a restart. An invalid draft keeps its red border, and the refused Save names the model in the footer. Measured in headless Chrome, rows are 43.5px at 1280px and 390px, as on main. Co-Authored-By: Claude Opus 5.5 --- app/settings.css | 44 ++++++++--------------- components/EnabledModelsSection.tsx | 4 ++- components/ModelContextWindow.test.mjs | 19 +++++----- components/ModelContextWindow.tsx | 49 +++++++++++--------------- docs/agents/models.md | 6 ++-- e2e/model-context.mjs | 14 +++++--- lib/i18n/messages/en.ts | 4 +-- lib/i18n/messages/zh-CN.ts | 4 +-- lib/i18n/messages/zh-TW.ts | 4 +-- 9 files changed, 65 insertions(+), 83 deletions(-) diff --git a/app/settings.css b/app/settings.css index 33ce30f54d..6b75b67efc 100644 --- a/app/settings.css +++ b/app/settings.css @@ -430,47 +430,31 @@ border-top: 1px solid var(--border); } -.model-context-field { - display: flex; - flex-direction: column; - gap: 4px; - flex: 0 0 180px; - min-width: 0; - color: var(--text-muted); - font-size: 10px; -} - -.model-context-controls { - display: flex; - gap: 5px; -} - -.model-context-controls input { - width: 100%; - min-width: 0; - height: 28px; +/* 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: 3px 7px; + padding: 0 7px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--text); - font: 12px var(--font-mono); + font-family: var(--font-mono); + font-size: 11px; + text-align: right; } -.model-context-controls input:focus-visible { +.model-context-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } -.model-context-controls input[aria-invalid="true"] { border-color: #ef4444; } -.model-context-controls input:disabled { opacity: 0.6; } -.model-context-effective { color: var(--text-dim); } - -@media (max-width: 640px) { - .enabled-models-row { flex-wrap: wrap; } - .model-context-field { flex-basis: 100%; order: 1; } -} +.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; diff --git a/components/EnabledModelsSection.tsx b/components/EnabledModelsSection.tsx index cbff030f6d..93d9350177 100644 --- a/components/EnabledModelsSection.tsx +++ b/components/EnabledModelsSection.tsx @@ -395,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 @@ -456,7 +457,7 @@ export function EnabledModelsSection({ className="enabled-models-filter" /> )} - {contextSettings &&

{t("models.contextOverrideHelp")}

} + {contextSettings &&
{t("models.contextOverrideHelp")}
}
{shown.length === 0 ? (
{t("models.enabledNoMatches")}
@@ -482,6 +483,7 @@ export function EnabledModelsSection({ effective={model.contextWindow} value={contextValue} disabled={contextSettings.disabled} + describedBy={contextNoteId} onChange={(value) => contextSettings.onChange(model.id, value)} /> )} diff --git a/components/ModelContextWindow.test.mjs b/components/ModelContextWindow.test.mjs index 2234ac37c0..3e6713f5a1 100644 --- a/components/ModelContextWindow.test.mjs +++ b/components/ModelContextWindow.test.mjs @@ -13,17 +13,20 @@ function render(value) { createElement(ModelContextWindow, { modelName: "GPT Example", effective: 272000, value, onChange() {} }))); } -test("a login model exposes a labeled context input and its currently effective value", () => { - const html = render("500000"); - assert.match(html, /value="500000"/); - assert.match(html, /inputmode="numeric"/i); - assert.match(html, /GPT Example/); - assert.match(html, /272,000/); +test("a login model's row gets one named input whose placeholder is the window pi resolves", () => { + 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("an invalid draft is visible without truncating it to a different context size", () => { +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"/); - assert.match(html, /role="alert"/); + // 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 index 1c1ed60bc0..090781af11 100644 --- a/components/ModelContextWindow.tsx +++ b/components/ModelContextWindow.tsx @@ -1,9 +1,7 @@ "use client"; -import { useId } from "react"; import { useI18n } from "@/hooks/useI18n"; import { parseContextWindow } from "@/lib/model-context"; -import { ConfigButton } from "./SettingsUi"; export interface ModelContextSettings { overrides?: Record; @@ -11,40 +9,33 @@ export interface ModelContextSettings { onChange: (modelId: string, value: string | undefined) => void; } -/** Like custom-model fields, this edits the Models panel's draft, not the file. */ -export function ModelContextWindow({ modelName, effective, value, onChange, disabled = false }: { +/** + * 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, locale } = useI18n(); - const id = useId(); + const { t } = useI18n(); const invalid = value !== undefined && parseContextWindow(value) === undefined; return ( -
- -
- onChange(event.target.value === "" ? undefined : event.target.value)} - /> - onChange(undefined)}> - {t("models.contextDefault")} - -
- - {invalid ? t("models.contextInvalid", { model: modelName }) : effective === undefined ? "" : t("models.contextEffective", { tokens: effective.toLocaleString(locale) })} - -
+ onChange(event.target.value.trim() === "" ? undefined : event.target.value)} + /> ); } diff --git a/docs/agents/models.md b/docs/agents/models.md index e5e0229c10..e33e77363d 100644 --- a/docs/agents/models.md +++ b/docs/agents/models.md @@ -28,9 +28,11 @@ The Models panel edits it only through `/api/models/enabled`, never with pattern - Saving models.json calls `op: "resync"`, the only operation that repairs entries (a toggle never touches what the user did not switch): it rewrites renamed models, then renamed providers (model references still spell the old provider id), cuts back entries whose provider prefix no longer scopes them, and re-asserts providers fully enabled before the save. Otherwise a rename corrupts the scope: pi also matches the bare `modelId`, so a provider renamed to `stepfun` makes `stepfun/*` enable `commandcode`'s `stepfun/Step-5-Preview`; a model renamed to an id with a slash leaves `provider/*`; a renamed sole entry resolves to nothing, i.e. every model. `ModelsConfig` must mirror every draft array move in `savedModelIdsRef` so `collectModelRenames()` can tell a rename from an add or a delete. ## Context-window overrides -Managed providers' model rows edit `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models. Save (client, then the route) checks them with `parseContextWindow()`: text typed in the field must be a whole token count, while a number already in the file only has to pass pi's own check (`exclusiveMinimum: 0`, so `400000.5` loads), or one hand-edited value would block every unrelated save. A value pi refuses (`0`, `null`, a non-numeric string) is refused with the model's `provider/id`: pi drops the whole models.json on a schema error. Invalid drafts stay visible. Clearing the field or Default removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. +Each model row of a managed provider (`EnabledModelsSection` given `contextSettings`) carries one compact text field, `ModelContextWindow`, editing `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models; the shared Save writes it. The row keeps its one-line height: no visible label (an `aria-label` names the model, a `title` says tokens), no Default button, no current-value line; one note above the list says what the fields are and that loaded sessions need a restart. The placeholder is the window pi resolves for the model (the enabled-models view's `contextWindow`, from the SDK-resolved model, overrides included), so a blank field means no override. It reads the saved file, not the draft: a cleared override still shows its old value until Save. Clearing the field removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. -The row's current budget comes from the SDK-resolved model, not from the draft. New runtimes pick up the saved budget; loaded sessions need a Pi Web restart. Pi 1.2's `/reload` reloads resources but retains the session's model metadata, so do not promise that it applies this edit. This changes Pi's context accounting, not the provider's actual limit; `enabledModels` switches still save separately and immediately. +Save (client, then the route) checks every override with `parseContextWindow()`: text typed in the field must be a whole token count, while a number already in the file only has to pass pi's own check (`exclusiveMinimum: 0`, so `400000.5` loads), or one hand-edited value would block every unrelated save. A value pi refuses (`0`, `null`, a non-numeric string) is refused with the model's `provider/id`: pi drops the whole models.json on a schema error. An invalid draft stays visible, marked `aria-invalid`, and the footer names the model. + +New runtimes pick up the saved budget; loaded sessions need a Pi Web restart. Pi 1.2's `/reload` reloads resources but retains the session's model metadata, so do not promise that it applies this edit. This changes Pi's context accounting, not the provider's actual limit; `enabledModels` switches still save separately and immediately. ## Auth and model config - `ModelsConfig` combines `~/.pi/agent/models.json` with provider auth status from pi's `ModelRuntime` (`/api/auth/providers`, through `createModelRuntimeWithExtensions()`). diff --git a/e2e/model-context.mjs b/e2e/model-context.mjs index 41d155f944..74aff1882f 100644 --- a/e2e/model-context.mjs +++ b/e2e/model-context.mjs @@ -47,6 +47,11 @@ export async function checkModelContext(page) { }; 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(); @@ -61,7 +66,7 @@ export async function checkModelContext(page) { await input.fill("500k"); assert.equal(await input.getAttribute("aria-invalid"), "true"); await settings.getByRole("button", { name: "Save", exact: true }).click(); - await settings.getByRole("alert").first().waitFor(); + 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"); @@ -78,12 +83,13 @@ export async function checkModelContext(page) { 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 settings.getByRole("button", { name: "Default", exact: true }).click(); - assert.equal(await input.inputValue(), ""); + 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/default preserve other model settings"); + 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/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 5d119fe9cf..fbce4851f4 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -1290,11 +1290,9 @@ export const enLocale: LocalePlugin = { "models.imageInput": "Image input", "models.modelSpecs": "Model specifications", "models.contextWindow": "Context window (tokens)", - "models.contextDefault": "Default", "models.contextForModel": "Context window for {model}", - "models.contextEffective": "Currently: {tokens}", "models.contextInvalid": "Context window of {model}: enter a positive whole number of tokens.", - "models.contextOverrideHelp": "Blank uses the catalog default. Save below; newly started sessions use the change. Restart Pi Web to update loaded sessions. This does not increase the provider's actual limit.", + "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 5c25e2d9f0..640c324de9 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -1290,11 +1290,9 @@ export const zhCNLocale: LocalePlugin = { "models.imageInput": "图片输入", "models.modelSpecs": "模型规格", "models.contextWindow": "上下文窗口(tokens)", - "models.contextDefault": "默认", "models.contextForModel": "{model} 的上下文窗口", - "models.contextEffective": "当前配置:{tokens}", "models.contextInvalid": "{model} 的上下文窗口:请输入正整数 tokens 数量。", - "models.contextOverrideHelp": "留空使用模型目录默认值。点击底部「保存」后,新启动的会话使用新值;已加载的会话需重启 Pi Web。这不会提高服务商的实际上限。", + "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 872de043e3..d7442a7c3d 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -1290,11 +1290,9 @@ export const zhTWLocale: LocalePlugin = { "models.imageInput": "圖片輸入", "models.modelSpecs": "模型規格", "models.contextWindow": "上下文視窗(Token)", - "models.contextDefault": "預設", "models.contextForModel": "{model} 的上下文視窗", - "models.contextEffective": "目前設定:{tokens}", "models.contextInvalid": "{model} 的上下文視窗:請輸入正整數 Token 數量。", - "models.contextOverrideHelp": "留空使用模型目錄預設值。點擊底部「儲存」後,新啟動的工作階段使用新值;已載入的工作階段需重新啟動 Pi Web。這不會提高服務商的實際上限。", + "models.contextOverrideHelp": "上下文視窗(Token),留空使用預設值。已載入的工作階段需重新啟動 Pi Web 才會更新。", "models.maxOutputTokens": "最大輸出 Token 數", "models.costPerMillion": "每百萬 Token 費用", "models.costInput": "輸入", From 1f7bb16610c1f63164ae8590b29d6f241d5c9d04 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 14 Oct 2026 23:04:38 +0900 Subject: [PATCH 4/7] fix(models): keep an overrides-only entry out of the custom list while loading An entry holding only modelOverrides is hidden from the custom providers once /api/auth/providers names its managed provider. Until that request answers both lists are empty, so the entry was listed as a custom provider (with its "+ Model" row) for as long as the request took, then vanished. The entry now waits for the lists to answer before it can be listed as custom; a failed request still lists it, as before this change. Co-Authored-By: Claude Opus 5.5 --- components/ModelsConfig.tsx | 8 ++++++-- docs/agents/models.md | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index dee2de9504..59c154182b 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -1856,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()); @@ -1879,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(() => { @@ -2092,8 +2095,9 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { }, [applyProviderRename, config, enabledModels, loadError, providerNameDraft, t]); 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 !managed || Object.keys(entry).length !== 1 || !entry.modelOverrides; + return authListed && !managed; }); // `12/40` next to a provider makes a narrowed selector visible at a glance. const scopeBadge = (providerId: string) => { diff --git a/docs/agents/models.md b/docs/agents/models.md index e33e77363d..3b9c2ee852 100644 --- a/docs/agents/models.md +++ b/docs/agents/models.md @@ -28,7 +28,7 @@ The Models panel edits it only through `/api/models/enabled`, never with pattern - Saving models.json calls `op: "resync"`, the only operation that repairs entries (a toggle never touches what the user did not switch): it rewrites renamed models, then renamed providers (model references still spell the old provider id), cuts back entries whose provider prefix no longer scopes them, and re-asserts providers fully enabled before the save. Otherwise a rename corrupts the scope: pi also matches the bare `modelId`, so a provider renamed to `stepfun` makes `stepfun/*` enable `commandcode`'s `stepfun/Step-5-Preview`; a model renamed to an id with a slash leaves `provider/*`; a renamed sole entry resolves to nothing, i.e. every model. `ModelsConfig` must mirror every draft array move in `savedModelIdsRef` so `collectModelRenames()` can tell a rename from an add or a delete. ## Context-window overrides -Each model row of a managed provider (`EnabledModelsSection` given `contextSettings`) carries one compact text field, `ModelContextWindow`, editing `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models; the shared Save writes it. The row keeps its one-line height: no visible label (an `aria-label` names the model, a `title` says tokens), no Default button, no current-value line; one note above the list says what the fields are and that loaded sessions need a restart. The placeholder is the window pi resolves for the model (the enabled-models view's `contextWindow`, from the SDK-resolved model, overrides included), so a blank field means no override. It reads the saved file, not the draft: a cleared override still shows its old value until Save. Clearing the field removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider. +Each model row of a managed provider (`EnabledModelsSection` given `contextSettings`) carries one compact text field, `ModelContextWindow`, editing `providers[provider].modelOverrides[modelId].contextWindow` in the **same models.json draft** as custom models; the shared Save writes it. The row keeps its one-line height: no visible label (an `aria-label` names the model, a `title` says tokens), no Default button, no current-value line; one note above the list says what the fields are and that loaded sessions need a restart. The placeholder is the window pi resolves for the model (the enabled-models view's `contextWindow`, from the SDK-resolved model, overrides included), so a blank field means no override. It reads the saved file, not the draft: a cleared override still shows its old value until Save. Clearing the field removes only `contextWindow`, pruning empty containers while preserving credentials and other overrides. An overrides-only entry stays under its managed provider rather than appearing again as a custom provider; it is listed as custom only once `/api/auth/providers` has answered without that provider (a failed request counts), so it does not flash there while the lists load. Save (client, then the route) checks every override with `parseContextWindow()`: text typed in the field must be a whole token count, while a number already in the file only has to pass pi's own check (`exclusiveMinimum: 0`, so `400000.5` loads), or one hand-edited value would block every unrelated save. A value pi refuses (`0`, `null`, a non-numeric string) is refused with the model's `provider/id`: pi drops the whole models.json on a schema error. An invalid draft stays visible, marked `aria-invalid`, and the footer names the model. From 7bc6fa31ca141a16f9a68db073173e215407d3fb Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 14 Oct 2026 23:04:59 +0900 Subject: [PATCH 5/7] docs: map the context-window editor's files in AGENTS.md lib/model-context.ts and components/ModelContextWindow.tsx were missing from the File Map, and the models.md entry under Topic Notes did not list them, so a change to either would not lead to the note that explains the overrides. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index a5c8983a0c..ac455076c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,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 @@ -191,6 +192,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 @@ -237,7 +239,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 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`. From 8ee706c1b40cca19c633a4ad8bb4e0c600efcc92 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Wed, 14 Oct 2026 23:23:09 +0900 Subject: [PATCH 6/7] fix(models): keep an overrides-only entry out of the custom form while loading The previous commit kept an overrides-only entry out of the custom provider list until /api/auth/providers answers, but the detail pane still opened it. With no remembered selection the panel selects the first models.json key, and a user who only set an override has exactly that entry first: for as long as the provider lists took, the pane showed a custom-provider form for openai-codex (Delete, Provider name, Base URL) beside a list that did not show it, then jumped to the subscription. The pane now opens the custom form only for an entry the list shows as custom, so it stays on its empty state until the lists answer and the existing redirect moves it to the managed provider. A failed request still lists and opens the entry as custom, as before. Co-Authored-By: Claude Opus 5.5 --- components/ModelsConfig.tsx | 3 ++- docs/agents/models.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ModelsConfig.tsx b/components/ModelsConfig.tsx index 59c154182b..3cfaabbe79 100644 --- a/components/ModelsConfig.tsx +++ b/components/ModelsConfig.tsx @@ -2122,7 +2122,8 @@ export function ModelsConfig({ onClose, embedded = false, cwd = null }: { } 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 ( Date: Thu, 15 Oct 2026 15:50:40 +0900 Subject: [PATCH 7/7] test(e2e): let the app register its service worker in the main e2e contexts serviceWorkers: "block" makes reading navigator.serviceWorker throw a SecurityError in production builds, so PwaRegistration's register call became a page error and the width check failed. The routes need no block: sw.js leaves every /api/ request to the network, where page.route sees it, as checkModelDiscovery's routes already do. Co-Authored-By: Claude Opus 5.5 --- e2e/run.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/run.mjs b/e2e/run.mjs index 4efc2fe81e..10a469c261 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -243,8 +243,7 @@ try { browser = await chromium.launch(); for (const viewport of [{ width: 1280, height: 800 }, { width: 390, height: 844 }]) { - // Route fixtures must intercept requests in production builds too. - context = await browser.newContext({ viewport, locale: "en-US", serviceWorkers: "block" }); + context = await browser.newContext({ viewport, locale: "en-US" }); await context.tracing.start({ screenshots: true, snapshots: true }); page = await context.newPage(); page.setDefaultTimeout(30_000);