mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
* feat(webui): refine output timeline and composer queue * feat(webui): add provider model picker * fix(webui): polish model settings and heartbeat checks * chore: keep heartbeat changes out of webui pr * refactor(webui): isolate settings routes * fix(providers): align minimax anthropic test * fix(providers): keep minimax anthropic base sdk-compatible * fix(providers): normalize anthropic base urls
464 lines
16 KiB
Python
464 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from nanobot.config.loader import load_config, save_config
|
|
from nanobot.config.schema import Config, ModelPresetConfig
|
|
from nanobot.webui.settings_api import (
|
|
WebUISettingsError,
|
|
_oauth_provider_status,
|
|
create_model_configuration,
|
|
provider_models_payload,
|
|
settings_payload,
|
|
update_agent_settings,
|
|
update_model_configuration,
|
|
update_network_safety_settings,
|
|
)
|
|
from nanobot.providers.registry import find_by_name
|
|
|
|
|
|
def test_create_model_configuration_writes_label_and_selects(
|
|
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.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(
|
|
{
|
|
"label": ["Fast writing"],
|
|
"provider": ["openai"],
|
|
"model": ["openai/gpt-4.1-mini"],
|
|
}
|
|
)
|
|
|
|
assert payload["agent"]["model_preset"] == "fast-writing"
|
|
assert payload["agent"]["model"] == "openai/gpt-4.1-mini"
|
|
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.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"
|
|
|
|
with pytest.raises(WebUISettingsError) as duplicate:
|
|
create_model_configuration(
|
|
{
|
|
"label": ["Fast writing"],
|
|
"provider": ["openai"],
|
|
"model": ["openai/gpt-4.1-mini"],
|
|
}
|
|
)
|
|
assert duplicate.value.status == 409
|
|
|
|
|
|
def test_create_model_configuration_rejects_unconfigured_provider(
|
|
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="provider is not configured"):
|
|
create_model_configuration(
|
|
{
|
|
"label": ["Deep"],
|
|
"provider": ["openai"],
|
|
"model": ["openai/gpt-4.1"],
|
|
}
|
|
)
|
|
|
|
|
|
def test_update_model_configuration_edits_named_preset_and_selects(
|
|
tmp_path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config = Config()
|
|
config.providers.openai.api_key = "sk-test"
|
|
config.model_presets["codex"] = ModelPresetConfig(
|
|
label="Old Codex",
|
|
provider="openai",
|
|
model="openai/gpt-4.1",
|
|
)
|
|
save_config(config, config_path)
|
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
monkeypatch.setattr(
|
|
"nanobot.webui.settings_api._oauth_provider_status",
|
|
lambda spec: {
|
|
"configured": spec.name == "openai_codex",
|
|
"account": "acct-test",
|
|
"expires_at": 123,
|
|
"login_supported": True,
|
|
},
|
|
)
|
|
|
|
payload = update_model_configuration(
|
|
{
|
|
"name": ["codex"],
|
|
"label": ["Codex"],
|
|
"provider": ["openai_codex"],
|
|
"model": ["openai-codex/gpt-5.5"],
|
|
}
|
|
)
|
|
|
|
assert payload["agent"]["model_preset"] == "codex"
|
|
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
|
|
saved = load_config(config_path)
|
|
assert saved.agents.defaults.model_preset == "codex"
|
|
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_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,
|
|
) -> 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="model configuration is required"):
|
|
update_model_configuration({"name": ["default"], "model": ["openai/gpt-4.1"]})
|
|
|
|
|
|
def test_settings_payload_includes_oauth_provider_status(
|
|
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)
|
|
|
|
def fake_oauth_status(spec):
|
|
if spec.name == "openai_codex":
|
|
return {
|
|
"configured": True,
|
|
"account": "acct-test",
|
|
"expires_at": 123,
|
|
"login_supported": True,
|
|
}
|
|
return {
|
|
"configured": False,
|
|
"account": None,
|
|
"expires_at": None,
|
|
"login_supported": True,
|
|
}
|
|
|
|
monkeypatch.setattr("nanobot.webui.settings_api._oauth_provider_status", fake_oauth_status)
|
|
|
|
payload = settings_payload()
|
|
providers = {row["name"]: row for row in payload["providers"]}
|
|
|
|
assert providers["openai_codex"]["auth_type"] == "oauth"
|
|
assert providers["openai_codex"]["configured"] is True
|
|
assert providers["openai_codex"]["oauth_account"] == "acct-test"
|
|
|
|
|
|
def test_settings_payload_includes_network_safety_fields(
|
|
tmp_path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config = Config()
|
|
config.tools.webui_allow_local_service_access = False
|
|
config.tools.ssrf_whitelist = ["100.64.0.0/10"]
|
|
save_config(config, config_path)
|
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
payload = settings_payload()
|
|
|
|
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
|
assert payload["advanced"]["allow_local_preview_access"] is False
|
|
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
|
assert payload["advanced"]["private_service_protection_enabled"] is True
|
|
assert payload["advanced"]["ssrf_whitelist_count"] == 1
|
|
|
|
|
|
def test_update_network_safety_settings_writes_local_service_flag(
|
|
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)
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
payload = update_network_safety_settings(
|
|
{
|
|
"webui_allow_local_service_access": ["false"],
|
|
"webui_default_access_mode": ["full"],
|
|
}
|
|
)
|
|
|
|
saved = load_config(config_path)
|
|
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert saved.tools.webui_allow_local_service_access is False
|
|
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
|
|
assert "allowLocalPreviewAccess" not in saved_raw["tools"]
|
|
assert payload["advanced"]["webui_allow_local_service_access"] is False
|
|
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
|
assert payload["requires_restart"] is True
|
|
|
|
|
|
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
|
|
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)
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
payload = update_network_safety_settings({"webui_default_access_mode": ["restricted"]})
|
|
|
|
assert payload["advanced"]["webui_default_access_mode"] == "default"
|
|
|
|
|
|
def test_update_network_safety_settings_default_access_is_webui_only(
|
|
tmp_path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
save_config(Config(), config_path)
|
|
before = config_path.read_text(encoding="utf-8")
|
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
|
|
|
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
|
|
|
|
saved = load_config(config_path)
|
|
assert config_path.read_text(encoding="utf-8") == before
|
|
assert saved.tools.restrict_to_workspace is False
|
|
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
|
assert payload["requires_restart"] is False
|
|
|
|
|
|
def test_openai_codex_oauth_status_uses_available_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def fake_get_token():
|
|
return type(
|
|
"Token",
|
|
(),
|
|
{
|
|
"access": "access-token",
|
|
"refresh": "refresh-token",
|
|
"expires": 2_000_000_000_000,
|
|
"account_id": "acct-codex",
|
|
},
|
|
)()
|
|
|
|
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
|
|
|
status = _oauth_provider_status(find_by_name("openai_codex"))
|
|
|
|
assert status["configured"] is True
|
|
assert status["account"] == "acct-codex"
|
|
|
|
|
|
def test_openai_codex_oauth_status_rejects_unavailable_token(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def fake_get_token():
|
|
raise RuntimeError("refresh failed")
|
|
|
|
monkeypatch.setattr("oauth_cli_kit.get_token", fake_get_token)
|
|
|
|
status = _oauth_provider_status(find_by_name("openai_codex"))
|
|
|
|
assert status["configured"] is False
|
|
assert status["account"] is None
|
|
|
|
|
|
def test_provider_models_payload_fetches_openai_compatible_models(
|
|
tmp_path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config = Config()
|
|
config.providers.deepseek.api_key = "sk-test"
|
|
save_config(config, config_path)
|
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
|
|
def fake_get(url: str, **kwargs):
|
|
assert url == "https://api.deepseek.com/models"
|
|
assert kwargs["headers"]["Authorization"] == "Bearer sk-test"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"data": [
|
|
{"id": "deepseek-chat", "owned_by": "deepseek"},
|
|
{"id": "deepseek-reasoner", "context_window": 65536},
|
|
]
|
|
},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
|
|
|
|
payload = provider_models_payload({"provider": ["deepseek"]})
|
|
|
|
assert payload["status"] == "available"
|
|
assert payload["catalog_kind"] == "official"
|
|
assert payload["model_count"] == 2
|
|
assert payload["models"][0]["id"] == "deepseek-chat"
|
|
assert payload["models"][1]["context_window"] == 65536
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("api_base", "expected_url"),
|
|
[
|
|
("https://api.minimaxi.com/anthropic", "https://api.minimaxi.com/anthropic/v1/models"),
|
|
("https://api.minimaxi.com/anthropic/v1", "https://api.minimaxi.com/anthropic/v1/models"),
|
|
],
|
|
)
|
|
def test_provider_models_payload_fetches_minimax_anthropic_models(
|
|
api_base: str,
|
|
expected_url: str,
|
|
tmp_path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
config_path = tmp_path / "config.json"
|
|
config = Config()
|
|
config.providers.minimax_anthropic.api_key = "sk-test"
|
|
config.providers.minimax_anthropic.api_base = api_base
|
|
save_config(config, config_path)
|
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
|
|
def fake_get(url: str, **kwargs):
|
|
assert url == expected_url
|
|
assert kwargs["headers"]["X-Api-Key"] == "sk-test"
|
|
assert "Authorization" not in kwargs["headers"]
|
|
return httpx.Response(
|
|
200,
|
|
json={"data": [{"id": "MiniMax-M2.7-highspeed"}]},
|
|
request=httpx.Request("GET", url),
|
|
)
|
|
|
|
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
|
|
|
|
payload = provider_models_payload({"provider": ["minimax_anthropic"]})
|
|
|
|
assert payload["status"] == "available"
|
|
assert payload["catalog_kind"] == "official"
|
|
assert payload["models"] == [
|
|
{
|
|
"id": "MiniMax-M2.7-highspeed",
|
|
"label": None,
|
|
"owned_by": None,
|
|
"context_window": None,
|
|
}
|
|
]
|
|
|
|
|
|
def test_provider_models_payload_requires_gateway_key(
|
|
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 = provider_models_payload({"provider": ["openrouter"]})
|
|
|
|
assert payload["status"] == "not_configured"
|
|
assert payload["models"] == []
|
|
|
|
|
|
def test_create_model_configuration_accepts_configured_oauth_provider(
|
|
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)
|
|
monkeypatch.setattr(
|
|
"nanobot.webui.settings_api._oauth_provider_status",
|
|
lambda spec: {
|
|
"configured": spec.name == "openai_codex",
|
|
"account": "acct-test",
|
|
"expires_at": 123,
|
|
"login_supported": True,
|
|
},
|
|
)
|
|
|
|
payload = create_model_configuration(
|
|
{
|
|
"label": ["Codex"],
|
|
"provider": ["openai_codex"],
|
|
"model": ["openai-codex/gpt-5.1-codex"],
|
|
}
|
|
)
|
|
|
|
assert payload["agent"]["model_preset"] == "codex"
|
|
saved = load_config(config_path)
|
|
assert saved.model_presets["codex"].provider == "openai_codex"
|