From 404b68cdd44684379f3d888887b23f49b4b06140 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Fri, 29 May 2026 04:23:30 +0800
Subject: [PATCH] feat(webui): add context window setting
---
nanobot/webui/settings_api.py | 33 +++++
tests/agent/test_runtime_refresh.py | 32 ++++-
tests/webui/test_settings_api.py | 55 ++++++++
.../src/components/settings/SettingsView.tsx | 131 ++++++++++++------
webui/src/i18n/locales/en/common.json | 6 +-
webui/src/i18n/locales/es/common.json | 6 +-
webui/src/i18n/locales/fr/common.json | 6 +-
webui/src/i18n/locales/id/common.json | 6 +-
webui/src/i18n/locales/ja/common.json | 6 +-
webui/src/i18n/locales/ko/common.json | 6 +-
webui/src/i18n/locales/vi/common.json | 6 +-
webui/src/i18n/locales/zh-CN/common.json | 6 +-
webui/src/i18n/locales/zh-TW/common.json | 6 +-
webui/src/lib/api.ts | 6 +
webui/src/lib/types.ts | 2 +
webui/src/tests/api.test.ts | 6 +-
webui/src/tests/settings-view.test.tsx | 25 +++-
17 files changed, 280 insertions(+), 64 deletions(-)
diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py
index efd836b2e..255dccc5e 100644
--- a/nanobot/webui/settings_api.py
+++ b/nanobot/webui/settings_api.py
@@ -85,6 +85,7 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
"2:3",
"21:9",
}
+_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 262_144}
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
@@ -257,6 +258,18 @@ def _parse_bool(value: str, field: str) -> bool:
return normalized in {"1", "true", "yes"}
+def _parse_context_window_tokens(value: str | None) -> int | None:
+ if value is None:
+ return None
+ try:
+ parsed = int(value)
+ except ValueError:
+ raise WebUISettingsError("context_window_tokens must be an integer") from None
+ if parsed not in _CONTEXT_WINDOW_TOKEN_OPTIONS:
+ raise WebUISettingsError("context_window_tokens must be 65536 or 262144")
+ return parsed
+
+
def _model_configuration_slug(label: str) -> str:
normalized = _MODEL_CONFIGURATION_SLUG_RE.sub("-", label.strip().lower())
normalized = normalized.strip("-_")
@@ -544,6 +557,16 @@ def update_agent_settings(query: QueryParams) -> dict[str, Any]:
defaults.provider = provider
changed = True
+ context_window_tokens = _parse_context_window_tokens(
+ _query_first_alias(query, "context_window_tokens", "contextWindowTokens")
+ )
+ if (
+ context_window_tokens is not None
+ and defaults.context_window_tokens != context_window_tokens
+ ):
+ defaults.context_window_tokens = context_window_tokens
+ changed = True
+
timezone = _query_first(query, "timezone")
if timezone is not None:
timezone = timezone.strip()
@@ -671,6 +694,16 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
preset.provider = provider
changed = True
+ context_window_tokens = _parse_context_window_tokens(
+ _query_first_alias(query, "context_window_tokens", "contextWindowTokens")
+ )
+ if (
+ context_window_tokens is not None
+ and preset.context_window_tokens != context_window_tokens
+ ):
+ preset.context_window_tokens = context_window_tokens
+ changed = True
+
if config.agents.defaults.model_preset != name:
config.agents.defaults.model_preset = name
changed = True
diff --git a/tests/agent/test_runtime_refresh.py b/tests/agent/test_runtime_refresh.py
index b36b1899b..1c7ca01c5 100644
--- a/tests/agent/test_runtime_refresh.py
+++ b/tests/agent/test_runtime_refresh.py
@@ -4,7 +4,10 @@ from unittest.mock import MagicMock
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
-from nanobot.providers.factory import ProviderSnapshot
+from nanobot.config.loader import save_config
+from nanobot.config.schema import Config
+from nanobot.providers.factory import ProviderSnapshot, load_provider_snapshot
+from nanobot.webui.settings_api import update_agent_settings
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
@@ -72,3 +75,30 @@ def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
assert runtime.model == "new-model"
assert loop.provider is new_provider
assert loop.runner.provider is new_provider
+
+
+def test_settings_context_window_refreshes_runtime_state(
+ tmp_path: Path,
+ monkeypatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ config = Config()
+ config.agents.defaults.workspace = str(tmp_path / "workspace")
+ config.agents.defaults.model = "openai/gpt-4o"
+ config.agents.defaults.provider = "openai"
+ config.agents.defaults.context_window_tokens = 65_536
+ config.providers.openai.api_key = "sk-test"
+ save_config(config, config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ def loader(*, preset_name: str | None = None) -> ProviderSnapshot:
+ return load_provider_snapshot(config_path, preset_name=preset_name)
+
+ loop = AgentLoop.from_config(config, provider_snapshot_loader=loader)
+
+ payload = update_agent_settings({"context_window_tokens": ["262144"]})
+ loop._refresh_provider_snapshot()
+
+ assert payload["requires_restart"] is False
+ assert loop.context_window_tokens == 262_144
+ assert loop.consolidator.context_window_tokens == 262_144
diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py
index ce8f74789..6cd8da493 100644
--- a/tests/webui/test_settings_api.py
+++ b/tests/webui/test_settings_api.py
@@ -11,6 +11,7 @@ from nanobot.webui.settings_api import (
_oauth_provider_status,
create_model_configuration,
settings_payload,
+ update_agent_settings,
update_model_configuration,
update_network_safety_settings,
)
@@ -119,6 +120,60 @@ def test_update_model_configuration_edits_named_preset_and_selects(
assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5"
+def test_update_agent_settings_accepts_context_window_options(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ config = Config()
+ save_config(config, config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ payload = update_agent_settings({"context_window_tokens": ["262144"]})
+
+ assert payload["agent"]["context_window_tokens"] == 262144
+ saved = load_config(config_path)
+ assert saved.agents.defaults.context_window_tokens == 262144
+
+
+def test_update_model_configuration_accepts_context_window_options(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ config = Config()
+ config.model_presets["codex"] = ModelPresetConfig(
+ label="Codex",
+ provider="openai",
+ model="openai/gpt-4.1",
+ )
+ save_config(config, config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ payload = update_model_configuration(
+ {
+ "name": ["codex"],
+ "context_window_tokens": ["262144"],
+ }
+ )
+
+ assert payload["agent"]["context_window_tokens"] == 262144
+ saved = load_config(config_path)
+ assert saved.model_presets["codex"].context_window_tokens == 262144
+
+
+def test_update_context_window_rejects_unknown_values(
+ tmp_path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ config_path = tmp_path / "config.json"
+ save_config(Config(), config_path)
+ monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
+
+ with pytest.raises(WebUISettingsError, match="context_window_tokens must be 65536 or 262144"):
+ update_agent_settings({"context_window_tokens": ["128000"]})
+
+
def test_update_model_configuration_rejects_default_preset(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index 0dabc07a3..811af69c0 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -139,6 +139,7 @@ interface AgentSettingsDraft {
provider: string;
modelPreset: string;
presetLabel: string;
+ contextWindowTokens: number;
timezone: string;
botName: string;
botIcon: string;
@@ -164,6 +165,7 @@ type ProviderForm = { apiKey: string; apiBase: string; apiType: ProviderApiType
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
const NANOBOT_ICON_SRC = "/brand/nanobot_icon.png";
+const CONTEXT_WINDOW_TOKEN_OPTIONS = [65_536, 262_144] as const;
const FALLBACK_TIMEZONES = [
"UTC",
@@ -279,6 +281,10 @@ function defaultPreset(payload: SettingsPayload): SettingsPayload["model_presets
return payload.model_presets.find((preset) => preset.is_default) ?? null;
}
+function normalizeContextWindowTokens(value: number | null | undefined): number {
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 65_536;
+}
+
function editableDefaultProvider(payload: SettingsPayload): string {
const base = defaultPreset(payload);
return base?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "";
@@ -373,6 +379,7 @@ export function SettingsView({
provider: "",
modelPreset: "default",
presetLabel: "Default",
+ contextWindowTokens: 65_536,
timezone: "UTC",
botName: "nanobot",
botIcon: "",
@@ -398,6 +405,9 @@ export function SettingsView({
: activePreset?.provider ?? editableDefaultProvider(payload),
modelPreset: activePresetName,
presetLabel: activePreset?.label ?? activePresetName,
+ contextWindowTokens: normalizeContextWindowTokens(
+ activePreset?.context_window_tokens ?? payload.agent.context_window_tokens,
+ ),
timezone: payload.agent.timezone,
botName: payload.agent.bot_name,
botIcon: payload.agent.bot_icon,
@@ -533,6 +543,7 @@ export function SettingsView({
form.modelPreset !== activePresetName ||
form.model !== selectedPreset.model ||
form.provider !== selectedProvider ||
+ form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) ||
(!selectedPreset.is_default && form.presetLabel.trim() !== selectedPreset.label)
);
}, [form, settings]);
@@ -644,14 +655,23 @@ export function SettingsView({
label: form.presetLabel.trim(),
model: form.model,
provider: form.provider,
+ ...(form.contextWindowTokens !== selectedPreset.context_window_tokens
+ ? { contextWindowTokens: form.contextWindowTokens }
+ : {}),
});
} else {
const defaultModel = defaultPreset(settings)?.model ?? settings.agent.model;
const defaultProvider = editableDefaultProvider(settings);
+ const defaultContextWindowTokens = normalizeContextWindowTokens(
+ defaultPreset(settings)?.context_window_tokens ?? settings.agent.context_window_tokens,
+ );
payload = await updateSettings(token, {
modelPreset: form.modelPreset,
...(form.model !== defaultModel ? { model: form.model } : {}),
...(form.provider !== defaultProvider ? { provider: form.provider } : {}),
+ ...(form.contextWindowTokens !== defaultContextWindowTokens
+ ? { contextWindowTokens: form.contextWindowTokens }
+ : {}),
});
}
applyPayload(payload);
@@ -1876,6 +1896,9 @@ function ModelsSettings({
? editableDefaultProvider(settings)
: nextPreset?.provider ?? prev.provider,
presetLabel: nextPreset?.label ?? modelPreset,
+ contextWindowTokens: normalizeContextWindowTokens(
+ nextPreset?.context_window_tokens ?? prev.contextWindowTokens,
+ ),
}));
}}
onCreateConfiguration={onCreateConfiguration}
@@ -1895,49 +1918,73 @@ function ModelsSettings({
/>
) : null}
-
+ setForm((prev) => ({ ...prev, provider }))}
+ />
+
+ {selectedProviderNeedsSignIn ? (
+
+
- {selectedProviderNeedsSignIn ? (
-
-
-
- ) : null}
-
- setForm((prev) => ({ ...prev, model: event.target.value }))}
- className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
- />
-
+ {selectedProviderSigningIn ? (
+
+ ) : null}
+ {selectedProviderSigningIn
+ ? tx("settings.oauth.signingIn", "Signing in...")
+ : tx("settings.oauth.signIn", "Sign in")}
+
+
+ ) : null}
+
+ setForm((prev) => ({ ...prev, model: event.target.value }))}
+ className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]"
+ />
+
+
+ ({
+ value: String(tokens),
+ label: tokens === 262_144 ? "256K" : "64K",
+ }))}
+ onChange={(value) =>
+ setForm((prev) => ({
+ ...prev,
+ contextWindowTokens: normalizeContextWindowTokens(Number(value)),
+ }))
+ }
+ />
+
(
`${base}/api/settings/model-configurations/update?${query}`,
token,
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index f63036f19..23462b043 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -517,6 +517,7 @@ export interface SettingsUpdate {
model?: string;
provider?: string;
modelPreset?: string | null;
+ contextWindowTokens?: number;
timezone?: string;
botName?: string;
botIcon?: string;
@@ -535,6 +536,7 @@ export interface ModelConfigurationUpdate {
label?: string;
provider?: string;
model?: string;
+ contextWindowTokens?: number;
}
export interface ProviderSettingsUpdate {
diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts
index 8675ec308..ea7973a2a 100644
--- a/webui/src/tests/api.test.ts
+++ b/webui/src/tests/api.test.ts
@@ -65,6 +65,7 @@ describe("webui API helpers", () => {
modelPreset: "default",
model: "openrouter/test",
provider: "openrouter",
+ contextWindowTokens: 262144,
timezone: "Asia/Shanghai",
botName: "nanobot",
botIcon: "nb",
@@ -72,7 +73,7 @@ describe("webui API helpers", () => {
});
expect(fetch).toHaveBeenCalledWith(
- "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
+ "/api/settings/update?model_preset=default&model=openrouter%2Ftest&provider=openrouter&context_window_tokens=262144&timezone=Asia%2FShanghai&bot_name=nanobot&bot_icon=nb&tool_hint_max_length=120",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
@@ -100,10 +101,11 @@ describe("webui API helpers", () => {
label: "Codex",
provider: "openai_codex",
model: "openai-codex/gpt-5.5",
+ contextWindowTokens: 65536,
});
expect(fetch).toHaveBeenCalledWith(
- "/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5",
+ "/api/settings/model-configurations/update?name=codex&label=Codex&provider=openai_codex&model=openai-codex%2Fgpt-5.5&context_window_tokens=65536",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
diff --git a/webui/src/tests/settings-view.test.tsx b/webui/src/tests/settings-view.test.tsx
index b209a6f8e..6b0dfd37a 100644
--- a/webui/src/tests/settings-view.test.tsx
+++ b/webui/src/tests/settings-view.test.tsx
@@ -121,7 +121,7 @@ const installedAnyGen = {
function renderSettingsView(
options: {
- initialSection?: "apps" | "advanced";
+ initialSection?: "apps" | "advanced" | "models";
onSettingsChange?: (payload: SettingsPayload) => void;
} = {},
) {
@@ -222,6 +222,29 @@ describe("SettingsView Apps catalog", () => {
await waitFor(() => expect(onSettingsChange).toHaveBeenCalledWith(payload));
});
+ it("shows context window options in model settings", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url === "/api/settings") return jsonResponse(settingsPayload());
+ if (url === "/api/settings/cli-apps") {
+ return jsonResponse({ apps: [], installed_count: 0 });
+ }
+ if (url === "/api/settings/mcp-presets") {
+ return jsonResponse({ presets: [], installed_count: 0 });
+ }
+ return { ok: false, status: 404, json: async () => ({}) } as Response;
+ }),
+ );
+
+ renderSettingsView({ initialSection: "models" });
+
+ expect(await screen.findByText("Context window")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "64K" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "256K" })).toBeInTheDocument();
+ });
+
it("saves network safety without exposing technical SSRF copy", async () => {
const payload = settingsPayload();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {