From 3dc38f639686c0520191658494769d1b9df6f6b9 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:20:39 +0800 Subject: [PATCH] refactor(models): unify preset names --- docs/chat-commands.md | 2 +- docs/configuration.md | 28 ++++------ docs/provider-cookbook.md | 18 ------- docs/providers.md | 5 -- nanobot/agent/model_presets.py | 10 ++-- nanobot/agent/tools/runtime_control.py | 1 - .../websocket/tests/test_websocket_channel.py | 5 +- nanobot/cli/onboard.py | 1 - nanobot/command/builtin.py | 11 +--- nanobot/config/schema.py | 14 ++++- nanobot/webui/settings_models.py | 53 ++++++++++++------- tests/agent/test_model_runtime_resolver.py | 12 +++++ tests/command/test_model_command.py | 22 +++++++- tests/config/test_model_presets.py | 24 +++++++++ tests/webui/test_settings_api.py | 42 ++++++++++++--- .../settings/models/ModelsSettings.tsx | 51 ++++++++---------- .../models/useModelSettingsActions.ts | 11 ++-- .../settings/overview/OverviewSettings.tsx | 2 +- .../settings/useSettingsController.ts | 3 +- .../components/thread/ModelPresetBadge.tsx | 6 +-- webui/src/components/thread/ThreadShell.tsx | 7 ++- webui/src/i18n/locales/en/common.json | 4 +- webui/src/i18n/locales/es/common.json | 4 +- webui/src/i18n/locales/fr/common.json | 4 +- webui/src/i18n/locales/id/common.json | 4 +- webui/src/i18n/locales/ja/common.json | 4 +- webui/src/i18n/locales/ko/common.json | 4 +- webui/src/i18n/locales/pt-BR/common.json | 4 +- webui/src/i18n/locales/vi/common.json | 4 +- webui/src/i18n/locales/zh-CN/common.json | 4 +- webui/src/i18n/locales/zh-TW/common.json | 4 +- webui/src/lib/api.ts | 4 +- webui/src/lib/types.ts | 7 ++- webui/src/tests/api.test.ts | 6 +-- webui/src/tests/settings-models.test.tsx | 33 ++++++------ webui/src/tests/thread-composer.test.tsx | 16 +++--- webui/src/tests/thread-shell.test.tsx | 14 ++--- 37 files changed, 239 insertions(+), 209 deletions(-) diff --git a/docs/chat-commands.md b/docs/chat-commands.md index 2e006243e..c0419dae6 100644 --- a/docs/chat-commands.md +++ b/docs/chat-commands.md @@ -47,7 +47,7 @@ Use `/model` to inspect the current runtime model: /model ``` -The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields. +The response shows the current session's model and preset, plus the available preset names. Each key under the top-level `modelPresets` config is the preset's canonical name everywhere nanobot displays or references it. `default` is always available and represents the model settings from direct `agents.defaults.*` fields. To switch presets for future turns: diff --git a/docs/configuration.md b/docs/configuration.md index 0a86c6e29..9ab884fd9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1404,21 +1404,6 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide { "modelPresets": { "fast": { - "provider": "openrouter", - "model": "anthropic/claude-sonnet-4.5", - "maxTokens": 4096, - "contextWindowTokens": 65536 - } - }, - "agents": { - "defaults": { - "modelPreset": "fast", - "fallbackModels": ["deep", "localSmall"] - } - }, - "modelPresets": { - "fast": { - "label": "Fast", "model": "gpt-4.1-mini", "provider": "openai", "maxTokens": 4096, @@ -1427,7 +1412,6 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide "reasoningEffort": "low" }, "deep": { - "label": "Deep", "model": "claude-opus-4-5", "provider": "anthropic", "maxTokens": 8192, @@ -1435,22 +1419,28 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide "reasoningEffort": "high" }, "localSmall": { - "label": "Local Small", "model": "llama3.2", "provider": "ollama", "maxTokens": 4096, "contextWindowTokens": 32768, "temperature": 0.2 } + }, + "agents": { + "defaults": { + "modelPreset": "fast", + "fallbackModels": ["deep", "localSmall"] + } } } ``` -`modelPresets` is a top-level object. The keys under it (`fast`, `deep`, `coding`, etc.) are user-defined preset names. Each preset supports: +`modelPresets` is a top-level object. Each key (`fast`, `deep`, `coding`, etc.) is the preset's one canonical name: it is shown in the interface, passed to `/model `, and referenced by defaults, fallbacks, sessions, and Dream. Names must be unique ignoring case. Each preset supports: + +Older configs may still contain a `label` inside a preset. It is accepted when loading for compatibility but ignored; the object key remains the canonical name. | Field | Description | |-------|-------------| -| `label` | Optional display name shown in model lists. | | `model` | Model name to use for this preset. | | `provider` | Provider name, or `"auto"` to use provider auto-detection. | | `maxTokens` | Maximum completion/output tokens. | diff --git a/docs/provider-cookbook.md b/docs/provider-cookbook.md index 3eb2ef509..e1cbf031a 100644 --- a/docs/provider-cookbook.md +++ b/docs/provider-cookbook.md @@ -71,7 +71,6 @@ This recipe applies when one API key routes many hosted model families. }, "modelPresets": { "primary": { - "label": "Primary", "provider": "openrouter", "model": "anthropic/claude-sonnet-4.5", "maxTokens": 4096, @@ -113,7 +112,6 @@ OpenCode Zen: }, "modelPresets": { "primary": { - "label": "OpenCode Zen", "provider": "opencode_zen", "model": "opencode/deepseek-v4-pro", "maxTokens": 4096, @@ -140,7 +138,6 @@ OpenCode Go: }, "modelPresets": { "primary": { - "label": "OpenCode Go", "provider": "opencode_go", "model": "opencode-go/deepseek-v4-flash", "maxTokens": 4096, @@ -182,7 +179,6 @@ This recipe applies when you have an OpenAI API key and want to call OpenAI dire }, "modelPresets": { "primary": { - "label": "OpenAI", "provider": "openai", "model": "gpt-5", "maxTokens": 4096, @@ -219,7 +215,6 @@ This recipe applies when your key comes from Anthropic and your model name is an }, "modelPresets": { "primary": { - "label": "Anthropic", "provider": "anthropic", "model": "claude-sonnet-4-5", "maxTokens": 4096, @@ -255,7 +250,6 @@ If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic }, "modelPresets": { "primary": { - "label": "Anthropic proxy", "provider": "anthropic", "model": "claude-sonnet-4-5", "maxTokens": 4096, @@ -286,7 +280,6 @@ This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobo }, "modelPresets": { "kimiCoding": { - "label": "Kimi Coding", "provider": "kimi_coding", "model": "kimi-for-coding", "maxTokens": 4096, @@ -324,7 +317,6 @@ This recipe applies to an OpenAI-compatible service that is not a named nanobot }, "modelPresets": { "primary": { - "label": "Custom", "provider": "custom", "model": "provider-model-name", "maxTokens": 4096, @@ -364,7 +356,6 @@ For multiple custom endpoints, do not overload the single `custom` block. Name e }, "modelPresets": { "work": { - "label": "Work proxy", "provider": "workProxy", "model": "gpt-4o-mini", "maxTokens": 4096, @@ -372,7 +363,6 @@ For multiple custom endpoints, do not overload the single `custom` block. Name e "temperature": 0.1 }, "lab": { - "label": "Lab local", "provider": "lab-local", "model": "served-model-name", "maxTokens": 4096, @@ -408,7 +398,6 @@ ollama pull llama3.2 }, "modelPresets": { "local": { - "label": "Local", "provider": "ollama", "model": "llama3.2", "maxTokens": 2048, @@ -453,7 +442,6 @@ This recipe applies when a local server exposes an OpenAI-compatible `/v1` API. }, "modelPresets": { "local": { - "label": "Local", "provider": "vllm", "model": "served-model-name", "maxTokens": 4096, @@ -480,7 +468,6 @@ For LM Studio, use its local base URL and provider name: }, "modelPresets": { "local": { - "label": "LM Studio", "provider": "lm_studio", "model": "local-model", "maxTokens": 2048, @@ -505,7 +492,6 @@ This recipe applies when one provider sometimes rate-limits, one model is expens { "modelPresets": { "fast": { - "label": "Fast", "provider": "openrouter", "model": "anthropic/claude-sonnet-4.5", "maxTokens": 4096, @@ -513,7 +499,6 @@ This recipe applies when one provider sometimes rate-limits, one model is expens "temperature": 0.1 }, "deep": { - "label": "Deep", "provider": "anthropic", "model": "claude-sonnet-4-5", "maxTokens": 4096, @@ -521,7 +506,6 @@ This recipe applies when one provider sometimes rate-limits, one model is expens "temperature": 0.1 }, "local": { - "label": "Local", "provider": "ollama", "model": "llama3.2", "maxTokens": 2048, @@ -580,14 +564,12 @@ Use this after you have more than one preset and are chatting through a supporte { "modelPresets": { "fast": { - "label": "Fast", "provider": "openrouter", "model": "anthropic/claude-sonnet-4.5", "maxTokens": 4096, "contextWindowTokens": 65536 }, "local": { - "label": "Local", "provider": "ollama", "model": "llama3.2", "maxTokens": 2048, diff --git a/docs/providers.md b/docs/providers.md index 176b3f72d..9d778cc1b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -633,7 +633,6 @@ Model presets are the recommended model configuration surface. Use them when you { "modelPresets": { "fast": { - "label": "Fast", "provider": "openrouter", "model": "anthropic/claude-sonnet-4.5", "maxTokens": 4096, @@ -641,7 +640,6 @@ Model presets are the recommended model configuration surface. Use them when you "temperature": 0.1 }, "deep": { - "label": "Deep", "provider": "anthropic", "model": "claude-opus-4-5", "maxTokens": 8192, @@ -667,7 +665,6 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai { "modelPresets": { "fast": { - "label": "Fast", "provider": "openrouter", "model": "anthropic/claude-sonnet-4.5", "maxTokens": 4096, @@ -675,7 +672,6 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai "temperature": 0.1 }, "deep": { - "label": "Deep", "provider": "anthropic", "model": "claude-opus-4-5", "maxTokens": 8192, @@ -683,7 +679,6 @@ Fallbacks are useful for transient provider failures, rate limits, or model avai "temperature": 0.1 }, "localSmall": { - "label": "Local Small", "provider": "ollama", "model": "llama3.2", "maxTokens": 4096, diff --git a/nanobot/agent/model_presets.py b/nanobot/agent/model_presets.py index 79a7d4e80..a3bd539f9 100644 --- a/nanobot/agent/model_presets.py +++ b/nanobot/agent/model_presets.py @@ -79,7 +79,9 @@ def normalize_preset_name(name: str | None, presets: dict[str, ModelPresetConfig if not isinstance(name, str) or not name.strip(): raise ValueError("model_preset must be a non-empty string") name = name.strip() - if name not in presets: - raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}") - return name - + if name in presets: + return name + matches = [candidate for candidate in presets if candidate.casefold() == name.casefold()] + if len(matches) == 1: + return matches[0] + raise KeyError(f"model_preset {name!r} not found. Available: {', '.join(presets) or '(none)'}") diff --git a/nanobot/agent/tools/runtime_control.py b/nanobot/agent/tools/runtime_control.py index 21fae0c70..7118edc39 100644 --- a/nanobot/agent/tools/runtime_control.py +++ b/nanobot/agent/tools/runtime_control.py @@ -235,7 +235,6 @@ def _snapshot_model_presets( ) -> dict[str, dict[str, object]]: return { name: { - "label": preset.label, "model": preset.model, "provider": preset.provider, "max_tokens": preset.max_tokens, diff --git a/nanobot/channels/websocket/tests/test_websocket_channel.py b/nanobot/channels/websocket/tests/test_websocket_channel.py index 6e07dba45..dd763cabb 100644 --- a/nanobot/channels/websocket/tests/test_websocket_channel.py +++ b/nanobot/channels/websocket/tests/test_websocket_channel.py @@ -3300,7 +3300,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( created_presets = { preset["name"]: preset for preset in created_body["model_presets"] } - assert created_presets["fast-writing"]["label"] == "Fast writing" + assert created_presets["fast-writing"]["label"] == "fast-writing" assert created_presets["fast-writing"]["provider"] == "openai" updated_preset = await _webui_mutate( @@ -3320,7 +3320,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( updated_presets = { preset["name"]: preset for preset in updated_preset_body["model_presets"] } - assert updated_presets["fast-writing"]["label"] == "Codex" + assert updated_presets["fast-writing"]["label"] == "fast-writing" call_order_updated = await _webui_mutate( webui_client, @@ -3437,7 +3437,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( assert saved.agents.defaults.provider == "atomic_chat" assert saved.agents.defaults.model_preset == "fast-writing" assert saved.agents.defaults.fallback_models == ["deep"] - assert saved.model_presets["fast-writing"].label == "Codex" assert saved.model_presets["fast-writing"].model == "openai/gpt-5.5" assert saved.model_presets["fast-writing"].provider == "openai" assert saved.agents.defaults.timezone == "Asia/Shanghai" diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index b28647f7c..87ae569db 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -1594,7 +1594,6 @@ def _pause(message: str = "Press Enter to continue...") -> None: def _set_primary_quick_start_preset(config: Config, provider_name: str, model: str) -> None: """Store the primary preset used by Quick Start.""" config.model_presets["primary"] = ModelPresetConfig( - label="Primary", model=model, provider=provider_name, ) diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index c5fe59105..5e5c92e26 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -375,16 +375,7 @@ async def cmd_model(ctx: CommandContext) -> OutboundMessage: metadata=metadata, ) - parts = args.split() - if len(parts) != 1: - return OutboundMessage( - channel=ctx.msg.channel, - chat_id=ctx.msg.chat_id, - content="Usage: `/model [preset]`", - metadata=metadata, - ) - - name = parts[0] + name = args try: runtime = loop.set_session_model_preset(ctx.key, name) except (KeyError, ValueError) as exc: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1beb36f51..7ef4e7b68 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -97,7 +97,6 @@ FallbackCandidate = str | InlineFallbackConfig class ModelPresetConfig(Base): """A named set of model + generation parameters for quick switching.""" - label: str | None = None model: str provider: str = "auto" max_tokens: int = 8192 @@ -463,7 +462,18 @@ class Config(BaseSettings): @model_validator(mode="after") def _validate_model_preset(self) -> "Config": - if "default" in self.model_presets: + names_by_case: dict[str, str] = {} + for preset_name in self.model_presets: + if preset_name != preset_name.strip() or not preset_name.isprintable(): + raise ValueError(f"invalid model_preset name {preset_name!r}") + normalized = preset_name.casefold() + if normalized in names_by_case: + raise ValueError( + "model_preset names must be unique ignoring case: " + f"{names_by_case[normalized]!r} and {preset_name!r}" + ) + names_by_case[normalized] = preset_name + if "default" in names_by_case: raise ValueError("model_preset name 'default' is reserved for agents.defaults") name = self.agents.defaults.model_preset if name and name != "default" and name not in self.model_presets: diff --git a/nanobot/webui/settings_models.py b/nanobot/webui/settings_models.py index a3816b9d4..eab71e5c1 100644 --- a/nanobot/webui/settings_models.py +++ b/nanobot/webui/settings_models.py @@ -778,6 +778,25 @@ def _model_configuration_slug(label: str) -> str: return normalized +def _model_configuration_name(value: str) -> str: + """Validate a user-facing preset name without inventing a second identity.""" + name = value.strip() + if not name: + raise WebUISettingsError("configuration name is required") + if name.casefold() == "default": + raise WebUISettingsError("configuration name is reserved") + if len(name) > 48: + raise WebUISettingsError("configuration name must be 48 characters or fewer") + if not name.isprintable(): + raise WebUISettingsError("configuration name contains unsupported characters") + return name + + +def _model_configuration_name_exists(config: Config, name: str) -> bool: + normalized = name.casefold() + return any(existing.casefold() == normalized for existing in config.model_presets) + + def _custom_provider_key(config: Config, display_name: str) -> str: slug = _MODEL_CONFIGURATION_SLUG_RE.sub("-", display_name.strip().lower()).strip("-_") base = f"custom-{slug or 'provider'}" @@ -824,7 +843,7 @@ def _unique_model_configuration_name(config: Config, label: str) -> str: base = "model" candidate = base suffix = 2 - while candidate in config.model_presets: + while _model_configuration_name_exists(config, candidate): candidate = f"{base}-{suffix}" suffix += 1 return candidate @@ -928,6 +947,8 @@ def model_settings_payload( model_presets = [ { "name": "default", + # Kept on the wire for older WebUI clients. It is no longer a + # separate product concept and always mirrors the canonical name. "label": "Default", "active": active_preset_name == "default", "is_default": True, @@ -958,7 +979,7 @@ def model_settings_payload( model_presets.append( { "name": name, - "label": preset.label or name, + "label": name, "active": active_preset_name == name, "is_default": False, "model": preset.model, @@ -1052,20 +1073,24 @@ def create_model_configuration( *, oauth_status: OAuthStatusReader, ) -> str: - label = (query_first_alias(query, "label", "displayName") or "").strip() - raw_name = (query_first(query, "name") or label).strip() + raw_name = query_first(query, "name") + legacy_label = query_first_alias(query, "label", "displayName") model = (query_first(query, "model") or "").strip() provider = (query_first(query, "provider") or "").strip() - if not label: - label = raw_name if not model: raise WebUISettingsError("model is required") if not provider: raise WebUISettingsError("provider is required") - name = _model_configuration_slug(raw_name or label) - if name in config.model_presets: + # Old clients only sent `label`; preserve their slugging behaviour while + # new clients provide the one canonical, user-visible name directly. + name = ( + _model_configuration_name(raw_name) + if raw_name is not None + else _model_configuration_slug(legacy_label or "") + ) + if _model_configuration_name_exists(config, name): raise WebUISettingsError("configuration already exists", status=409) _validate_configured_provider(config, provider, oauth_status) @@ -1085,7 +1110,6 @@ def create_model_configuration( query_first_alias(query, "reasoning_effort", "reasoningEffort") or "" ).strip() or None config.model_presets[name] = ModelPresetConfig( - label=label, model=model, provider=provider, max_tokens=max_tokens if max_tokens is not None else base.max_tokens, @@ -1115,15 +1139,6 @@ def update_model_configuration( raise WebUISettingsError("unknown model configuration") changed = False - label = query_first_alias(query, "label", "displayName") - if label is not None: - label = label.strip() - if not label: - raise WebUISettingsError("label is required") - if preset.label != label: - preset.label = label - changed = True - model = query_first(query, "model") if model is not None: model = model.strip() @@ -1228,7 +1243,6 @@ def migrate_model_configurations(config: Config) -> bool: label = _model_configuration_label(primary.model) name = _unique_model_configuration_name(config, label) config.model_presets[name] = ModelPresetConfig( - label=label, model=primary.model, provider=primary.provider, max_tokens=primary.max_tokens, @@ -1247,7 +1261,6 @@ def migrate_model_configurations(config: Config) -> bool: label = _model_configuration_label(fallback.model) name = _unique_model_configuration_name(config, label) config.model_presets[name] = ModelPresetConfig( - label=label, model=fallback.model, provider=fallback.provider, max_tokens=( diff --git a/tests/agent/test_model_runtime_resolver.py b/tests/agent/test_model_runtime_resolver.py index d3aa7dac8..b66d3c6c4 100644 --- a/tests/agent/test_model_runtime_resolver.py +++ b/tests/agent/test_model_runtime_resolver.py @@ -361,3 +361,15 @@ def test_resolver_mutates_only_its_default_selection() -> None: assert resolver.model_preset is None assert initial.model == "base-model" assert initial.context_window_tokens == 10_000 + + +def test_resolver_preserves_canonical_preset_name_for_case_insensitive_input() -> None: + resolver = ModelRuntimeResolver( + _runtime(), + model_presets={"Deep Research": ModelPresetConfig(model="deep-model")}, + ) + + selected = resolver.select_preset("deep research") + + assert selected.model == "deep-model" + assert selected.model_preset == "Deep Research" diff --git a/tests/command/test_model_command.py b/tests/command/test_model_command.py index ceef123c2..04fc03d60 100644 --- a/tests/command/test_model_command.py +++ b/tests/command/test_model_command.py @@ -33,14 +33,14 @@ def _provider(default_model: str, max_tokens: int = 123) -> MagicMock: return provider -def _make_loop(tmp_path, *, preset_snapshot_loader=None) -> AgentLoop: +def _make_loop(tmp_path, *, preset_snapshot_loader=None, model_presets=None) -> AgentLoop: return AgentLoop( bus=MessageBus(), provider=_provider("base-model", max_tokens=123), workspace=tmp_path, model="base-model", context_window_tokens=1000, - model_presets={ + model_presets=model_presets or { "default": ModelPresetConfig( model="base-model", max_tokens=123, @@ -106,6 +106,24 @@ async def test_model_command_switches_preset(tmp_path) -> None: assert status is not None and "openai/gpt-4.1" in status.content +@pytest.mark.asyncio +async def test_model_command_accepts_canonical_names_with_spaces(tmp_path) -> None: + loop = _make_loop( + tmp_path, + model_presets={ + "default": ModelPresetConfig(model="base-model"), + "Deep Research": ModelPresetConfig(model="deep-model"), + }, + ) + + out = await cmd_model( + _ctx(loop, "/model deep research", args="deep research"), + ) + + assert "Switched model preset to `Deep Research`." in out.content + assert _saved_model_preset(loop) == "Deep Research" + + @pytest.mark.asyncio async def test_model_command_switches_back_to_default(tmp_path) -> None: loop = _make_loop(tmp_path) diff --git a/tests/config/test_model_presets.py b/tests/config/test_model_presets.py index e419e40e8..b3121569a 100644 --- a/tests/config/test_model_presets.py +++ b/tests/config/test_model_presets.py @@ -275,6 +275,30 @@ def test_model_presets_accepts_camel_case_root_key() -> None: assert config.model_presets["fast"].provider == "openai" +def test_legacy_model_preset_label_is_ignored() -> None: + config = Config.model_validate({ + "modelPresets": { + "gpt-5-6-sol": { + "label": "Codex", + "model": "openai-codex/gpt-5.6-luna", + } + } + }) + + assert set(config.model_presets) == {"gpt-5-6-sol"} + assert "label" not in config.model_presets["gpt-5-6-sol"].model_dump() + + +def test_model_preset_names_are_unique_ignoring_case() -> None: + with pytest.raises(ValueError, match="unique ignoring case"): + Config.model_validate({ + "modelPresets": { + "Fast": {"model": "openai/gpt-4.1-mini"}, + "fast": {"model": "openai/gpt-4.1"}, + } + }) + + def test_model_presets_serializes_with_camel_case_root_key() -> None: config = Config.model_validate({ "model_presets": { diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 1ec93d361..0e13f25a5 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -236,7 +236,7 @@ def _dynamic_provider_config( return Config.model_validate(raw_config) -def test_create_model_configuration_writes_label_without_changing_call_order( +def test_create_model_configuration_accepts_legacy_label_without_changing_call_order( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -260,11 +260,10 @@ def test_create_model_configuration_writes_label_without_changing_call_order( assert payload["agent"]["model"] == "openai/gpt-4o" assert payload["created_model_preset"] == "fast-writing" rows = {row["name"]: row for row in payload["model_presets"]} - assert rows["fast-writing"]["label"] == "Fast writing" + assert rows["fast-writing"]["label"] == "fast-writing" saved = load_config(config_path) assert saved.agents.defaults.model_preset is None - assert saved.model_presets["fast-writing"].label == "Fast writing" assert saved.model_presets["fast-writing"].model == "openai/gpt-4.1-mini" assert saved.model_presets["fast-writing"].provider == "openai" @@ -279,6 +278,40 @@ def test_create_model_configuration_writes_label_without_changing_call_order( assert duplicate.value.status == 409 +def test_create_model_configuration_preserves_canonical_name( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + config = Config() + config.providers.openai.api_key = "sk-test" + save_config(config, config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + payload = create_model_configuration( + { + "name": ["Fast Writing"], + "provider": ["openai"], + "model": ["openai/gpt-4.1-mini"], + } + ) + + assert payload["created_model_preset"] == "Fast Writing" + rows = {row["name"]: row for row in payload["model_presets"]} + assert rows["Fast Writing"]["label"] == "Fast Writing" + assert "Fast Writing" in load_config(config_path).model_presets + + with pytest.raises(WebUISettingsError) as duplicate: + create_model_configuration( + { + "name": ["fast writing"], + "provider": ["openai"], + "model": ["openai/gpt-4.1-mini"], + } + ) + assert duplicate.value.status == 409 + + def test_create_model_configuration_accepts_dynamic_custom_provider( tmp_path, monkeypatch: pytest.MonkeyPatch, @@ -353,7 +386,6 @@ def test_update_model_configuration_edits_named_preset_without_selecting( config = Config() config.providers.openai.api_key = "sk-test" config.model_presets["codex"] = ModelPresetConfig( - label="Old Codex", provider="openai", model="openai/gpt-4.1", ) @@ -382,7 +414,6 @@ def test_update_model_configuration_edits_named_preset_without_selecting( assert payload["agent"]["model"] == "anthropic/claude-opus-4-5" saved = load_config(config_path) assert saved.agents.defaults.model_preset is None - assert saved.model_presets["codex"].label == "Codex" assert saved.model_presets["codex"].provider == "openai_codex" assert saved.model_presets["codex"].model == "openai-codex/gpt-5.5" @@ -834,7 +865,6 @@ def test_update_model_configuration_preserves_custom_context_windows( config_path = tmp_path / "config.json" config = Config() config.model_presets["codex"] = ModelPresetConfig( - label="Codex", provider="openai", model="openai/gpt-4.1", ) diff --git a/webui/src/components/settings/models/ModelsSettings.tsx b/webui/src/components/settings/models/ModelsSettings.tsx index 590b5e225..e5b8231dc 100644 --- a/webui/src/components/settings/models/ModelsSettings.tsx +++ b/webui/src/components/settings/models/ModelsSettings.tsx @@ -44,7 +44,6 @@ export interface AgentSettingsDraft { model: string; provider: string; modelPreset: string; - presetLabel: string; maxTokens: number; contextWindowTokens: number; temperature: number; @@ -67,7 +66,6 @@ export const DEFAULT_AGENT_SETTINGS_DRAFT: AgentSettingsDraft = { model: "", provider: "", modelPreset: "", - presetLabel: "", maxTokens: 8192, contextWindowTokens: 200_000, temperature: 0.1, @@ -89,7 +87,6 @@ export function agentDraftFromPayload( model: activePreset?.model ?? payload.agent.model, provider: activePreset?.provider ?? payload.agent.provider ?? payload.agent.resolved_provider ?? "", modelPreset: activePresetName, - presetLabel: activePreset?.label ?? activePresetName, maxTokens: activePreset?.max_tokens ?? payload.agent.max_tokens, contextWindowTokens: normalizeContextWindowTokens( activePreset?.context_window_tokens ?? payload.agent.context_window_tokens, @@ -126,7 +123,7 @@ export function ModelPresetDeleteDialog({ {tx( "settings.models.deletePresetHelp", "This removes the preset “{{name}}”. Provider credentials are not affected.", - { name: preset?.label ?? "" }, + { name: preset?.name ?? "" }, )} @@ -266,7 +263,7 @@ export function ModelsSettings({ const modelFieldsMissing = !form.model.trim() || !form.provider.trim() || - !form.presetLabel.trim() || + (creating && !form.modelPreset.trim()) || form.maxTokens <= 0 || form.temperature < 0 || form.temperature > 2; @@ -290,7 +287,6 @@ export function ModelsSettings({ modelPreset: preset.name, model: preset.model, provider: preset.provider, - presetLabel: preset.label, maxTokens: preset.max_tokens, contextWindowTokens: normalizeContextWindowTokens(preset.context_window_tokens), temperature: preset.temperature, @@ -351,30 +347,29 @@ export function ModelsSettings({ ) : null} - - setForm((prev) => ({ ...prev, presetLabel: event.target.value })) - } - className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" - /> + {creating ? ( + + setForm((prev) => ({ ...prev, modelPreset: event.target.value })) + } + className="h-8 w-[min(280px,70vw)] rounded-full text-[13px]" + /> + ) : ( + {form.modelPreset} + )} { @@ -704,7 +699,7 @@ export function ModelsSettings({ - {preset?.label ?? name} + {name} {orderIndex === 0 ? ( diff --git a/webui/src/components/settings/models/useModelSettingsActions.ts b/webui/src/components/settings/models/useModelSettingsActions.ts index 35d55c372..cb433e50a 100644 --- a/webui/src/components/settings/models/useModelSettingsActions.ts +++ b/webui/src/components/settings/models/useModelSettingsActions.ts @@ -128,11 +128,11 @@ export function useModelSettingsActions({ } if (modelPresetCreating) { - const label = form.presetLabel.trim(); + const name = form.modelPreset.trim(); const provider = form.provider.trim(); const model = form.model.trim(); if ( - !label || + !name || !provider || !model || form.maxTokens <= 0 || @@ -145,7 +145,7 @@ export function useModelSettingsActions({ setModelConfigurationSaving(true); try { const payload = await createModelConfiguration(client, { - label, + name, provider, model, maxTokens: form.maxTokens, @@ -190,10 +190,6 @@ export function useModelSettingsActions({ try { const payload = await updateModelConfiguration(client, { name: selectedPreset.name, - label: - form.presetLabel.trim() !== selectedPreset.label - ? form.presetLabel.trim() - : undefined, model: form.model !== selectedPreset.model ? form.model : undefined, provider: form.provider !== selectedPreset.provider ? form.provider : undefined, maxTokens: @@ -235,7 +231,6 @@ export function useModelSettingsActions({ setForm((prev) => ({ ...prev, modelPreset: "", - presetLabel: "", provider, model: "", maxTokens: primaryPreset?.max_tokens ?? settings.agent.max_tokens, diff --git a/webui/src/components/settings/overview/OverviewSettings.tsx b/webui/src/components/settings/overview/OverviewSettings.tsx index 4914eec5d..33bc62ce8 100644 --- a/webui/src/components/settings/overview/OverviewSettings.tsx +++ b/webui/src/components/settings/overview/OverviewSettings.tsx @@ -58,7 +58,7 @@ export function OverviewSettings({ const activePresetName = settings.agent.model_preset; const activePreset = activePresetName && activePresetName !== "default" - ? settings.model_presets.find((preset) => preset.name === activePresetName)?.label ?? + ? settings.model_presets.find((preset) => preset.name === activePresetName)?.name ?? activePresetName : null; const activeProvider = settings.agent.resolved_provider ?? settings.agent.provider; diff --git a/webui/src/components/settings/useSettingsController.ts b/webui/src/components/settings/useSettingsController.ts index 994d50bd0..c7d0a7831 100644 --- a/webui/src/components/settings/useSettingsController.ts +++ b/webui/src/components/settings/useSettingsController.ts @@ -261,8 +261,7 @@ export function useSettingsController({ form.maxTokens !== selectedPreset.max_tokens || form.contextWindowTokens !== normalizeContextWindowTokens(selectedPreset.context_window_tokens) || form.temperature !== selectedPreset.temperature || - form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") || - form.presetLabel.trim() !== selectedPreset.label + form.reasoningEffort !== (selectedPreset.reasoning_effort ?? "") ); }, [form, settings]); diff --git a/webui/src/components/thread/ModelPresetBadge.tsx b/webui/src/components/thread/ModelPresetBadge.tsx index 317d459f7..a62849005 100644 --- a/webui/src/components/thread/ModelPresetBadge.tsx +++ b/webui/src/components/thread/ModelPresetBadge.tsx @@ -14,7 +14,6 @@ import { cn } from "@/lib/utils"; export interface ModelPresetOption { name: string; - label: string; model?: string | null; provider?: string | null; } @@ -99,7 +98,6 @@ export function ModelPresetBadge({ const activePreset: ModelPresetOption = { ...(listedIndex >= 0 ? modelPresets[listedIndex] : undefined), name: activeName, - label: label || modelPresets[listedIndex]?.label || activeName, model: modelDetail ?? modelPresets[listedIndex]?.model, provider: provider || modelPresets[listedIndex]?.provider, }; @@ -225,7 +223,7 @@ export function ModelPresetBadge({ aria-valuemax={canSwitch ? presets.length - 1 : undefined} aria-valuemin={canSwitch ? 0 : undefined} aria-valuenow={canSwitch ? previewIndex : undefined} - aria-valuetext={canSwitch ? previewPreset?.label || label : undefined} + aria-valuetext={canSwitch ? previewPreset?.name || label : undefined} role={canSwitch ? "spinbutton" : undefined} type={interactive || canSwitch ? "button" : undefined} onClick={interactive ? onClick : undefined} @@ -290,7 +288,7 @@ export function ModelPresetBadge({ return ( { it("serializes model configuration creation", async () => { await createModelConfiguration(mutationTransport, { - label: "Fast writing", + name: "Fast writing", provider: "openai", model: "openai/gpt-4.1-mini", maxTokens: 4096, @@ -451,7 +451,7 @@ describe("webui API helpers", () => { expect(requestMutation).toHaveBeenCalledWith( "settings.model_configuration.create", { - label: "Fast writing", + name: "Fast writing", provider: "openai", model: "openai/gpt-4.1-mini", max_tokens: 4096, @@ -466,7 +466,6 @@ describe("webui API helpers", () => { it("serializes model configuration updates", async () => { await updateModelConfiguration(mutationTransport, { name: "codex", - label: "Codex", provider: "openai_codex", model: "openai-codex/gpt-5.5", maxTokens: 8192, @@ -479,7 +478,6 @@ describe("webui API helpers", () => { "settings.model_configuration.update", { name: "codex", - label: "Codex", provider: "openai_codex", model: "openai-codex/gpt-5.5", max_tokens: 8192, diff --git a/webui/src/tests/settings-models.test.tsx b/webui/src/tests/settings-models.test.tsx index e5fd4417b..6b1197253 100644 --- a/webui/src/tests/settings-models.test.tsx +++ b/webui/src/tests/settings-models.test.tsx @@ -89,7 +89,7 @@ async function togglePresetEditor(name = "primary") { describe("Settings models", () => { installSettingsViewTestHooks(); - it("distinguishes the editable display name from the stable command name", async () => { + it("uses the preset name as the canonical identity", async () => { const payload = settingsPayload(); payload.model_presets[0] = { ...payload.model_presets[0], @@ -104,11 +104,10 @@ describe("Settings models", () => { await togglePresetEditor("openai"); - expect(screen.getByText("Display name")).toBeInTheDocument(); - expect( - screen.getByText("Shown in the interface. The command name stays /model openai."), - ).toBeInTheDocument(); - expect(screen.getByDisplayValue("minimax")).toBeInTheDocument(); + const editor = screen.getByTestId("model-preset-editor"); + expect(within(editor).getByText("Preset name")).toBeInTheDocument(); + expect(within(editor).getByText("openai")).toBeInTheDocument(); + expect(within(editor).queryByText("minimax")).not.toBeInTheDocument(); }); it("keeps generation parameters collapsed until advanced options are opened", async () => { @@ -188,7 +187,7 @@ describe("Settings models", () => { "lg:max-w-6xl", "rounded-floating", ); - expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument(); + expect(within(editor).getByText("primary")).toBeInTheDocument(); const deleteButton = within(editor).getByRole("button", { name: "Delete" }); expect(deleteButton).toBeDisabled(); expect(deleteButton).toHaveAttribute("aria-describedby", "model-preset-delete-hint"); @@ -244,8 +243,9 @@ describe("Settings models", () => { ), ); await togglePresetEditor(); - fireEvent.change(screen.getByDisplayValue("Primary"), { - target: { value: "Primary draft" }, + fireEvent.click(screen.getByRole("button", { name: /Advanced options/ })); + fireEvent.change(screen.getByLabelText("Temperature"), { + target: { value: "0.4" }, }); const primaryRow = screen.getByTestId("model-call-order-row-primary"); const backupRow = screen.getByTestId("model-call-order-row-backup"); @@ -270,7 +270,7 @@ describe("Settings models", () => { }); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); - expect(screen.getByDisplayValue("Primary draft")).toBeInTheDocument(); + expect(screen.getByLabelText("Temperature")).toHaveValue(0.4); expect(screen.getByRole("button", { name: "Save preset" })).toBeEnabled(); }); @@ -430,7 +430,8 @@ describe("Settings models", () => { renderSettingsView({ initialSection: "models", initialSettings: payloadWithCodex }); const codexRow = await screen.findByTestId("model-call-order-row-codex"); - expect(codexRow).toHaveTextContent("Codex"); + expect(codexRow).toHaveTextContent("codex"); + expect(codexRow).not.toHaveTextContent("Codex"); expect(codexRow).toHaveTextContent("openai-codex/gpt-5.5"); expect(codexRow).toHaveTextContent("Disabled"); expect(codexRow).toHaveAttribute("draggable", "false"); @@ -461,7 +462,7 @@ describe("Settings models", () => { const { payload } = settingsPayloadWithBackup(); const writerPreset = { ...payload.model_presets[0], - name: "writer", + name: "Writer", label: "Writer", active: false, model: "openai/gpt-4o-mini", @@ -475,7 +476,7 @@ describe("Settings models", () => { }; const orderedPayload: SettingsPayload = { ...createdPayload, - model_call_order: ["primary", "backup", "writer"], + model_call_order: ["primary", "backup", "Writer"], created_model_preset: undefined, }; const fetchMock = vi.fn(async (input: RequestInfo | URL) => { @@ -528,15 +529,15 @@ describe("Settings models", () => { await waitFor(() => { expect(requestMutationMock).toHaveBeenLastCalledWith( "settings.model_call_order.update", - { order: ["primary", "backup", "writer"] }, + { order: ["primary", "backup", "Writer"] }, 20_000, ); }); - const writerRow = await screen.findByTestId("model-call-order-row-writer"); + const writerRow = await screen.findByTestId("model-call-order-row-Writer"); expect(writerRow).not.toHaveTextContent("Disabled"); expect(writerRow).not.toHaveTextContent(/Fallback/); expect(within(writerRow).getByRole("switch", { name: "Disable preset" })).toBeChecked(); - expect(screen.getByDisplayValue("Writer")).toBeInTheDocument(); + expect(screen.getAllByText("Writer").length).toBeGreaterThan(0); expect(screen.queryByRole("button", { name: "Save order" })).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index c454fec50..05ec25dd7 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -307,9 +307,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string { } const MODEL_PRESETS = [ - { name: "kimi", label: "Kimi", provider: "moonshot" }, - { name: "dflash", label: "DFlash", provider: "deepseek" }, - { name: "dspro", label: "DS Pro", provider: "deepseek" }, + { name: "kimi", provider: "moonshot" }, + { name: "dflash", provider: "deepseek" }, + { name: "dspro", provider: "deepseek" }, ]; function renderPresetComposer(variant: "thread" | "hero" = "thread") { @@ -317,7 +317,7 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") { render( , ); return { - badge: screen.getByRole("spinbutton", { name: "Kimi" }), + badge: screen.getByRole("spinbutton", { name: "kimi" }), onPresetChange, }; } @@ -604,7 +604,7 @@ describe("ThreadComposer", () => { expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true); expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true); const centeredPill = track.querySelector("[data-preset-offset='0']"); - expect(centeredPill).toHaveTextContent("Kimi"); + expect(centeredPill).toHaveTextContent("kimi"); expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" }); expect( track.querySelector("[data-preset-offset='1']"), @@ -615,13 +615,13 @@ describe("ThreadComposer", () => { pointerId: 7, pointerType: "mouse", }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("Kimi"); + expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("kimi"); fireEvent.pointerMove(badge, { clientY: 123, pointerId: 7, pointerType: "mouse", }); - expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("DS Pro"); + expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("dspro"); fireEvent.pointerUp(badge, { clientY: 123, pointerId: 7, diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 19774fca9..8bb3d7cde 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -609,7 +609,7 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("Fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); + expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument(); expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument(); }); @@ -645,19 +645,19 @@ describe("ThreadShell", () => { "preset-order", "/model fast", ); - expect(await screen.findByText("Fast")).toBeInTheDocument(); + expect(await screen.findByText("fast")).toBeInTheDocument(); fireEvent.keyDown( - screen.getByRole("spinbutton", { name: "Fast" }), + screen.getByRole("spinbutton", { name: "fast" }), { key: "End" }, ); expect(client.sendSystemCommand).toHaveBeenLastCalledWith( "preset-order", "/model extra", ); - expect(await screen.findByText("Extra")).toBeInTheDocument(); + expect(await screen.findByText("extra")).toBeInTheDocument(); rerender(view("fast")); - expect(await screen.findByText("Fast")).toBeInTheDocument(); + expect(await screen.findByText("fast")).toBeInTheDocument(); }); it("uses the backend-resolved provider for an auto session preset", async () => { @@ -692,7 +692,7 @@ describe("ThreadShell", () => { ), ); - expect(await screen.findByTitle("Fast · gpt-4 · Company Proxy")).toBeInTheDocument(); + expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument(); }); @@ -1070,7 +1070,7 @@ describe("ThreadShell", () => { await screen.findByRole("spinbutton", { name: "Default" }), { key: "ArrowDown" }, ); - expect(await screen.findByText("Fast")).toBeInTheDocument(); + expect(await screen.findByText("fast")).toBeInTheDocument(); expect(client.sendSystemCommand).not.toHaveBeenCalled(); fireEvent.change(screen.getByLabelText("Message input"), {