feat(webui): simplify model preset settings (#5061)

This commit is contained in:
chengyongru
2026-07-24 00:55:06 +08:00
committed by GitHub
parent d993c81f08
commit aae259c790
29 changed files with 4548 additions and 1182 deletions
+344 -18
View File
@@ -8,7 +8,7 @@ import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
@@ -18,13 +18,17 @@ from nanobot.webui.settings_api import (
_reasoning_effort_values_for,
complete_oauth_provider,
create_model_configuration,
create_provider_settings,
delete_model_configuration,
login_oauth_provider,
logout_oauth_provider,
migrate_model_configurations,
provider_models_payload,
settings_payload,
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_model_call_order,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
@@ -186,7 +190,7 @@ def _dynamic_provider_config(
return Config.model_validate(raw_config)
def test_create_model_configuration_writes_label_and_selects(
def test_create_model_configuration_writes_label_without_changing_call_order(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -206,13 +210,14 @@ def test_create_model_configuration_writes_label_and_selects(
}
)
assert payload["agent"]["model_preset"] == "fast-writing"
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
assert payload["agent"]["model_preset"] == "default"
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"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "fast-writing"
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"
@@ -244,8 +249,8 @@ def test_create_model_configuration_accepts_dynamic_custom_provider(
}
)
assert payload["agent"]["model_preset"] == "tenant-model"
assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME
assert payload["agent"]["model_preset"] == "default"
assert payload["created_model_preset"] == "tenant-model"
saved = load_config(config_path)
assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME
assert saved.model_presets["tenant-model"].model == "gpt-4o-mini"
@@ -294,7 +299,7 @@ def test_create_model_configuration_rejects_unconfigured_provider(
)
def test_update_model_configuration_edits_named_preset_and_selects(
def test_update_model_configuration_edits_named_preset_without_selecting(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -327,15 +332,178 @@ def test_update_model_configuration_edits_named_preset_and_selects(
}
)
assert payload["agent"]["model_preset"] == "codex"
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
assert payload["agent"]["model_preset"] == "default"
assert payload["agent"]["model"] == "anthropic/claude-opus-4-5"
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "codex"
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"
def test_settings_payload_exposes_named_model_call_order(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
}
config.agents.defaults.model_preset = "primary"
config.agents.defaults.fallback_models = ["backup", "backup"]
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
assert payload["model_call_order"] == ["primary", "backup", "backup"]
assert payload["model_call_order_editable"] is True
def test_update_model_call_order_sets_primary_and_fallbacks(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"primary": ModelPresetConfig(model="openai/gpt-4.1", provider="openai"),
"backup": ModelPresetConfig(model="anthropic/claude-sonnet-4", provider="anthropic"),
}
config.agents.defaults.model_preset = "primary"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_model_call_order({"order": [json.dumps(["backup", "primary"])]})
assert payload["model_call_order"] == ["backup", "primary"]
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "backup"
assert saved.agents.defaults.fallback_models == ["primary"]
def test_update_model_call_order_requires_named_primary(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets["backup"] = ModelPresetConfig(model="openai/gpt-4.1-mini")
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError) as error:
update_model_call_order({"order": [json.dumps(["backup"])]})
assert error.value.status == 409
assert load_config(config_path).agents.defaults.model_preset is None
def test_migrate_model_configurations_preserves_legacy_chain(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.agents.defaults.model = "openai/gpt-4o"
config.agents.defaults.provider = "openai"
config.agents.defaults.max_tokens = 4096
config.agents.defaults.temperature = 0.25
config.agents.defaults.fallback_models = [
InlineFallbackConfig(
model="anthropic/claude-sonnet-4",
provider="anthropic",
)
]
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
legacy_payload = settings_payload()
assert legacy_payload["model_call_order"] == []
assert legacy_payload["model_call_order_editable"] is False
payload = migrate_model_configurations()
assert payload["model_call_order_editable"] is True
assert payload["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
saved = load_config(config_path)
assert saved.agents.defaults.model_preset == "gpt-4o"
assert saved.agents.defaults.fallback_models == ["claude-sonnet-4"]
assert saved.model_presets["gpt-4o"].temperature == 0.25
assert saved.model_presets["claude-sonnet-4"].max_tokens == 4096
assert saved.model_presets["claude-sonnet-4"].temperature == 0.25
repeated = migrate_model_configurations()
assert repeated["model_call_order"] == ["gpt-4o", "claude-sonnet-4"]
assert set(load_config(config_path).model_presets) == {"gpt-4o", "claude-sonnet-4"}
def test_model_configuration_advanced_options_round_trip(
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)
created = create_model_configuration(
{
"label": ["Reasoning"],
"provider": ["openai"],
"model": ["openai/o3"],
"max_tokens": ["16384"],
"context_window_tokens": ["262144"],
"temperature": ["0.4"],
"reasoning_effort": ["high"],
}
)
row = next(row for row in created["model_presets"] if row["name"] == "reasoning")
assert row["max_tokens"] == 16384
assert row["context_window_tokens"] == 262144
assert row["temperature"] == 0.4
assert row["reasoning_effort"] == "high"
updated = update_model_configuration(
{
"name": ["reasoning"],
"max_tokens": ["8192"],
"temperature": ["0"],
"reasoning_effort": [""],
}
)
row = next(row for row in updated["model_presets"] if row["name"] == "reasoning")
assert row["max_tokens"] == 8192
assert row["temperature"] == 0
assert row["reasoning_effort"] is None
def test_delete_model_configuration_requires_removing_it_from_call_order(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.model_presets = {
"primary": ModelPresetConfig(model="openai/gpt-4.1"),
"spare": ModelPresetConfig(model="openai/gpt-4.1-mini"),
}
config.agents.defaults.model_preset = "primary"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError) as referenced:
delete_model_configuration({"name": ["primary"]})
assert referenced.value.status == 409
payload = delete_model_configuration({"name": ["spare"]})
assert {row["name"] for row in payload["model_presets"]} == {"default", "primary"}
assert "spare" not in load_config(config_path).model_presets
def test_update_provider_settings_updates_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -361,6 +529,161 @@ def test_update_provider_settings_updates_dynamic_custom_provider(
assert dynamic_provider.api_key == "sk-test"
def test_create_provider_settings_persists_custom_advanced_options(
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)
payload = create_provider_settings(
{
"name": ["Company Gateway"],
"apiBase": ["https://gateway.example/v1"],
"apiKey": ["sk-company"],
"proxy": ["http://127.0.0.1:7890"],
"extraHeaders": [json.dumps({"X-Tenant": "engineering"})],
"extraBody": [json.dumps({"service_tier": "priority"})],
"extraQuery": [json.dumps({"api-version": "2026-01-01"})],
"thinkingStyle": ["enable_thinking"],
}
)
provider_name = payload["created_provider"]
row = next(provider for provider in payload["providers"] if provider["name"] == provider_name)
assert row["label"] == "Company Gateway"
assert row["is_custom"] is True
assert row["advanced_fields"] == [
"extra_headers",
"extra_body",
"extra_query",
"proxy",
"thinking_style",
]
assert row["extra_headers"] == {"X-Tenant": "engineering"}
assert row["extra_body"] == {"service_tier": "priority"}
assert row["extra_query"] == {"api-version": "2026-01-01"}
saved = load_config(config_path).providers.model_extra[provider_name]
assert saved.display_name == "Company Gateway"
assert saved.api_key == "sk-company"
assert saved.api_base == "https://gateway.example/v1"
assert saved.proxy == "http://127.0.0.1:7890"
assert saved.extra_headers == {"X-Tenant": "engineering"}
assert saved.extra_body == {"service_tier": "priority"}
assert saved.extra_query == {"api-version": "2026-01-01"}
assert saved.thinking_style == "enable_thinking"
def test_provider_settings_redacts_and_preserves_structured_secrets(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openai.api_key = "sk-openai"
config.providers.openai.extra_headers = {
"Authorization": "Bearer header-secret",
"X-Trace": "visible",
}
config.providers.openai.extra_body = {
"access_token": "body-secret",
"metadata": {
"client_secret": "nested-secret",
"label": "visible",
},
}
config.providers.openai.extra_query = {
"api_key": "query-secret",
"api-version": "2026-01-01",
}
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
row = next(provider for provider in payload["providers"] if provider["name"] == "openai")
serialized_row = json.dumps(row, ensure_ascii=False)
assert "header-secret" not in serialized_row
assert "body-secret" not in serialized_row
assert "nested-secret" not in serialized_row
assert "query-secret" not in serialized_row
assert row["extra_headers"]["X-Trace"] == "visible"
assert row["extra_body"]["metadata"]["label"] == "visible"
assert row["extra_query"]["api-version"] == "2026-01-01"
row["extra_headers"]["X-Trace"] = "updated"
row["extra_body"]["access_token"] = "replacement-secret"
row["extra_body"]["metadata"]["label"] = "updated"
row["extra_query"]["api-version"] = "2026-07-24"
update_provider_settings(
{
"provider": ["openai"],
"extraHeaders": [json.dumps(row["extra_headers"], ensure_ascii=False)],
"extraBody": [json.dumps(row["extra_body"], ensure_ascii=False)],
"extraQuery": [json.dumps(row["extra_query"], ensure_ascii=False)],
}
)
saved = load_config(config_path).providers.openai
assert saved.extra_headers == {
"Authorization": "Bearer header-secret",
"X-Trace": "updated",
}
assert saved.extra_body == {
"access_token": "replacement-secret",
"metadata": {
"client_secret": "nested-secret",
"label": "updated",
},
}
assert saved.extra_query == {
"api_key": "query-secret",
"api-version": "2026-07-24",
}
def test_update_provider_settings_persists_provider_specific_advanced_options(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openai.api_key = "sk-openai"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
update_provider_settings(
{
"provider": ["openai"],
"apiType": ["responses"],
"proxy": ["http://127.0.0.1:7890"],
"extraHeaders": [json.dumps({"X-Trace": "enabled"})],
"extraBody": [json.dumps({"service_tier": "priority"})],
"extraQuery": [json.dumps({"trace": "true"})],
}
)
update_provider_settings(
{
"provider": ["bedrock"],
"region": ["us-west-2"],
"profile": ["production"],
"extraBody": [json.dumps({"guardrailIdentifier": "guardrail-1"})],
}
)
saved = load_config(config_path)
assert saved.providers.openai.api_type == "responses"
assert saved.providers.openai.proxy == "http://127.0.0.1:7890"
assert saved.providers.openai.extra_headers == {"X-Trace": "enabled"}
assert saved.providers.openai.extra_body == {"service_tier": "priority"}
assert saved.providers.openai.extra_query == {"trace": "true"}
assert saved.providers.bedrock.region == "us-west-2"
assert saved.providers.bedrock.profile == "production"
assert saved.providers.bedrock.extra_body == {"guardrailIdentifier": "guardrail-1"}
@pytest.mark.parametrize(
("provider_name", "config_attr"),
[
@@ -414,7 +737,7 @@ def test_update_provider_settings_keeps_oauth_credentials_read_only(
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="only supports proxy settings"):
with pytest.raises(WebUISettingsError, match="only supports proxy and extra_body settings"):
update_provider_settings({"provider": ["openai_codex"], "apiKey": ["not-allowed"]})
@@ -434,7 +757,7 @@ def test_update_agent_settings_accepts_context_window_options(
assert saved.agents.defaults.context_window_tokens == 200000
def test_update_model_configuration_accepts_context_window_options(
def test_update_model_configuration_preserves_custom_context_windows(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -451,13 +774,14 @@ def test_update_model_configuration_accepts_context_window_options(
payload = update_model_configuration(
{
"name": ["codex"],
"context_window_tokens": ["1048576"],
"context_window_tokens": ["128000"],
}
)
assert payload["agent"]["context_window_tokens"] == 1048576
rows = {row["name"]: row for row in payload["model_presets"]}
assert rows["codex"]["context_window_tokens"] == 128000
saved = load_config(config_path)
assert saved.model_presets["codex"].context_window_tokens == 1048576
assert saved.model_presets["codex"].context_window_tokens == 128000
def test_update_context_window_rejects_unknown_values(
@@ -1490,7 +1814,8 @@ def test_create_model_configuration_accepts_configured_oauth_provider(
}
)
assert payload["agent"]["model_preset"] == "codex"
assert payload["agent"]["model_preset"] == "default"
assert payload["created_model_preset"] == "codex"
saved = load_config(config_path)
assert saved.model_presets["codex"].provider == "openai_codex"
@@ -1577,7 +1902,8 @@ def test_create_model_configuration_accepts_azure_openai_aad_mode(
}
)
assert payload["agent"]["model_preset"] == "azure-aad"
assert payload["agent"]["model_preset"] == "default"
assert payload["created_model_preset"] == "azure-aad"
saved = load_config(config_path)
assert saved.model_presets["azure-aad"].provider == "azure_openai"
assert saved.model_presets["azure-aad"].model == "my-deployment"