feat(webui): simplify model preset settings (#5061)
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user