Files
nanobot/tests/webui/test_settings_api.py
T

1938 lines
67 KiB
Python
Raw Normal View History

from __future__ import annotations
2026-07-05 14:16:39 +08:00
import builtins
import json
from types import SimpleNamespace
import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfig
2026-06-01 14:03:43 +00:00
from nanobot.providers.registry import find_by_name
from nanobot.webui.settings_api import (
WebUISettingsError,
2026-07-13 13:11:46 +08:00
_docs_version,
_model_catalog_kind,
_oauth_provider_status,
2026-07-17 16:57:34 +08:00
_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,
2026-05-29 04:23:30 +08:00
update_agent_settings,
2026-07-13 13:11:46 +08:00
update_api_settings,
update_model_call_order,
update_model_configuration,
update_network_safety_settings,
update_provider_settings,
update_transcription_settings,
update_web_search_settings,
)
DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
2026-07-21 11:25:02 +08:00
def test_settings_payload_propagates_preset_resolution_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = Config()
monkeypatch.setattr("nanobot.webui.settings_api.load_config", lambda: config)
monkeypatch.setattr(
Config,
"resolve_preset",
lambda _self: (_ for _ in ()).throw(RuntimeError("invalid preset")),
)
with pytest.raises(RuntimeError, match="invalid preset"):
settings_payload()
2026-07-13 13:11:46 +08:00
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3") == "0.2.3"
assert _docs_version("0.2.3.post1") == "0.2.3.post1"
assert _docs_version("0.2.3.dev0") == "latest"
assert _docs_version("0.2.3+editable") == "latest"
2026-07-17 16:57:34 +08:00
def test_kimi_k3_only_offers_supported_reasoning_effort_values() -> None:
assert _reasoning_effort_values_for("moonshot", "kimi-k3") == ["", "max"]
2026-07-13 13:11:46 +08:00
def test_settings_payload_includes_versioned_docs(
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.__version__", "0.2.3")
payload = settings_payload()
assert payload["docs"] == {
"version": "0.2.3",
"base_url": "https://nanobot.wiki/docs/0.2.3",
"chat_apps_url": "https://nanobot.wiki/docs/0.2.3/getting-started/chat-apps",
"latest_url": "https://nanobot.wiki/docs/latest",
}
def test_settings_payload_includes_relocated_capabilities(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.api.port = 9910
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "secret")
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "public")
payload = settings_payload()
assert payload["api"]["port"] == 9910
assert payload["api"]["api_key_hint"] is None
assert payload["observability"]["provider"] == "langfuse"
assert payload["observability"]["configured"] is True
2026-07-23 00:14:25 +08:00
def test_settings_payload_exposes_modelscope_image_model(
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 = settings_payload()
providers = {row["name"]: row for row in payload["image_generation"]["providers"]}
assert providers["modelscope"]["models"] == ["Qwen/Qwen-Image-2512"]
assert providers["modelscope"]["default_model"] == "Qwen/Qwen-Image-2512"
2026-07-13 13:11:46 +08:00
def test_update_api_settings_requires_key_for_network_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)
with pytest.raises(WebUISettingsError, match="API key"):
update_api_settings({"host": ["0.0.0.0"], "port": ["8900"]})
payload = update_api_settings({
"host": ["0.0.0.0"],
"port": ["9900"],
"api_key": ["secret-token"],
})
saved = load_config(config_path)
assert saved.api.host == "0.0.0.0"
assert saved.api.port == 9900
assert saved.api.api_key == "secret-token"
assert payload["api"]["api_key_hint"]
def test_update_api_settings_requires_key_for_specific_network_interface(
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="API key"):
update_api_settings({"host": ["192.168.1.10"], "port": ["8900"]})
def test_update_api_settings_allows_alternate_loopback_without_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)
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
assert load_config(config_path).api.host == "127.0.0.2"
def _dynamic_provider_config(
*,
api_base: str = DYNAMIC_PROVIDER_API_BASE,
defaults: bool = False,
) -> Config:
raw_config = {
"providers": {
DYNAMIC_PROVIDER_NAME: {
"apiBase": api_base,
}
}
}
if defaults:
raw_config["agents"] = {
"defaults": {
"provider": DYNAMIC_PROVIDER_NAME,
"model": "gpt-4o-mini",
}
}
return Config.model_validate(raw_config)
def test_create_model_configuration_writes_label_without_changing_call_order(
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"] == "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 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"
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_accepts_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(_dynamic_provider_config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = create_model_configuration(
{
"label": ["Tenant model"],
"provider": [DYNAMIC_PROVIDER_NAME],
"model": ["gpt-4o-mini"],
}
)
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"
def test_create_model_configuration_rejects_dynamic_custom_provider_without_api_base(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {
DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test",
}
}
})
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": ["Tenant model"],
"provider": [DYNAMIC_PROVIDER_NAME],
"model": ["gpt-4o-mini"],
}
)
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_without_selecting(
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"] == "default"
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"
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,
) -> None:
config_path = tmp_path / "config.json"
save_config(_dynamic_provider_config(api_base="https://old.example/v1"), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_provider_settings(
{
"provider": [DYNAMIC_PROVIDER_NAME],
"apiBase": ["https://new.example/v1"],
"apiKey": ["sk-test"],
}
)
providers = {row["name"]: row for row in payload["providers"]}
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == "https://new.example/v1"
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••"
saved = load_config(config_path)
dynamic_provider = saved.providers.model_extra[DYNAMIC_PROVIDER_NAME]
assert dynamic_provider.api_base == "https://new.example/v1"
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"),
[
("openai_codex", "openai_codex"),
("xai_grok", "xai_grok"),
],
)
def test_update_provider_settings_updates_and_clears_oauth_proxy(
provider_name: str,
config_attr: str,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
getattr(config.providers, config_attr).proxy = "http://127.0.0.1:7000"
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": False,
"account": None,
"expires_at": None,
"login_supported": True,
},
)
payload = update_provider_settings(
{"provider": [provider_name], "proxy": [" http://127.0.0.1:7890 "]}
)
providers = {row["name"]: row for row in payload["providers"]}
assert providers[provider_name]["proxy"] == "http://127.0.0.1:7890"
assert getattr(load_config(config_path).providers, config_attr).proxy == (
"http://127.0.0.1:7890"
)
cleared = update_provider_settings({"provider": [provider_name], "proxy": [" "]})
providers = {row["name"]: row for row in cleared["providers"]}
assert providers[provider_name]["proxy"] is None
assert getattr(load_config(config_path).providers, config_attr).proxy is None
def test_update_provider_settings_keeps_oauth_credentials_read_only(
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="only supports proxy and extra_body settings"):
update_provider_settings({"provider": ["openai_codex"], "apiKey": ["not-allowed"]})
2026-05-29 04:23:30 +08:00
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)
2026-06-22 16:27:30 +08:00
payload = update_agent_settings({"context_window_tokens": ["200000"]})
2026-05-29 04:23:30 +08:00
2026-06-22 16:27:30 +08:00
assert payload["agent"]["context_window_tokens"] == 200000
2026-05-29 04:23:30 +08:00
saved = load_config(config_path)
2026-06-22 16:27:30 +08:00
assert saved.agents.defaults.context_window_tokens == 200000
2026-05-29 04:23:30 +08:00
def test_update_model_configuration_preserves_custom_context_windows(
2026-05-29 04:23:30 +08:00
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": ["128000"],
2026-05-29 04:23:30 +08:00
}
)
rows = {row["name"]: row for row in payload["model_presets"]}
assert rows["codex"]["context_window_tokens"] == 128000
2026-05-29 04:23:30 +08:00
saved = load_config(config_path)
assert saved.model_presets["codex"].context_window_tokens == 128000
2026-05-29 04:23:30 +08:00
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)
2026-06-22 16:27:30 +08:00
with pytest.raises(
WebUISettingsError,
match="context_window_tokens must be 65536, 200000, 262144, 500000, or 1048576",
2026-06-22 16:27:30 +08:00
):
2026-05-29 04:23:30 +08:00
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_dynamic_custom_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(_dynamic_provider_config(defaults=True), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {row["name"]: row for row in payload["providers"]}
assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME
assert payload["agent"]["resolved_provider"] == DYNAMIC_PROVIDER_NAME
assert providers[DYNAMIC_PROVIDER_NAME]["configured"] is True
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_required"] is False
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
def test_settings_payload_resolves_provider_for_each_auto_preset(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = _dynamic_provider_config()
config.model_presets["fast"] = ModelPresetConfig(
provider="auto",
model=f"{DYNAMIC_PROVIDER_NAME}/gpt-4",
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
presets = {row["name"]: row for row in payload["model_presets"]}
assert presets["fast"]["provider"] == "auto"
assert presets["fast"]["resolved_provider"] == DYNAMIC_PROVIDER_NAME
2026-07-13 13:11:46 +08:00
def test_settings_payload_groups_opencode_compatibility_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
opencode_rows = [row for row in payload["providers"] if row["label"].startswith("OpenCode")]
assert [(row["name"], row["label"]) for row in opencode_rows] == [
("opencode", "OpenCode Zen"),
("opencode_go", "OpenCode Go"),
]
def test_settings_payload_keeps_configured_opencode_legacy_alias(tmp_path, monkeypatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {"opencodeZen": {"apiKey": "legacy-key"}},
"agents": {
"defaults": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
}
},
})
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
zen_rows = [row for row in payload["providers"] if row["label"] == "OpenCode Zen"]
assert len(zen_rows) == 1
assert zen_rows[0]["name"] == "opencode_zen"
assert zen_rows[0]["configured"] is True
def test_settings_payload_marks_dynamic_custom_provider_without_api_base_unconfigured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate({
"providers": {
DYNAMIC_PROVIDER_NAME: {
"apiKey": "sk-test",
}
}
})
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {row["name"]: row for row in payload["providers"]}
assert providers[DYNAMIC_PROVIDER_NAME]["configured"] is False
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••"
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] is None
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
2026-06-10 15:50:24 +08:00
def test_settings_payload_includes_exec_path_flags(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.exec.path_prepend = "/venv/bin"
config.tools.exec.path_append = "/usr/sbin"
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"]["exec_path_prepend_set"] is True
assert payload["advanced"]["exec_path_append_set"] is True
def test_update_web_search_settings_accepts_keenable_without_api_key(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.web.search.provider = "brave"
config.tools.web.search.api_key = "brave-key"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_web_search_settings({"provider": ["keenable"]})
saved = load_config(config_path)
assert saved.tools.web.search.provider == "keenable"
assert saved.tools.web.search.api_key == ""
option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable")
assert option["credential"] == "optional_api_key"
def test_update_web_search_settings_can_clear_optional_api_key(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.web.search.provider = "keenable"
config.tools.web.search.api_key = "keen-key"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
update_web_search_settings({"provider": ["keenable"], "api_key": [""]})
saved = load_config(config_path)
assert saved.tools.web.search.provider == "keenable"
assert saved.tools.web.search.api_key == ""
def test_settings_payload_includes_effective_transcription_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.channels.transcription_provider = "openai"
config.channels.transcription_language = "en"
config.providers.openai.api_key = "sk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
assert payload["transcription"]["enabled"] is True
assert payload["transcription"]["provider"] == "openai"
assert payload["transcription"]["provider_configured"] is True
assert payload["transcription"]["model"] == "whisper-1"
assert payload["transcription"]["language"] == "en"
def test_settings_payload_exposes_openrouter_transcription_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openrouter.api_key = "sk-or-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {provider["name"]: provider for provider in payload["transcription"]["providers"]}
assert providers["openrouter"]["label"] == "OpenRouter"
assert providers["openrouter"]["configured"] is True
def test_settings_payload_exposes_siliconflow_transcription_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.siliconflow.api_key = "sf-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {provider["name"]: provider for provider in payload["transcription"]["providers"]}
assert providers["siliconflow"]["label"] == "SiliconFlow"
assert providers["siliconflow"]["configured"] is True
assert providers["siliconflow"]["default_api_base"] == "https://api.siliconflow.cn/v1"
def test_settings_payload_exposes_xiaomi_mimo_transcription_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.xiaomi_mimo.api_key = "mimo-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
providers = {provider["name"]: provider for provider in payload["transcription"]["providers"]}
assert providers["xiaomi_mimo"]["label"] == "Xiaomi MIMO"
assert providers["xiaomi_mimo"]["configured"] is True
def test_settings_payload_exposes_assemblyai_transcription_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.transcription.provider = "assemblyai"
config.providers.assemblyai.api_key = "aai-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
assert payload["transcription"]["provider"] == "assemblyai"
assert payload["transcription"]["provider_configured"] is True
providers = {provider["name"]: provider for provider in payload["transcription"]["providers"]}
assert providers["assemblyai"]["label"] == "AssemblyAI"
assert providers["assemblyai"]["configured"] is True
assert providers["assemblyai"]["default_api_base"] == "https://api.assemblyai.com/v2"
provider_rows = {provider["name"]: provider for provider in payload["providers"]}
assert provider_rows["assemblyai"]["configured"] is True
assert provider_rows["assemblyai"]["model_selectable"] is False
def test_model_configuration_rejects_transcription_only_provider(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.assemblyai.api_key = "aai-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
with pytest.raises(WebUISettingsError, match="does not support chat models"):
create_model_configuration(
{
"label": ["Voice only"],
"provider": ["assemblyai"],
"model": ["universal-3-pro"],
}
)
def test_update_transcription_settings_writes_top_level_only(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.channels.transcription_provider = "openai"
config.channels.transcription_language = "en"
config.providers.groq.api_key = "gsk-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_transcription_settings(
{
"enabled": ["true"],
"provider": ["groq"],
"model": ["whisper-large-v3-turbo"],
"language": ["ko"],
"maxDurationSec": ["90"],
"maxUploadMb": ["20"],
}
)
saved = load_config(config_path)
assert saved.channels.transcription_provider == "openai"
assert saved.channels.transcription_language == "en"
assert saved.transcription.enabled is True
assert saved.transcription.provider == "groq"
assert saved.transcription.model == "whisper-large-v3-turbo"
assert saved.transcription.language == "ko"
assert saved.transcription.max_duration_sec == 90
assert saved.transcription.max_upload_mb == 20
assert payload["transcription"]["provider"] == "groq"
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_accepts_openrouter(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.openrouter.api_key = "sk-or-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_transcription_settings(
{
"provider": ["openrouter"],
"model": ["nvidia/parakeet-tdt-0.6b-v3"],
}
)
saved = load_config(config_path)
assert saved.transcription.provider == "openrouter"
assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3"
assert payload["transcription"]["provider"] == "openrouter"
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_accepts_xiaomi_mimo(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.xiaomi_mimo.api_key = "mimo-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_transcription_settings(
{
"provider": ["xiaomi_mimo"],
"model": ["mimo-v2.5-asr"],
"language": ["zh"],
}
)
saved = load_config(config_path)
assert saved.transcription.provider == "xiaomi_mimo"
assert saved.transcription.model == "mimo-v2.5-asr"
assert saved.transcription.language == "zh"
assert payload["transcription"]["provider"] == "xiaomi_mimo"
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_accepts_assemblyai(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.providers.assemblyai.api_key = "aai-test"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = update_transcription_settings(
{
"provider": ["assemblyai"],
"model": ["universal-3-pro"],
}
)
saved = load_config(config_path)
assert saved.transcription.provider == "assemblyai"
assert saved.transcription.model == "universal-3-pro"
assert payload["transcription"]["provider"] == "assemblyai"
assert payload["transcription"]["provider_configured"] is True
def test_update_transcription_settings_validates_language(
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="transcription language"):
update_transcription_settings({"language": ["en-US"]})
def test_settings_payload_includes_token_usage_summary(
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)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 10, "completion_tokens": 5})
payload = settings_payload()
assert payload["usage"]["total_tokens_30d"] == 15
assert payload["usage"]["total_tokens"] == 15
assert payload["usage"]["peak_day_tokens"] == 15
assert payload["usage"]["current_streak_days"] == 1
assert payload["usage"]["longest_streak_days"] == 1
assert payload["usage"]["active_days_30d"] == 1
assert payload["usage"]["requests_30d"] == 1
def test_settings_usage_payload_returns_lightweight_token_usage(
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)
monkeypatch.setattr("nanobot.webui.token_usage.get_webui_dir", lambda: tmp_path / "webui")
from nanobot.webui.token_usage import record_token_usage
record_token_usage({"prompt_tokens": 20, "completion_tokens": 2})
payload = settings_usage_payload()
assert payload["total_tokens"] == 22
assert payload["requests_30d"] == 1
assert "agent" not in payload
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:
2026-06-18 16:57:53 +08:00
token = type(
"Token",
(),
{
"access": "access-token",
"refresh": "refresh-token",
"expires": 2_000_000_000_000,
"account_id": "acct-codex",
},
)()
monkeypatch.setattr("oauth_cli_kit.storage.FileTokenStorage.load", lambda _self: 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_uses_refreshable_expired_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = type(
"Token",
(),
{
"access": "access-token",
"refresh": "refresh-token",
"expires": 1,
"account_id": "acct-codex",
},
)()
monkeypatch.setattr("oauth_cli_kit.storage.FileTokenStorage.load", lambda _self: token)
status = _oauth_provider_status(find_by_name("openai_codex"))
assert status["configured"] is True
assert status["expires_at"] == 1
def test_openai_codex_oauth_status_rejects_unavailable_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
2026-06-18 16:57:53 +08:00
def fake_load(_self):
raise RuntimeError("refresh failed")
2026-06-18 16:57:53 +08:00
monkeypatch.setattr("oauth_cli_kit.storage.FileTokenStorage.load", fake_load)
status = _oauth_provider_status(find_by_name("openai_codex"))
assert status["configured"] is False
assert status["account"] is None
def test_xai_grok_status_accepts_refreshable_login(
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = SimpleNamespace(
access="access-token",
refresh="refresh-token",
expires=1,
account_id="user@example.com",
)
monkeypatch.setattr(
"nanobot.providers.xai_oauth.get_xai_oauth_login_status",
lambda: token,
)
status = _oauth_provider_status(find_by_name("xai_grok"))
assert status == {
"configured": True,
"account": "user@example.com",
"expires_at": 1,
"login_supported": True,
}
def test_openai_codex_oauth_login_passes_configured_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_TEST}"}}}),
config_path,
)
monkeypatch.setenv("CODEX_PROXY_TEST", proxy)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
import oauth_cli_kit
captured: dict[str, str | None] = {}
def fake_get_token(*, proxy=None):
captured["get_proxy"] = proxy
raise RuntimeError("no-token")
def fake_login(*, print_fn, prompt_fn, proxy=None):
captured["login_proxy"] = proxy
return SimpleNamespace(access="access-token", account_id="acct-test")
monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token)
monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login)
login_oauth_provider({"provider": ["openai-codex"]})
assert captured == {"get_proxy": proxy, "login_proxy": proxy}
2026-07-05 14:16:39 +08:00
def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "oauth_cli_kit":
raise ImportError("missing")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["openai-codex"]})
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
def test_github_copilot_oauth_login_reports_missing_oauth_cli_kit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "nanobot.providers.github_copilot_provider":
raise ImportError("missing")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["github-copilot"]})
assert "oauth_cli_kit not installed. Run: pip install oauth-cli-kit" in str(exc.value)
def test_xai_grok_login_starts_fresh_browser_flow_with_proxy(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
proxy = "http://127.0.0.1:23458"
config_path = tmp_path / "config.json"
save_config(Config.model_validate({"providers": {"xaiGrok": {"proxy": proxy}}}), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
captured: dict[str, object] = {}
class FakeFlow:
authorization_url = "https://auth.x.ai/oauth2/authorize?state=test"
remaining_seconds = 600
expired = False
def cancel(self) -> None:
captured["cancelled"] = True
def fake_start(*, proxy=None, timeout_s=None):
captured.update(proxy=proxy, timeout_s=timeout_s)
return FakeFlow()
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
payload = login_oauth_provider({"provider": ["xai-grok"]})
assert captured["proxy"] == proxy
assert captured["timeout_s"] == 600
assert payload["status"] == "authorization_required"
assert payload["provider"] == "xai_grok"
assert payload["authorization_url"] == FakeFlow.authorization_url
assert payload["flow_id"]
callbacks: list[str | None] = []
def fake_complete(_flow, callback):
callbacks.append(callback)
if callback is None:
return None
return SimpleNamespace(access="access-token")
monkeypatch.setattr(
"nanobot.providers.xai_oauth.complete_xai_oauth_login",
fake_complete,
)
monkeypatch.setattr(
"nanobot.webui.settings_api.settings_payload",
lambda: {"settings": "ready"},
)
pending = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
)
completed = complete_oauth_provider(
{"provider": ["xai-grok"], "flow_id": [payload["flow_id"]]},
"secret",
)
assert pending == {
"status": "pending",
"provider": "xai_grok",
"flow_id": payload["flow_id"],
}
assert completed == {"settings": "ready"}
assert callbacks == [None, "secret"]
def test_xai_grok_login_reports_upstream_failure_as_bad_gateway(
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)
failure = RuntimeError("Could not reach xAI sign-in: ConnectError.")
def fake_start(**_kwargs):
raise failure
monkeypatch.setattr("nanobot.providers.xai_oauth.start_xai_oauth_login", fake_start)
with pytest.raises(WebUISettingsError) as exc:
login_oauth_provider({"provider": ["xai-grok"]})
assert exc.value.status == 502
assert str(exc.value) == (
"xAI OAuth login failed: Could not reach xAI sign-in: ConnectError."
)
assert exc.value.__cause__ is failure
def test_xai_grok_logout_removes_token_through_shared_lock(
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)
token_path = tmp_path / "auth" / "xai.json"
token_path.parent.mkdir(parents=True)
token_path.write_text("{}", encoding="utf-8")
token_path.with_suffix(".lock").write_text("", encoding="utf-8")
monkeypatch.setattr(
"nanobot.providers.xai_oauth.get_xai_oauth_storage_path",
lambda: token_path,
)
logout_oauth_provider({"provider": ["xai-grok"]})
assert not token_path.exists()
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
2026-07-13 13:11:46 +08:00
def test_provider_models_payload_returns_curated_openai_codex_models() -> None:
payload = provider_models_payload({"provider": ["openai_codex"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin"
assert payload["model_count"] == 7
assert payload["models"][0] == {
"id": "openai-codex/gpt-5.6-sol",
"label": "GPT-5.6-Sol",
"description": "Latest frontier agentic coding model.",
"owned_by": "OpenAI Codex",
"context_window": 372000,
}
assert [model["id"] for model in payload["models"][:3]] == [
"openai-codex/gpt-5.6-sol",
"openai-codex/gpt-5.6-terra",
"openai-codex/gpt-5.6-luna",
]
def test_provider_models_payload_returns_xai_grok_model() -> None:
payload = provider_models_payload({"provider": ["xai_grok"]})
assert payload["status"] == "available"
assert payload["catalog_kind"] == "builtin"
assert payload["models"] == [
{
"id": "xai-grok/grok-4.5",
"label": "Grok 4.5",
"description": "Grok via xAI subscription; X Search is enabled when supported.",
"owned_by": "xAI Grok",
"context_window": 500000,
}
]
def test_provider_models_payload_fetches_dynamic_custom_provider_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(_dynamic_provider_config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def fake_get(url: str, **kwargs):
assert url == f"{DYNAMIC_PROVIDER_API_BASE}/models"
assert "Authorization" not in kwargs["headers"]
return httpx.Response(
200,
json={"data": [{"id": "custom-gpt", "owned_by": "example"}]},
request=httpx.Request("GET", url),
)
monkeypatch.setattr("nanobot.webui.settings_api.httpx.get", fake_get)
payload = provider_models_payload({"provider": [DYNAMIC_PROVIDER_NAME]})
assert payload["provider"] == DYNAMIC_PROVIDER_NAME
assert payload["status"] == "available"
assert payload["catalog_kind"] == "custom"
assert payload["models"][0]["id"] == "custom-gpt"
@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["catalog_kind"] == "catalog"
assert payload["models"] == []
def test_model_catalog_kind_uses_provider_spec_metadata() -> None:
assert _model_catalog_kind(find_by_name("skywork")) == "official"
assert _model_catalog_kind(find_by_name("anthropic")) == "unsupported"
assert _model_catalog_kind(find_by_name("openrouter")) == "catalog"
2026-07-13 13:11:46 +08:00
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
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.6-sol"],
}
)
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"
2026-06-01 14:03:43 +00:00
# ---------------------------------------------------------------------------
# Azure OpenAI: settings contract for static-key vs AAD (DefaultAzureCredential)
# ---------------------------------------------------------------------------
def test_settings_payload_azure_openai_with_api_key_is_configured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Static-key mode: api_key + api_base both set -> configured."""
config_path = tmp_path / "config.json"
config = Config()
config.providers.azure_openai.api_key = "k"
config.providers.azure_openai.api_base = "https://r.openai.azure.com"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
azure = next(row for row in payload["providers"] if row["name"] == "azure_openai")
assert azure["configured"] is True
assert azure["api_key_required"] is False
assert azure["auth_type"] == "api_key"
assert azure["api_base"] == "https://r.openai.azure.com"
def test_settings_payload_azure_openai_aad_mode_is_configured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""AAD mode: only api_base set (no api_key) -> still configured."""
config_path = tmp_path / "config.json"
config = Config()
config.providers.azure_openai.api_base = "https://r.openai.azure.com"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
azure = next(row for row in payload["providers"] if row["name"] == "azure_openai")
assert azure["configured"] is True
assert azure["api_key_required"] is False
assert azure["api_base"] == "https://r.openai.azure.com"
assert azure["api_key_hint"] is None
def test_settings_payload_azure_openai_missing_base_not_configured(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""api_key alone (no api_base) is NOT a working config -> not configured."""
config_path = tmp_path / "config.json"
config = Config()
config.providers.azure_openai.api_key = "k"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
azure = next(row for row in payload["providers"] if row["name"] == "azure_openai")
assert azure["configured"] is False
def test_create_model_configuration_accepts_azure_openai_aad_mode(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Provider-validation accepts azure_openai with only api_base (AAD mode)."""
config_path = tmp_path / "config.json"
config = Config()
config.providers.azure_openai.api_base = "https://r.openai.azure.com"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = create_model_configuration(
{
"label": ["Azure AAD"],
"provider": ["azure_openai"],
"model": ["my-deployment"],
}
)
assert payload["agent"]["model_preset"] == "default"
assert payload["created_model_preset"] == "azure-aad"
2026-06-01 14:03:43 +00:00
saved = load_config(config_path)
assert saved.model_presets["azure-aad"].provider == "azure_openai"
assert saved.model_presets["azure-aad"].model == "my-deployment"
def test_create_model_configuration_rejects_azure_openai_without_base(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""azure_openai without api_base must still be rejected as not configured."""
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": ["Azure"],
"provider": ["azure_openai"],
"model": ["my-deployment"],
}
)
def test_azure_openai_spec_no_longer_requires_api_key() -> None:
"""Contract guard: api_key is optional for azure_openai (AAD fallback)."""
from nanobot.webui.settings_api import _provider_requires_api_key
spec = find_by_name("azure_openai")
assert spec is not None
assert _provider_requires_api_key(spec) is False