feat(webui): add guided setup flows
* feat(channels): add guided setup flows * test(channels): preserve setup config values * fix(channels): reflect saved setup state * refactor(channels): simplify setup state metadata * fix(channels): harden setup lifecycle * refactor(channels): centralize setup contracts * fix(channels): route setup actions through webui shim * fix(channels): adapt settings for compact screens * fix(models): preserve default preset display * feat(models): add curated Codex catalog * fix(webui): stop attached gateway on interrupt * fix(webui): simplify apps catalog * docs(webui): clarify apps and runtime features * feat(settings): add guided capability setup * fix(webui): harden setup and managed services * test: keep managed runtime checks portable * test: scope POSIX runtime coverage * fix(webui): simplify file settings * feat(files): bundle document reading * fix(webui): harden setup request boundaries * fix(webui): prevent channel setup status squeeze * fix(settings): group provider compatibility aliases * refactor(settings): remove redundant setup surfaces * fix(webui): harden guided setup lifecycle * fix(webui): preserve channel setup compatibility
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle
|
||||
|
||||
_MTIME_BASE_NS = 1_700_000_000_000_000_000
|
||||
_MTIME_STEP_NS = 5_000_000_000
|
||||
|
||||
|
||||
def _touch(path: Path, *, mtime_ns: int) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(path.name, encoding="utf-8")
|
||||
if mtime_ns < 1_000_000_000_000_000:
|
||||
mtime_ns = _MTIME_BASE_NS + mtime_ns * _MTIME_STEP_NS
|
||||
os.utime(path, ns=(mtime_ns, mtime_ns))
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_ignores_packaged_install_without_source(tmp_path: Path) -> None:
|
||||
source = tmp_path / "site-packages" / "webui"
|
||||
dist = tmp_path / "site-packages" / "nanobot" / "web" / "dist"
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.source_available is False
|
||||
assert status.stale is False
|
||||
assert status.reason == "no_source"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_marks_missing_dist_stale(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.source_available is True
|
||||
assert status.dist_available is False
|
||||
assert status.stale is True
|
||||
assert status.reason == "missing_dist"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.stale is True
|
||||
assert status.reason == "source_newer"
|
||||
assert status.newest_source == source / "src" / "App.tsx"
|
||||
|
||||
|
||||
def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=20)
|
||||
_touch(dist / "index.html", mtime_ns=30)
|
||||
|
||||
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
|
||||
|
||||
assert status.stale is False
|
||||
assert status.reason == "fresh"
|
||||
|
||||
|
||||
def test_ensure_webui_bundle_auto_builds_stale_dist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
commands: list[tuple[str, ...]] = []
|
||||
|
||||
def fake_run(command, *, cwd: Path, check: bool) -> None:
|
||||
commands.append(tuple(command))
|
||||
assert cwd == source
|
||||
assert check is True
|
||||
if command == ["bun", "run", "build"]:
|
||||
_touch(dist / "index.html", mtime_ns=40)
|
||||
|
||||
status = ensure_webui_bundle(
|
||||
mode="auto",
|
||||
source_dir=source,
|
||||
dist_dir=dist,
|
||||
runner="bun",
|
||||
subprocess_run=fake_run,
|
||||
)
|
||||
|
||||
assert status.stale is False
|
||||
assert commands == [("bun", "install"), ("bun", "run", "build")]
|
||||
|
||||
|
||||
def test_ensure_webui_bundle_warns_without_building(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
dist = tmp_path / "nanobot" / "web" / "dist"
|
||||
_touch(source / "package.json", mtime_ns=10)
|
||||
_touch(source / "src" / "App.tsx", mtime_ns=30)
|
||||
_touch(dist / "index.html", mtime_ns=20)
|
||||
messages: list[str] = []
|
||||
|
||||
status = ensure_webui_bundle(
|
||||
mode="warn",
|
||||
source_dir=source,
|
||||
dist_dir=dist,
|
||||
output=messages.append,
|
||||
)
|
||||
|
||||
assert status.stale is True
|
||||
assert messages
|
||||
assert "Run `cd" in messages[0]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.channels.weixin import WeixinChannel
|
||||
from nanobot.config.loader import save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui.channel_connect import WeixinConnectStore
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_connect_store_saves_confirmed_qr_login(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-1", "https://qr.example/1"
|
||||
|
||||
async def fake_api_get_with_base(
|
||||
self: WeixinChannel,
|
||||
*,
|
||||
base_url: str,
|
||||
endpoint: str,
|
||||
params: dict[str, Any],
|
||||
auth: bool,
|
||||
) -> dict[str, str]:
|
||||
assert base_url == "https://ilinkai.weixin.qq.com"
|
||||
assert endpoint == "ilink/bot/get_qrcode_status"
|
||||
assert params == {"qrcode": "qr-1"}
|
||||
assert auth is False
|
||||
return {
|
||||
"status": "confirmed",
|
||||
"bot_token": "wx-token",
|
||||
"baseurl": "https://weixin.example",
|
||||
"ilink_user_id": "wx-user",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
|
||||
started = await store.start()
|
||||
assert started["status"] == "pending"
|
||||
assert started["qr_url"] == "https://qr.example/1"
|
||||
|
||||
completed = await store.poll(started["session_id"])
|
||||
assert completed["status"] == "succeeded"
|
||||
assert completed["account"] == "wx-user"
|
||||
|
||||
saved = json.loads((state_dir / "account.json").read_text())
|
||||
assert saved["token"] == "wx-token"
|
||||
assert saved["base_url"] == "https://weixin.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
state_dir = tmp_path / "weixin-state"
|
||||
state_dir.mkdir()
|
||||
existing = {
|
||||
"token": "working-token",
|
||||
"base_url": "https://working.weixin.example",
|
||||
"context_tokens": {"user-1": "context-1"},
|
||||
}
|
||||
state_file = state_dir / "account.json"
|
||||
state_file.write_text(json.dumps(existing), encoding="utf-8")
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||
return "qr-reconnect", "https://qr.example/reconnect"
|
||||
|
||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||
|
||||
store = WeixinConnectStore()
|
||||
started = await store.start(force=True)
|
||||
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
cancelled = await store.cancel(started["session_id"])
|
||||
assert cancelled["status"] == "cancelled"
|
||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||
@@ -0,0 +1,231 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.webui import channel_validation
|
||||
from nanobot.webui.channel_validation import validate_channel_config
|
||||
|
||||
|
||||
def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"slack": {
|
||||
"appToken": "xapp-old",
|
||||
"botToken": "xoxb-old",
|
||||
"groupPolicy": "mention",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True})
|
||||
|
||||
payload = validate_channel_config(
|
||||
"slack",
|
||||
{
|
||||
"channels.slack.appToken": "",
|
||||
"channels.slack.botToken": "",
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == "connected"
|
||||
saved = load_config(config_path)
|
||||
assert saved.channels.slack["appToken"] == "xapp-old"
|
||||
assert saved.channels.slack["botToken"] == "xoxb-old"
|
||||
|
||||
|
||||
def test_validate_telegram_bad_token_is_invalid(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 = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
|
||||
|
||||
assert payload["status"] == "invalid"
|
||||
assert payload["can_enable"] is False
|
||||
assert payload["missing_fields"] == []
|
||||
|
||||
|
||||
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
token = "123456:abcdefghijklmnopqrstuvwxyz"
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate({"channels": {"telegram": {"token": token}}}),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
def raise_http_error(url: str, **_kwargs) -> dict:
|
||||
request = httpx.Request("GET", url)
|
||||
response = httpx.Response(401, request=request)
|
||||
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
|
||||
|
||||
monkeypatch.setattr(channel_validation, "_http_get", raise_http_error)
|
||||
|
||||
payload = validate_channel_config("telegram", {"channels.telegram.token": ""})
|
||||
|
||||
assert token not in str(payload)
|
||||
assert any("HTTP 401" in check.get("message", "") for check in payload["checks"])
|
||||
|
||||
|
||||
def test_validate_email_presets_are_checked_without_saving(
|
||||
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(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "imap.gmail.com",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "smtp.gmail.com",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == "connected"
|
||||
assert payload["can_enable"] is True
|
||||
assert not hasattr(load_config(config_path).channels, "email")
|
||||
|
||||
|
||||
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config = Config()
|
||||
config.tools.webui_allow_local_service_access = False
|
||||
save_config(config, config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
monkeypatch.setattr(
|
||||
channel_validation.socket,
|
||||
"create_connection",
|
||||
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
|
||||
)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"email",
|
||||
{
|
||||
"channels.email.consentGranted": "true",
|
||||
"channels.email.imapHost": "127.0.0.1",
|
||||
"channels.email.imapUsername": "bot@example.com",
|
||||
"channels.email.imapPassword": "imap-secret",
|
||||
"channels.email.smtpHost": "192.168.1.10",
|
||||
"channels.email.smtpUsername": "bot@example.com",
|
||||
"channels.email.smtpPassword": "smtp-secret",
|
||||
},
|
||||
)
|
||||
|
||||
warnings = [check["message"] for check in payload["checks"] if check["status"] == "warn"]
|
||||
assert len(warnings) == 2
|
||||
assert all("private/internal" in message for message in warnings)
|
||||
|
||||
|
||||
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
connected: list[tuple[str, int]] = []
|
||||
|
||||
class FakeSocket:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
channel_validation,
|
||||
"resolve_url_target",
|
||||
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
channel_validation.socket,
|
||||
"create_connection",
|
||||
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
|
||||
)
|
||||
|
||||
channel_validation._probe_tcp("mail.example.com", 2525)
|
||||
|
||||
assert connected == [("203.0.113.10", 2525)]
|
||||
|
||||
|
||||
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(
|
||||
Config.model_validate(
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"clientId": "ding-client",
|
||||
"clientSecret": "ding-secret",
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
config_path,
|
||||
)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = validate_channel_config("dingtalk", {})
|
||||
|
||||
assert payload["status"] == "configured"
|
||||
assert payload["can_enable"] is True
|
||||
assert any(check["status"] == "skipped" for check in payload["checks"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("credentials", "expected_status", "expected_missing"),
|
||||
[
|
||||
({}, "needs_setup", "password_or_accessToken"),
|
||||
({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"),
|
||||
({"channels.matrix.password": "secret"}, "configured", None),
|
||||
(
|
||||
{
|
||||
"channels.matrix.accessToken": "token",
|
||||
"channels.matrix.deviceId": "DEVICE",
|
||||
},
|
||||
"configured",
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_validate_matrix_requires_a_complete_login_method(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
credentials: dict[str, str],
|
||||
expected_status: str,
|
||||
expected_missing: str | None,
|
||||
) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
save_config(Config(), config_path)
|
||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||
|
||||
payload = validate_channel_config(
|
||||
"matrix",
|
||||
{
|
||||
"channels.matrix.homeserver": "https://matrix.example",
|
||||
"channels.matrix.userId": "@nanobot:matrix.example",
|
||||
**credentials,
|
||||
},
|
||||
)
|
||||
|
||||
assert payload["status"] == expected_status
|
||||
assert payload["can_enable"] is (expected_status == "configured")
|
||||
if expected_missing is None:
|
||||
assert payload["missing_fields"] == []
|
||||
else:
|
||||
assert expected_missing in payload["missing_fields"]
|
||||
@@ -12,6 +12,7 @@ from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.webui.settings_api import (
|
||||
WebUISettingsError,
|
||||
_docs_version,
|
||||
_model_catalog_kind,
|
||||
_oauth_provider_status,
|
||||
create_model_configuration,
|
||||
@@ -20,6 +21,7 @@ from nanobot.webui.settings_api import (
|
||||
settings_payload,
|
||||
settings_usage_payload,
|
||||
update_agent_settings,
|
||||
update_api_settings,
|
||||
update_model_configuration,
|
||||
update_network_safety_settings,
|
||||
update_provider_settings,
|
||||
@@ -31,6 +33,100 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
|
||||
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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,
|
||||
@@ -348,6 +444,42 @@ def test_settings_payload_includes_dynamic_custom_provider(
|
||||
assert providers[DYNAMIC_PROVIDER_NAME]["api_base"] == DYNAMIC_PROVIDER_API_BASE
|
||||
|
||||
|
||||
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,
|
||||
@@ -952,6 +1084,26 @@ def test_provider_models_payload_fetches_openai_compatible_models(
|
||||
assert payload["models"][1]["context_window"] == 65536
|
||||
|
||||
|
||||
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_fetches_dynamic_custom_provider_models(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1044,6 +1196,7 @@ 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"
|
||||
assert _model_catalog_kind(find_by_name("openai_codex")) == "builtin"
|
||||
|
||||
|
||||
def test_create_model_configuration_accepts_configured_oauth_provider(
|
||||
|
||||
Reference in New Issue
Block a user