mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e065cfffe | ||
|
|
27f7549c84 | ||
|
|
f5371a6c5a | ||
|
|
f19efcd990 |
@@ -27,3 +27,9 @@ A bugfix should make the protected invariant clear, change the smallest surface
|
|||||||
## Explicit over magical
|
## Explicit over magical
|
||||||
|
|
||||||
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.
|
||||||
|
|
||||||
|
## Configuration has an explicit owner
|
||||||
|
|
||||||
|
`FileConfigRepository` owns config-file reads, validation, revisions, and atomic writes. Process entry points may use the explicit functions in `config/loader.py`; components with an explicit config path should keep their own repository instance instead of importing a mutable global config object.
|
||||||
|
|
||||||
|
Persisted and runtime views are separate: `load_raw_config()` / `load_raw()` preserve `${VAR}` references for editing, while `load_effective_config()` / `load_effective()` return an isolated snapshot with references resolved. Read-modify-write flows must use `update()` / `update_config()` so a stale object cannot silently overwrite a newer change. Loading config is side-effect free; process-wide policies are applied explicitly during runtime startup.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## Config `${VAR}` References
|
## Config `${VAR}` References
|
||||||
|
|
||||||
`config/loader.py` resolves `${VAR}` patterns in `config.json` at load time. This is **not** a shell-like default-value syntax. If the environment variable is missing, `load_config` raises `ValueError` and the agent falls back to default configuration.
|
`load_raw_config()` and `FileConfigRepository.load_raw()` preserve `${VAR}` patterns so Settings can safely edit and save the persisted representation. Runtime entry points use `load_effective_config()` / `load_effective()` to resolve them in an isolated snapshot. This is **not** a shell-like default-value syntax. If a referenced variable is missing, effective loading raises `ValueError`.
|
||||||
|
|
||||||
Example valid usage:
|
Example valid usage:
|
||||||
```json
|
```json
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
|
|||||||
|
|
||||||
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
|
||||||
|
|
||||||
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time.
|
The only escape hatch is `configure_ssrf_whitelist(cidrs)`. Runtime entry points explicitly apply `config.tools.ssrf_whitelist` after loading the effective config; ordinary config reads must not mutate this process-wide policy.
|
||||||
|
|
||||||
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,46 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
|
|||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings.
|
||||||
|
|
||||||
|
## Python Configuration API
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> `nanobot.config.load_config` and `nanobot.config.loader.load_config` have been
|
||||||
|
> removed. There is intentionally no compatibility alias because the old name
|
||||||
|
> did not distinguish the persisted representation from the runtime view.
|
||||||
|
|
||||||
|
Python embedders and plugins must choose the view they need explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from nanobot.config import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
load_effective_config,
|
||||||
|
load_raw_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
path = Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
# For settings editors and persistence flows: preserves ${VAR} placeholders.
|
||||||
|
persisted = load_raw_config(path)
|
||||||
|
|
||||||
|
# For agents, providers, channels, and tools: resolves ${VAR} placeholders.
|
||||||
|
runtime = load_effective_config(path)
|
||||||
|
apply_config_runtime_policies(runtime)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this migration mapping:
|
||||||
|
|
||||||
|
| Previous call | Replacement |
|
||||||
|
|---|---|
|
||||||
|
| `load_config(path)` used to inspect or edit persisted values | `load_raw_config(path)` |
|
||||||
|
| `resolve_config_env_vars(load_config(path))` used at runtime | `load_effective_config(path)` |
|
||||||
|
| Loading followed by implicit process-policy setup | `load_effective_config(path)`, then `apply_config_runtime_policies(config)` at the runtime boundary |
|
||||||
|
|
||||||
|
This is a Python API breaking change for embedders and plugins that import the
|
||||||
|
old function. The `config.json` format is unchanged, and the bundled CLI,
|
||||||
|
gateway, and WebUI already use the explicit APIs.
|
||||||
|
|
||||||
## Configuration Guides
|
## Configuration Guides
|
||||||
|
|
||||||
This page is the complete configuration reference. For task-oriented setup, use
|
This page is the complete configuration reference. For task-oriented setup, use
|
||||||
|
|||||||
@@ -1162,9 +1162,9 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
config = resolve_config_env_vars(load_config())
|
config = load_effective_config()
|
||||||
next_servers = dict(config.tools.mcp_servers)
|
next_servers = dict(config.tools.mcp_servers)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("MCP hot reload could not read config: {}", exc)
|
logger.warning("MCP hot reload could not read config: {}", exc)
|
||||||
|
|||||||
@@ -315,8 +315,9 @@ class WebSearchTool(Tool):
|
|||||||
config_loader = None
|
config_loader = None
|
||||||
if ctx.provider_snapshot_loader is not None:
|
if ctx.provider_snapshot_loader is not None:
|
||||||
def config_loader():
|
def config_loader():
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
return resolve_config_env_vars(load_config()).tools.web.search
|
|
||||||
|
return load_effective_config().tools.web.search
|
||||||
return cls(
|
return cls(
|
||||||
config=ctx.config.web.search,
|
config=ctx.config.web.search,
|
||||||
proxy=ctx.config.web.proxy,
|
proxy=ctx.config.web.proxy,
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ class BaseChannel(ABC):
|
|||||||
resolve_transcription_config,
|
resolve_transcription_config,
|
||||||
transcribe_audio_file,
|
transcribe_audio_file,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
|
return await transcribe_audio_file(file_path, resolve_transcription_config(load_raw_config()))
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("Audio transcription failed")
|
self.logger.exception("Audio transcription failed")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+104
-57
@@ -626,38 +626,44 @@ def sync_saved_feishu_identity_boundary(
|
|||||||
if not current_identity_key:
|
if not current_identity_key:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import update_config
|
||||||
|
|
||||||
full_config = load_config()
|
|
||||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
|
||||||
if not isinstance(feishu_cfg, dict):
|
|
||||||
feishu_cfg = {}
|
|
||||||
|
|
||||||
defaults = FeishuChannel.default_config()
|
defaults = FeishuChannel.default_config()
|
||||||
previous_identity_key = ""
|
access_cleared = False
|
||||||
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
|
||||||
if spec.instance_id == instance_id:
|
|
||||||
previous_identity_key = str(
|
|
||||||
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
access_cleared = bool(previous_identity_key and previous_identity_key != current_identity_key)
|
def mutate(full_config: Any) -> None:
|
||||||
values: dict[str, Any] = {"identityKey": current_identity_key}
|
nonlocal access_cleared
|
||||||
if access_cleared:
|
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||||
values["allowFrom"] = []
|
if not isinstance(feishu_cfg, dict):
|
||||||
values["allow_from"] = []
|
feishu_cfg = {}
|
||||||
clear_channel(runtime_channel_name("feishu", instance_id))
|
|
||||||
|
|
||||||
if not previous_identity_key or access_cleared:
|
previous_identity_key = ""
|
||||||
feishu_cfg = update_feishu_instance_preserving_shape(
|
for spec in feishu_instance_specs(feishu_cfg, defaults):
|
||||||
feishu_cfg,
|
if spec.instance_id == instance_id:
|
||||||
defaults,
|
previous_identity_key = str(
|
||||||
instance_id,
|
spec.config.get("identityKey") or spec.config.get("identity_key") or ""
|
||||||
values,
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
access_cleared = bool(
|
||||||
|
previous_identity_key and previous_identity_key != current_identity_key
|
||||||
)
|
)
|
||||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
values: dict[str, Any] = {"identityKey": current_identity_key}
|
||||||
save_config(full_config)
|
if access_cleared:
|
||||||
|
values["allowFrom"] = []
|
||||||
|
values["allow_from"] = []
|
||||||
|
|
||||||
|
if not previous_identity_key or access_cleared:
|
||||||
|
full_config.channels.feishu = update_feishu_instance_preserving_shape(
|
||||||
|
feishu_cfg,
|
||||||
|
defaults,
|
||||||
|
instance_id,
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
|
if access_cleared:
|
||||||
|
clear_channel(runtime_channel_name("feishu", instance_id))
|
||||||
|
|
||||||
return access_cleared
|
return access_cleared
|
||||||
|
|
||||||
@@ -669,19 +675,13 @@ def save_registration_result(
|
|||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist a successful Feishu/Lark registration result to config.json."""
|
"""Persist a successful Feishu/Lark registration result to config.json."""
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import update_config
|
||||||
|
|
||||||
full_config = load_config()
|
|
||||||
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
|
||||||
if not isinstance(feishu_cfg, dict):
|
|
||||||
feishu_cfg = {}
|
|
||||||
defaults = FeishuChannel.default_config()
|
defaults = FeishuChannel.default_config()
|
||||||
app_id = str(result["app_id"]).strip()
|
app_id = str(result["app_id"]).strip()
|
||||||
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
domain = str(result.get("domain", "feishu") or "feishu").strip().lower()
|
||||||
domain = "lark" if domain == "lark" else "feishu"
|
domain = "lark" if domain == "lark" else "feishu"
|
||||||
previous_identity_key = _saved_feishu_instance_identity_key(feishu_cfg, defaults, instance_id)
|
|
||||||
next_identity_key = _feishu_app_identity_key(app_id, domain)
|
next_identity_key = _feishu_app_identity_key(app_id, domain)
|
||||||
identity_changed = bool(previous_identity_key and previous_identity_key != next_identity_key)
|
|
||||||
identity: dict[str, str] = {}
|
identity: dict[str, str] = {}
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
identity = fetch_feishu_app_identity(
|
identity = fetch_feishu_app_identity(
|
||||||
@@ -698,18 +698,35 @@ def save_registration_result(
|
|||||||
"enabled": True,
|
"enabled": True,
|
||||||
**identity,
|
**identity,
|
||||||
}
|
}
|
||||||
|
identity_changed = False
|
||||||
|
|
||||||
|
def mutate(full_config: Any) -> None:
|
||||||
|
nonlocal identity_changed
|
||||||
|
feishu_cfg = getattr(full_config.channels, "feishu", None) or {}
|
||||||
|
if not isinstance(feishu_cfg, dict):
|
||||||
|
feishu_cfg = {}
|
||||||
|
previous_identity_key = _saved_feishu_instance_identity_key(
|
||||||
|
feishu_cfg,
|
||||||
|
defaults,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
|
identity_changed = bool(
|
||||||
|
previous_identity_key and previous_identity_key != next_identity_key
|
||||||
|
)
|
||||||
|
next_values = dict(values)
|
||||||
|
if identity_changed:
|
||||||
|
next_values["allowFrom"] = []
|
||||||
|
next_values["allow_from"] = []
|
||||||
|
full_config.channels.feishu = upsert_feishu_instance(
|
||||||
|
feishu_cfg,
|
||||||
|
defaults,
|
||||||
|
instance_id,
|
||||||
|
next_values,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
if identity_changed:
|
if identity_changed:
|
||||||
values["allowFrom"] = []
|
|
||||||
values["allow_from"] = []
|
|
||||||
clear_channel(runtime_channel_name("feishu", instance_id))
|
clear_channel(runtime_channel_name("feishu", instance_id))
|
||||||
feishu_cfg = upsert_feishu_instance(
|
|
||||||
feishu_cfg,
|
|
||||||
defaults,
|
|
||||||
instance_id,
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
|
||||||
save_config(full_config)
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
||||||
@@ -723,13 +740,13 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
|||||||
if not FEISHU_AVAILABLE:
|
if not FEISHU_AVAILABLE:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_raw_config, update_config
|
||||||
|
|
||||||
full_config = config or load_config()
|
source_config = config or load_raw_config()
|
||||||
feishu_cfg = getattr(full_config.channels, "feishu", None)
|
feishu_cfg = getattr(source_config.channels, "feishu", None)
|
||||||
defaults = FeishuChannel.default_config()
|
defaults = FeishuChannel.default_config()
|
||||||
specs = feishu_instance_specs(feishu_cfg, defaults)
|
specs = feishu_instance_specs(feishu_cfg, defaults)
|
||||||
updated = False
|
fetched: dict[str, tuple[tuple[str, str, str], dict[str, str]]] = {}
|
||||||
|
|
||||||
for spec in specs:
|
for spec in specs:
|
||||||
instance = spec.config
|
instance = spec.config
|
||||||
@@ -753,20 +770,50 @@ def refresh_saved_feishu_identities(config: Any | None = None) -> bool:
|
|||||||
if not identity:
|
if not identity:
|
||||||
identity = {"identityFetchedAt": _identity_timestamp()}
|
identity = {"identityFetchedAt": _identity_timestamp()}
|
||||||
|
|
||||||
feishu_cfg = update_feishu_instance_preserving_shape(
|
fetched[spec.instance_id] = (
|
||||||
feishu_cfg,
|
(app_id, app_secret, str(instance.get("domain") or "feishu")),
|
||||||
defaults,
|
|
||||||
spec.instance_id,
|
|
||||||
identity,
|
identity,
|
||||||
)
|
)
|
||||||
updated = True
|
|
||||||
|
|
||||||
if not updated:
|
if not fetched:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
setattr(full_config.channels, "feishu", feishu_cfg)
|
updated = False
|
||||||
save_config(full_config)
|
|
||||||
return True
|
def mutate(full_config: Any) -> None:
|
||||||
|
nonlocal updated
|
||||||
|
current_cfg = getattr(full_config.channels, "feishu", None)
|
||||||
|
for spec in feishu_instance_specs(current_cfg, defaults):
|
||||||
|
fetched_entry = fetched.get(spec.instance_id)
|
||||||
|
if fetched_entry is None:
|
||||||
|
continue
|
||||||
|
expected_credentials, identity = fetched_entry
|
||||||
|
instance = spec.config
|
||||||
|
current_credentials = (
|
||||||
|
str(instance.get("appId") or instance.get("app_id") or "").strip(),
|
||||||
|
str(instance.get("appSecret") or instance.get("app_secret") or "").strip(),
|
||||||
|
str(instance.get("domain") or "feishu"),
|
||||||
|
)
|
||||||
|
if current_credentials != expected_credentials:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
instance.get("displayName")
|
||||||
|
or instance.get("avatarUrl")
|
||||||
|
or instance.get("identityFetchedAt")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
current_cfg = update_feishu_instance_preserving_shape(
|
||||||
|
current_cfg,
|
||||||
|
defaults,
|
||||||
|
spec.instance_id,
|
||||||
|
identity,
|
||||||
|
)
|
||||||
|
updated = True
|
||||||
|
if updated:
|
||||||
|
full_config.channels.feishu = current_cfg
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def qr_register(
|
def qr_register(
|
||||||
|
|||||||
@@ -364,9 +364,9 @@ class ChannelManager:
|
|||||||
"message": "WebSocket hosts the WebUI and is applied on restart.",
|
"message": "WebSocket hosts the WebUI and is applied on restart.",
|
||||||
}
|
}
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
self.config = load_config()
|
self.config = load_effective_config()
|
||||||
section = self._channel_section(name)
|
section = self._channel_section(name)
|
||||||
if action == "disable":
|
if action == "disable":
|
||||||
runtime_names = [name if not instance_id else f"{name}.{instance_id}"]
|
runtime_names = [name if not instance_id else f"{name}.{instance_id}"]
|
||||||
|
|||||||
+39
-34
@@ -640,7 +640,7 @@ def onboard(
|
|||||||
non_interactive_refresh: bool = typer.Option(False, "--refresh", help="Refresh config, preserving existing settings without prompting"),
|
non_interactive_refresh: bool = typer.Option(False, "--refresh", help="Refresh config, preserving existing settings without prompting"),
|
||||||
):
|
):
|
||||||
"""Initialize nanobot configuration and workspace."""
|
"""Initialize nanobot configuration and workspace."""
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
from nanobot.config.loader import get_config_path, load_raw_config, save_config, set_config_path
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
if config:
|
if config:
|
||||||
@@ -658,7 +658,7 @@ def onboard(
|
|||||||
# Create or update config
|
# Create or update config
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
if wizard:
|
if wizard:
|
||||||
config = _apply_workspace_override(load_config(config_path))
|
config = _apply_workspace_override(load_raw_config(config_path))
|
||||||
else:
|
else:
|
||||||
should_refresh = non_interactive_refresh
|
should_refresh = non_interactive_refresh
|
||||||
if not non_interactive_refresh:
|
if not non_interactive_refresh:
|
||||||
@@ -677,7 +677,7 @@ def onboard(
|
|||||||
should_refresh = True
|
should_refresh = True
|
||||||
|
|
||||||
if should_refresh:
|
if should_refresh:
|
||||||
config = _apply_workspace_override(load_config(config_path))
|
config = _apply_workspace_override(load_raw_config(config_path))
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
console.print(
|
console.print(
|
||||||
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
|
f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)"
|
||||||
@@ -738,27 +738,25 @@ def onboard(
|
|||||||
|
|
||||||
def _onboard_plugins(config_path: Path) -> None:
|
def _onboard_plugins(config_path: Path) -> None:
|
||||||
"""Inject default config for all discovered channels (built-in + plugins)."""
|
"""Inject default config for all discovered channels (built-in + plugins)."""
|
||||||
import json
|
|
||||||
|
|
||||||
from nanobot.channels.registry import discover_all
|
from nanobot.channels.registry import discover_all
|
||||||
from nanobot.config.loader import merge_missing_defaults
|
from nanobot.config.loader import get_config_repository, merge_missing_defaults
|
||||||
|
|
||||||
all_channels = discover_all()
|
all_channels = discover_all()
|
||||||
if not all_channels:
|
if not all_channels:
|
||||||
return
|
return
|
||||||
|
|
||||||
with open(config_path, encoding="utf-8") as f:
|
def mutate(config: Config) -> None:
|
||||||
data = json.load(f)
|
for name, cls in all_channels.items():
|
||||||
|
existing = getattr(config.channels, name, None)
|
||||||
|
if not isinstance(existing, dict):
|
||||||
|
existing = {}
|
||||||
|
setattr(
|
||||||
|
config.channels,
|
||||||
|
name,
|
||||||
|
merge_missing_defaults(existing, cls.default_config()),
|
||||||
|
)
|
||||||
|
|
||||||
channels = data.setdefault("channels", {})
|
get_config_repository(config_path).update(mutate)
|
||||||
for name, cls in all_channels.items():
|
|
||||||
if name not in channels:
|
|
||||||
channels[name] = cls.default_config()
|
|
||||||
else:
|
|
||||||
channels[name] = merge_missing_defaults(channels[name], cls.default_config())
|
|
||||||
|
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def _print_enable_options(
|
def _print_enable_options(
|
||||||
@@ -798,7 +796,11 @@ def _model_display(config: Config) -> tuple[str, str]:
|
|||||||
|
|
||||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||||
"""Load config and optionally override the active workspace."""
|
"""Load config and optionally override the active workspace."""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, set_config_path
|
from nanobot.config.loader import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
load_effective_config,
|
||||||
|
set_config_path,
|
||||||
|
)
|
||||||
|
|
||||||
config_path = None
|
config_path = None
|
||||||
if config:
|
if config:
|
||||||
@@ -810,13 +812,14 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
|||||||
console.print(f"[dim]Using config: {config_path}[/dim]")
|
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loaded = resolve_config_env_vars(load_config(config_path))
|
loaded = load_effective_config(config_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
_warn_deprecated_config_keys(config_path)
|
_warn_deprecated_config_keys(config_path)
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
|
apply_config_runtime_policies(loaded)
|
||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
@@ -858,7 +861,7 @@ def _load_inspection_config(
|
|||||||
workspace: str | None = None,
|
workspace: str | None = None,
|
||||||
) -> tuple[Path, Config]:
|
) -> tuple[Path, Config]:
|
||||||
"""Load config for diagnostic commands without resolving secret env refs."""
|
"""Load config for diagnostic commands without resolving secret env refs."""
|
||||||
from nanobot.config.loader import get_config_path, load_config, set_config_path
|
from nanobot.config.loader import get_config_path, load_raw_config, set_config_path
|
||||||
|
|
||||||
config_path = None
|
config_path = None
|
||||||
if config:
|
if config:
|
||||||
@@ -868,7 +871,7 @@ def _load_inspection_config(
|
|||||||
|
|
||||||
display_path = config_path or get_config_path()
|
display_path = config_path or get_config_path()
|
||||||
try:
|
try:
|
||||||
loaded = load_config(config_path)
|
loaded = load_raw_config(config_path)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
@@ -920,10 +923,10 @@ def _resolve_webui_config_path(config: str | None) -> Path:
|
|||||||
|
|
||||||
def _load_webui_setup_config(config_path: Path) -> Config:
|
def _load_webui_setup_config(config_path: Path) -> Config:
|
||||||
"""Load config for first-run mutation without resolving env-var placeholders."""
|
"""Load config for first-run mutation without resolving env-var placeholders."""
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return load_config(config_path)
|
return load_raw_config(config_path)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
raise typer.Exit(1) from e
|
raise typer.Exit(1) from e
|
||||||
@@ -931,7 +934,7 @@ def _load_webui_setup_config(config_path: Path) -> Config:
|
|||||||
|
|
||||||
def _provider_setup_error(config: Config) -> str | None:
|
def _provider_setup_error(config: Config) -> str | None:
|
||||||
"""Return the provider setup error, or None when the current model can start."""
|
"""Return the provider setup error, or None when the current model can start."""
|
||||||
from nanobot.config.loader import resolve_config_env_vars
|
from nanobot.config.repository import resolve_config_env_vars
|
||||||
from nanobot.providers.factory import build_provider_snapshot
|
from nanobot.providers.factory import build_provider_snapshot
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -2517,7 +2520,7 @@ def plugins_list(
|
|||||||
):
|
):
|
||||||
"""List optional nanobot features."""
|
"""List optional nanobot features."""
|
||||||
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
||||||
from nanobot.config.loader import load_config, set_config_path
|
from nanobot.config.loader import load_raw_config, set_config_path
|
||||||
|
|
||||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||||
if resolved_config_path is not None:
|
if resolved_config_path is not None:
|
||||||
@@ -2527,7 +2530,7 @@ def plugins_list(
|
|||||||
feature_support.optional_dependency_groups(),
|
feature_support.optional_dependency_groups(),
|
||||||
set(discover_channel_names()),
|
set(discover_channel_names()),
|
||||||
discover_plugins(),
|
discover_plugins(),
|
||||||
load_config(resolved_config_path),
|
load_raw_config(resolved_config_path),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2688,19 +2691,21 @@ def _set_oauth_provider_as_main(
|
|||||||
config_path: str | None = None,
|
config_path: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Persist an OAuth provider as the active agent provider."""
|
"""Persist an OAuth provider as the active agent provider."""
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
|
from nanobot.config.loader import get_config_path, set_config_path, update_config
|
||||||
|
|
||||||
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
resolved_config_path = Path(config_path).expanduser().resolve() if config_path else None
|
||||||
if resolved_config_path is not None:
|
if resolved_config_path is not None:
|
||||||
set_config_path(resolved_config_path)
|
set_config_path(resolved_config_path)
|
||||||
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
console.print(f"[dim]Using config: {resolved_config_path}[/dim]")
|
||||||
|
|
||||||
config = load_config(resolved_config_path)
|
|
||||||
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
selected_model = (model or "").strip() or _OAUTH_PROVIDER_DEFAULT_MODELS[provider_name]
|
||||||
config.agents.defaults.model_preset = None
|
|
||||||
config.agents.defaults.provider = provider_name
|
def mutate(config: Config) -> None:
|
||||||
config.agents.defaults.model = selected_model
|
config.agents.defaults.model_preset = None
|
||||||
save_config(config, resolved_config_path)
|
config.agents.defaults.provider = provider_name
|
||||||
|
config.agents.defaults.model = selected_model
|
||||||
|
|
||||||
|
update_config(mutate, resolved_config_path)
|
||||||
|
|
||||||
saved_path = resolved_config_path or get_config_path()
|
saved_path = resolved_config_path or get_config_path()
|
||||||
console.print(
|
console.print(
|
||||||
@@ -2762,11 +2767,11 @@ def _login_openai_codex() -> None:
|
|||||||
try:
|
try:
|
||||||
from oauth_cli_kit import get_token, login_oauth_interactive
|
from oauth_cli_kit import get_token, login_oauth_interactive
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
proxy = None
|
proxy = None
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
proxy = load_effective_config().providers.openai_codex.proxy or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console.print(f"[red]{e}[/red]")
|
console.print(f"[red]{e}[/red]")
|
||||||
raise typer.Exit(1) from e
|
raise typer.Exit(1) from e
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from nanobot.cli.models import (
|
|||||||
get_model_context_limit,
|
get_model_context_limit,
|
||||||
get_model_suggestions,
|
get_model_suggestions,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import get_config_path, load_raw_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
@@ -1940,7 +1940,7 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
|||||||
else:
|
else:
|
||||||
config_path = get_config_path()
|
config_path = get_config_path()
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
base_config = load_config()
|
base_config = load_raw_config()
|
||||||
else:
|
else:
|
||||||
base_config = Config()
|
base_config = Config()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
"""Configuration module for nanobot."""
|
"""Configuration module for nanobot."""
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path, load_config
|
from nanobot.config.loader import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
get_config_path,
|
||||||
|
get_config_repository,
|
||||||
|
load_effective_config,
|
||||||
|
load_raw_config,
|
||||||
|
update_config,
|
||||||
|
)
|
||||||
from nanobot.config.paths import (
|
from nanobot.config.paths import (
|
||||||
get_cli_history_path,
|
get_cli_history_path,
|
||||||
get_cron_dir,
|
get_cron_dir,
|
||||||
@@ -13,12 +20,28 @@ from nanobot.config.paths import (
|
|||||||
get_workspace_path,
|
get_workspace_path,
|
||||||
is_default_workspace,
|
is_default_workspace,
|
||||||
)
|
)
|
||||||
|
from nanobot.config.repository import (
|
||||||
|
ConfigCommit,
|
||||||
|
ConfigConflictError,
|
||||||
|
EffectiveConfigSnapshot,
|
||||||
|
FileConfigRepository,
|
||||||
|
PersistedConfigSnapshot,
|
||||||
|
)
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Config",
|
"Config",
|
||||||
"load_config",
|
"ConfigCommit",
|
||||||
|
"ConfigConflictError",
|
||||||
|
"EffectiveConfigSnapshot",
|
||||||
|
"FileConfigRepository",
|
||||||
|
"PersistedConfigSnapshot",
|
||||||
|
"apply_config_runtime_policies",
|
||||||
|
"load_effective_config",
|
||||||
|
"load_raw_config",
|
||||||
|
"update_config",
|
||||||
"get_config_path",
|
"get_config_path",
|
||||||
|
"get_config_repository",
|
||||||
"get_data_dir",
|
"get_data_dir",
|
||||||
"get_runtime_subdir",
|
"get_runtime_subdir",
|
||||||
"get_media_dir",
|
"get_media_dir",
|
||||||
|
|||||||
+55
-166
@@ -1,92 +1,76 @@
|
|||||||
"""Configuration loading utilities."""
|
"""Configuration loading and persistence entry points."""
|
||||||
|
|
||||||
import json
|
from __future__ import annotations
|
||||||
import os
|
|
||||||
import re
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
from nanobot.config.repository import (
|
||||||
from loguru import logger
|
ConfigCommit,
|
||||||
from pydantic import BaseModel
|
FileConfigRepository,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
# Default path for process entry points that do not receive an explicit path.
|
||||||
|
|
||||||
# Global variable to store current config path (for multi-instance support)
|
|
||||||
_current_config_path: Path | None = None
|
_current_config_path: Path | None = None
|
||||||
_schema_refs_ready = False
|
|
||||||
|
|
||||||
|
|
||||||
def set_config_path(path: Path) -> None:
|
def set_config_path(path: Path) -> None:
|
||||||
"""Set the current config path (used to derive data directory)."""
|
"""Set the default configuration path for subsequent entry-point calls."""
|
||||||
global _current_config_path
|
global _current_config_path
|
||||||
_current_config_path = path
|
_current_config_path = path
|
||||||
|
|
||||||
|
|
||||||
def get_config_path() -> Path:
|
def get_config_path() -> Path:
|
||||||
"""Get the configuration file path."""
|
"""Get the default configuration file path."""
|
||||||
if _current_config_path:
|
if _current_config_path:
|
||||||
return _current_config_path
|
return _current_config_path
|
||||||
return Path.home() / ".nanobot" / "config.json"
|
return Path.home() / ".nanobot" / "config.json"
|
||||||
|
|
||||||
|
|
||||||
def load_config(config_path: Path | None = None) -> Config:
|
def get_config_repository(config_path: Path | None = None) -> FileConfigRepository:
|
||||||
"""
|
"""Return an instance-scoped repository for *config_path*."""
|
||||||
Load configuration from file or create default.
|
return FileConfigRepository(config_path or get_config_path())
|
||||||
|
|
||||||
Args:
|
|
||||||
config_path: Optional path to config file. Uses default if not provided.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Loaded configuration object.
|
|
||||||
"""
|
|
||||||
global _schema_refs_ready
|
|
||||||
if not _schema_refs_ready:
|
|
||||||
_resolve_tool_config_refs()
|
|
||||||
_schema_refs_ready = True
|
|
||||||
|
|
||||||
path = config_path or get_config_path()
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
if path.exists():
|
|
||||||
try:
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
data = json.load(f)
|
|
||||||
data = _migrate_config(data)
|
|
||||||
config = Config.model_validate(data)
|
|
||||||
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
|
||||||
raise ValueError(f"Failed to load config from {path}: {e}") from e
|
|
||||||
|
|
||||||
_apply_ssrf_whitelist(config)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_ssrf_whitelist(config: Config) -> None:
|
def load_raw_config(config_path: Path | None = None) -> Config:
|
||||||
"""Apply SSRF whitelist from config to the network security module."""
|
"""Load raw persisted config without applying process runtime policy."""
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
return get_config_repository(config_path).load_raw().config
|
||||||
|
|
||||||
configure_ssrf_whitelist(config.tools.ssrf_whitelist)
|
|
||||||
|
def load_effective_config(config_path: Path | None = None) -> Config:
|
||||||
|
"""Load a fresh runtime config with environment references resolved."""
|
||||||
|
return get_config_repository(config_path).load_effective().config
|
||||||
|
|
||||||
|
|
||||||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||||
|
"""Atomically save a complete config.
|
||||||
|
|
||||||
|
New read-modify-write flows should use :func:`update_config` so concurrent
|
||||||
|
writers for the same file cannot silently overwrite one another.
|
||||||
"""
|
"""
|
||||||
Save configuration to file.
|
get_config_repository(config_path).save(config)
|
||||||
|
|
||||||
Args:
|
|
||||||
config: Configuration to save.
|
|
||||||
config_path: Optional path to save to. Uses default if not provided.
|
|
||||||
"""
|
|
||||||
path = config_path or get_config_path()
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
data = config.model_dump(mode="json", by_alias=True)
|
def update_config(
|
||||||
if config.providers.openai_codex.proxy is not None:
|
mutator: Callable[[Config], None],
|
||||||
data.setdefault("providers", {})["openaiCodex"] = {
|
config_path: Path | None = None,
|
||||||
"proxy": config.providers.openai_codex.proxy,
|
*,
|
||||||
}
|
expected_revision: str | None = None,
|
||||||
|
) -> ConfigCommit:
|
||||||
|
"""Atomically mutate the latest raw config and return the resulting commit."""
|
||||||
|
return get_config_repository(config_path).update(
|
||||||
|
mutator,
|
||||||
|
expected_revision=expected_revision,
|
||||||
|
)
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
def apply_config_runtime_policies(config: Config) -> None:
|
||||||
|
"""Apply process-level policies when starting or reconfiguring a runtime."""
|
||||||
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
|
configure_ssrf_whitelist(config.tools.ssrf_whitelist)
|
||||||
|
|
||||||
|
|
||||||
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||||
@@ -103,109 +87,14 @@ def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
__all__ = [
|
||||||
|
"apply_config_runtime_policies",
|
||||||
|
"get_config_path",
|
||||||
def resolve_config_env_vars(config: Config) -> Config:
|
"get_config_repository",
|
||||||
"""Return *config* with ``${VAR}`` env-var references resolved.
|
"load_raw_config",
|
||||||
|
"load_effective_config",
|
||||||
Walks in place so fields declared with ``exclude=True`` survive;
|
"merge_missing_defaults",
|
||||||
returns the same instance when no references are present.
|
"save_config",
|
||||||
Raises ``ValueError`` if a referenced variable is not set.
|
"set_config_path",
|
||||||
"""
|
"update_config",
|
||||||
return _resolve_in_place(config)
|
]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_in_place(obj: Any) -> Any:
|
|
||||||
if isinstance(obj, str):
|
|
||||||
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
|
||||||
return new if new != obj else obj
|
|
||||||
if isinstance(obj, BaseModel):
|
|
||||||
updates: dict[str, Any] = {}
|
|
||||||
for name in type(obj).model_fields:
|
|
||||||
old = getattr(obj, name)
|
|
||||||
new = _resolve_in_place(old)
|
|
||||||
if new is not old:
|
|
||||||
updates[name] = new
|
|
||||||
extras = obj.__pydantic_extra__
|
|
||||||
new_extras: dict[str, Any] | None = None
|
|
||||||
if extras:
|
|
||||||
resolved = {k: _resolve_in_place(v) for k, v in extras.items()}
|
|
||||||
if any(resolved[k] is not extras[k] for k in extras):
|
|
||||||
new_extras = resolved
|
|
||||||
if not updates and new_extras is None:
|
|
||||||
return obj
|
|
||||||
copy = obj.model_copy(update=updates) if updates else obj.model_copy()
|
|
||||||
if new_extras is not None:
|
|
||||||
copy.__pydantic_extra__ = new_extras
|
|
||||||
return copy
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
resolved = {k: _resolve_in_place(v) for k, v in obj.items()}
|
|
||||||
return resolved if any(resolved[k] is not obj[k] for k in obj) else obj
|
|
||||||
if isinstance(obj, list):
|
|
||||||
resolved = [_resolve_in_place(v) for v in obj]
|
|
||||||
return resolved if any(nv is not ov for nv, ov in zip(resolved, obj)) else obj
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_env_vars(obj: object) -> object:
|
|
||||||
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
|
||||||
if isinstance(obj, str):
|
|
||||||
return _ENV_REF_PATTERN.sub(_env_replace, obj)
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: _resolve_env_vars(v) for k, v in obj.items()}
|
|
||||||
if isinstance(obj, list):
|
|
||||||
return [_resolve_env_vars(v) for v in obj]
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
def _env_replace(match: re.Match[str]) -> str:
|
|
||||||
name = match.group(1)
|
|
||||||
value = os.environ.get(name)
|
|
||||||
if value is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"Environment variable '{name}' referenced in config is not set"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
|
||||||
"""Migrate old config formats to current."""
|
|
||||||
agents = data.get("agents", {})
|
|
||||||
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
|
||||||
if isinstance(defaults, dict):
|
|
||||||
had_legacy_max_messages = (
|
|
||||||
"maxMessages" in defaults or "max_messages" in defaults
|
|
||||||
)
|
|
||||||
defaults.pop("maxMessages", None)
|
|
||||||
defaults.pop("max_messages", None)
|
|
||||||
if had_legacy_max_messages:
|
|
||||||
# TODO(next version): Remove this legacy cleanup branch; the schema
|
|
||||||
# will silently ignore this field once the warning grace period ends.
|
|
||||||
logger.warning(
|
|
||||||
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
|
||||||
"replay max messages is now an internal safety cap. Remove it from "
|
|
||||||
"config. This compatibility warning will be removed in the next version."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
|
||||||
tools = data.get("tools", {})
|
|
||||||
exec_cfg = tools.get("exec", {})
|
|
||||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
|
||||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
|
||||||
|
|
||||||
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
|
||||||
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
|
||||||
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
|
|
||||||
if "myEnabled" in tools or "mySet" in tools:
|
|
||||||
my_cfg = tools.setdefault("my", {})
|
|
||||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
|
||||||
my_cfg["enable"] = tools.pop("myEnabled")
|
|
||||||
else:
|
|
||||||
tools.pop("myEnabled", None)
|
|
||||||
if "mySet" in tools and "allowSet" not in my_cfg:
|
|
||||||
my_cfg["allowSet"] = tools.pop("mySet")
|
|
||||||
else:
|
|
||||||
tools.pop("mySet", None)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""Instance-scoped configuration persistence and resolution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
ConfigMutator = Callable[[Config], None]
|
||||||
|
|
||||||
|
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
|
# Several WebUI config actions run in worker threads via asyncio.to_thread().
|
||||||
|
# Repository instances for the same path therefore share a process-local lock.
|
||||||
|
_path_locks_guard = threading.Lock()
|
||||||
|
_path_locks: dict[Path, threading.RLock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigConflictError(RuntimeError):
|
||||||
|
"""Raised when a caller tries to update a stale configuration snapshot."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PersistedConfigSnapshot:
|
||||||
|
"""Validated configuration as represented by the persisted source."""
|
||||||
|
|
||||||
|
config: Config
|
||||||
|
path: Path
|
||||||
|
revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EffectiveConfigSnapshot:
|
||||||
|
"""Runtime configuration with environment references resolved."""
|
||||||
|
|
||||||
|
config: Config
|
||||||
|
path: Path
|
||||||
|
revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConfigCommit:
|
||||||
|
"""One atomic configuration update and its semantic field changes."""
|
||||||
|
|
||||||
|
before: PersistedConfigSnapshot
|
||||||
|
after: PersistedConfigSnapshot
|
||||||
|
changed_paths: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_for_path(path: Path) -> threading.RLock:
|
||||||
|
key = path.expanduser().resolve(strict=False)
|
||||||
|
with _path_locks_guard:
|
||||||
|
return _path_locks.setdefault(key, threading.RLock())
|
||||||
|
|
||||||
|
|
||||||
|
def _revision_for_bytes(raw: bytes | None) -> str:
|
||||||
|
if raw is None:
|
||||||
|
return "missing"
|
||||||
|
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _config_data(config: Config) -> dict[str, Any]:
|
||||||
|
data = config.model_dump(mode="json", by_alias=True)
|
||||||
|
if config.providers.openai_codex.proxy is not None:
|
||||||
|
data.setdefault("providers", {})["openaiCodex"] = {
|
||||||
|
"proxy": config.providers.openai_codex.proxy,
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config_data(data: dict[str, Any], path: Path) -> Config:
|
||||||
|
try:
|
||||||
|
return Config.model_validate(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Failed to load config from {path}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _read_snapshot(path: Path) -> PersistedConfigSnapshot:
|
||||||
|
if not path.exists():
|
||||||
|
return PersistedConfigSnapshot(Config(), path, "missing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("config root must be a JSON object")
|
||||||
|
data = _migrate_config(data)
|
||||||
|
config = Config.model_validate(data)
|
||||||
|
except (UnicodeDecodeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"Failed to load config from {path}: {exc}") from exc
|
||||||
|
return PersistedConfigSnapshot(config, path, _revision_for_bytes(raw))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_config_atomic(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
content = json.dumps(data, indent=2, ensure_ascii=False)
|
||||||
|
tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||||
|
existing_mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else None
|
||||||
|
try:
|
||||||
|
with open(tmp, "w", encoding="utf-8") as handle:
|
||||||
|
if existing_mode is not None:
|
||||||
|
try:
|
||||||
|
os.chmod(tmp, existing_mode)
|
||||||
|
except OSError:
|
||||||
|
if os.name != "nt":
|
||||||
|
raise
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp, path)
|
||||||
|
with suppress(OSError, NotImplementedError):
|
||||||
|
directory_fd = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(directory_fd)
|
||||||
|
finally:
|
||||||
|
os.close(directory_fd)
|
||||||
|
finally:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_paths(before: Any, after: Any, prefix: str = "") -> set[str]:
|
||||||
|
if isinstance(before, dict) and isinstance(after, dict):
|
||||||
|
changed: set[str] = set()
|
||||||
|
for key in before.keys() | after.keys():
|
||||||
|
path = f"{prefix}.{key}" if prefix else str(key)
|
||||||
|
if key not in before or key not in after:
|
||||||
|
changed.add(path)
|
||||||
|
else:
|
||||||
|
changed.update(_changed_paths(before[key], after[key], path))
|
||||||
|
return changed
|
||||||
|
if before != after:
|
||||||
|
return {prefix} if prefix else {"<root>"}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
class FileConfigRepository:
|
||||||
|
"""Read and atomically update one configuration file.
|
||||||
|
|
||||||
|
The repository does not cache. Every read returns a new validated snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = Path(path).expanduser().resolve(strict=False)
|
||||||
|
self._lock = _lock_for_path(self.path)
|
||||||
|
|
||||||
|
def load_raw(self) -> PersistedConfigSnapshot:
|
||||||
|
"""Load the persisted form without resolving secret references."""
|
||||||
|
with self._lock:
|
||||||
|
return _read_snapshot(self.path)
|
||||||
|
|
||||||
|
def load_effective(self) -> EffectiveConfigSnapshot:
|
||||||
|
"""Load an isolated runtime snapshot with ``${VAR}`` references resolved."""
|
||||||
|
raw = self.load_raw()
|
||||||
|
effective = resolve_config_env_vars(raw.config.model_copy(deep=True))
|
||||||
|
return EffectiveConfigSnapshot(effective, raw.path, raw.revision)
|
||||||
|
|
||||||
|
def save(
|
||||||
|
self,
|
||||||
|
config: Config,
|
||||||
|
*,
|
||||||
|
expected_revision: str | None = None,
|
||||||
|
) -> PersistedConfigSnapshot:
|
||||||
|
"""Atomically save a complete config, optionally rejecting stale writes."""
|
||||||
|
with self._lock:
|
||||||
|
current = _read_snapshot(self.path)
|
||||||
|
if expected_revision is not None and current.revision != expected_revision:
|
||||||
|
raise ConfigConflictError(
|
||||||
|
f"Config changed since revision {expected_revision}; "
|
||||||
|
f"current revision is {current.revision}"
|
||||||
|
)
|
||||||
|
data = _config_data(config)
|
||||||
|
_validate_config_data(data, self.path)
|
||||||
|
_write_config_atomic(self.path, data)
|
||||||
|
return _read_snapshot(self.path)
|
||||||
|
|
||||||
|
def update(
|
||||||
|
self,
|
||||||
|
mutator: ConfigMutator,
|
||||||
|
*,
|
||||||
|
expected_revision: str | None = None,
|
||||||
|
) -> ConfigCommit:
|
||||||
|
"""Atomically apply a mutation to the latest persisted config."""
|
||||||
|
with self._lock:
|
||||||
|
before = _read_snapshot(self.path)
|
||||||
|
if expected_revision is not None and before.revision != expected_revision:
|
||||||
|
raise ConfigConflictError(
|
||||||
|
f"Config changed since revision {expected_revision}; "
|
||||||
|
f"current revision is {before.revision}"
|
||||||
|
)
|
||||||
|
|
||||||
|
before_data = _config_data(before.config)
|
||||||
|
draft = before.config.model_copy(deep=True)
|
||||||
|
mutator(draft)
|
||||||
|
after_data = _config_data(draft)
|
||||||
|
changed = frozenset(_changed_paths(before_data, after_data))
|
||||||
|
if not changed:
|
||||||
|
return ConfigCommit(before, before, changed)
|
||||||
|
|
||||||
|
_validate_config_data(after_data, self.path)
|
||||||
|
_write_config_atomic(self.path, after_data)
|
||||||
|
after = _read_snapshot(self.path)
|
||||||
|
return ConfigCommit(before, after, changed)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_config_env_vars(config: Config) -> Config:
|
||||||
|
"""Return *config* with ``${VAR}`` environment references resolved."""
|
||||||
|
return _resolve_in_place(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_in_place(obj: Any) -> Any:
|
||||||
|
if isinstance(obj, str):
|
||||||
|
new = _ENV_REF_PATTERN.sub(_env_replace, obj)
|
||||||
|
return new if new != obj else obj
|
||||||
|
if isinstance(obj, BaseModel):
|
||||||
|
updates: dict[str, Any] = {}
|
||||||
|
for name in type(obj).model_fields:
|
||||||
|
old = getattr(obj, name)
|
||||||
|
new = _resolve_in_place(old)
|
||||||
|
if new is not old:
|
||||||
|
updates[name] = new
|
||||||
|
extras = obj.__pydantic_extra__
|
||||||
|
new_extras: dict[str, Any] | None = None
|
||||||
|
if extras:
|
||||||
|
resolved = {key: _resolve_in_place(value) for key, value in extras.items()}
|
||||||
|
if any(resolved[key] is not extras[key] for key in extras):
|
||||||
|
new_extras = resolved
|
||||||
|
if not updates and new_extras is None:
|
||||||
|
return obj
|
||||||
|
copy = obj.model_copy(update=updates) if updates else obj.model_copy()
|
||||||
|
if new_extras is not None:
|
||||||
|
copy.__pydantic_extra__ = new_extras
|
||||||
|
return copy
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
resolved = {key: _resolve_in_place(value) for key, value in obj.items()}
|
||||||
|
return resolved if any(resolved[key] is not obj[key] for key in obj) else obj
|
||||||
|
if isinstance(obj, list):
|
||||||
|
resolved = [_resolve_in_place(value) for value in obj]
|
||||||
|
return resolved if any(new is not old for new, old in zip(resolved, obj)) else obj
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _env_replace(match: re.Match[str]) -> str:
|
||||||
|
name = match.group(1)
|
||||||
|
value = os.environ.get(name)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"Environment variable '{name}' referenced in config is not set")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_config(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Migrate old config formats to the current schema."""
|
||||||
|
agents = data.get("agents", {})
|
||||||
|
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
had_legacy_max_messages = "maxMessages" in defaults or "max_messages" in defaults
|
||||||
|
defaults.pop("maxMessages", None)
|
||||||
|
defaults.pop("max_messages", None)
|
||||||
|
if had_legacy_max_messages:
|
||||||
|
logger.warning(
|
||||||
|
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||||
|
"replay max messages is now an internal safety cap. Remove it from "
|
||||||
|
"config. This compatibility warning will be removed in the next version."
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = data.get("tools", {})
|
||||||
|
if not isinstance(tools, dict):
|
||||||
|
return data
|
||||||
|
exec_cfg = tools.get("exec", {})
|
||||||
|
if isinstance(exec_cfg, dict) and "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||||||
|
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||||
|
|
||||||
|
if "myEnabled" in tools or "mySet" in tools:
|
||||||
|
my_cfg = tools.setdefault("my", {})
|
||||||
|
if not isinstance(my_cfg, dict):
|
||||||
|
return data
|
||||||
|
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||||
|
my_cfg["enable"] = tools.pop("myEnabled")
|
||||||
|
else:
|
||||||
|
tools.pop("myEnabled", None)
|
||||||
|
if "mySet" in tools and "allowSet" not in my_cfg:
|
||||||
|
my_cfg["allowSet"] = tools.pop("mySet")
|
||||||
|
else:
|
||||||
|
tools.pop("mySet", None)
|
||||||
|
|
||||||
|
return data
|
||||||
+7
-2
@@ -95,7 +95,10 @@ class Nanobot:
|
|||||||
model: Override the instance default model.
|
model: Override the instance default model.
|
||||||
model_preset: Override the instance default model preset.
|
model_preset: Override the instance default model preset.
|
||||||
"""
|
"""
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import (
|
||||||
|
apply_config_runtime_policies,
|
||||||
|
load_effective_config,
|
||||||
|
)
|
||||||
|
|
||||||
ensure_single_model_selector(model=model, model_preset=model_preset)
|
ensure_single_model_selector(model=model, model_preset=model_preset)
|
||||||
resolved: Path | None = None
|
resolved: Path | None = None
|
||||||
@@ -104,7 +107,7 @@ class Nanobot:
|
|||||||
if not resolved.exists():
|
if not resolved.exists():
|
||||||
raise FileNotFoundError(f"Config not found: {resolved}")
|
raise FileNotFoundError(f"Config not found: {resolved}")
|
||||||
|
|
||||||
config: Config = resolve_config_env_vars(load_config(resolved))
|
config: Config = load_effective_config(resolved)
|
||||||
if workspace is not None:
|
if workspace is not None:
|
||||||
config.agents.defaults.workspace = str(
|
config.agents.defaults.workspace = str(
|
||||||
Path(workspace).expanduser().resolve()
|
Path(workspace).expanduser().resolve()
|
||||||
@@ -116,6 +119,8 @@ class Nanobot:
|
|||||||
elif model_preset is not None:
|
elif model_preset is not None:
|
||||||
config.agents.defaults.model_preset = model_preset
|
config.agents.defaults.model_preset = model_preset
|
||||||
|
|
||||||
|
apply_config_runtime_policies(config)
|
||||||
|
|
||||||
loop = AgentLoop.from_config(
|
loop = AgentLoop.from_config(
|
||||||
config,
|
config,
|
||||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from nanobot.channels._setup import (
|
|||||||
stringify_channel_value,
|
stringify_channel_value,
|
||||||
)
|
)
|
||||||
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
||||||
from nanobot.config.loader import merge_missing_defaults
|
from nanobot.config.loader import get_config_repository, merge_missing_defaults
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
@@ -266,29 +266,16 @@ def install_extra(
|
|||||||
return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output)
|
return InstallResult(False, label, pip_cmd, failed_cmd=failed_cmd, output=output)
|
||||||
|
|
||||||
|
|
||||||
def read_config_data(path: Path) -> dict[str, Any]:
|
|
||||||
if not path.exists():
|
|
||||||
return {}
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def write_config_data(path: Path, data: dict[str, Any]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
|
|
||||||
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
|
def enable_channel_config(config_path: Path, channel_name: str, defaults: dict[str, Any]) -> None:
|
||||||
data = read_config_data(config_path)
|
def mutate(config: Config) -> None:
|
||||||
channels = data.setdefault("channels", {})
|
existing = getattr(config.channels, channel_name, {})
|
||||||
existing = channels.get(channel_name, {})
|
if not isinstance(existing, dict):
|
||||||
if not isinstance(existing, dict):
|
existing = {}
|
||||||
existing = {}
|
merged = merge_missing_defaults(existing, defaults)
|
||||||
merged = merge_missing_defaults(existing, defaults)
|
merged["enabled"] = True
|
||||||
merged["enabled"] = True
|
setattr(config.channels, channel_name, merged)
|
||||||
channels[channel_name] = merged
|
|
||||||
write_config_data(config_path, data)
|
get_config_repository(config_path).update(mutate)
|
||||||
|
|
||||||
|
|
||||||
def enable_feishu_instance_config(
|
def enable_feishu_instance_config(
|
||||||
@@ -297,24 +284,29 @@ def enable_feishu_instance_config(
|
|||||||
*,
|
*,
|
||||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = read_config_data(config_path)
|
def mutate(config: Config) -> None:
|
||||||
channels = data.setdefault("channels", {})
|
existing = getattr(config.channels, "feishu", {})
|
||||||
existing = channels.get("feishu", {})
|
if not isinstance(existing, dict):
|
||||||
if not isinstance(existing, dict):
|
existing = {}
|
||||||
existing = {}
|
config.channels.feishu = set_feishu_instance_enabled(
|
||||||
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, True)
|
existing,
|
||||||
write_config_data(config_path, data)
|
defaults,
|
||||||
|
instance_id,
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_config_repository(config_path).update(mutate)
|
||||||
|
|
||||||
|
|
||||||
def disable_channel_config(config_path: Path, channel_name: str) -> None:
|
def disable_channel_config(config_path: Path, channel_name: str) -> None:
|
||||||
data = read_config_data(config_path)
|
def mutate(config: Config) -> None:
|
||||||
channels = data.setdefault("channels", {})
|
existing = getattr(config.channels, channel_name, {})
|
||||||
existing = channels.get(channel_name, {})
|
if not isinstance(existing, dict):
|
||||||
if not isinstance(existing, dict):
|
existing = {}
|
||||||
existing = {}
|
existing["enabled"] = False
|
||||||
existing["enabled"] = False
|
setattr(config.channels, channel_name, existing)
|
||||||
channels[channel_name] = existing
|
|
||||||
write_config_data(config_path, data)
|
get_config_repository(config_path).update(mutate)
|
||||||
|
|
||||||
|
|
||||||
def disable_feishu_instance_config(
|
def disable_feishu_instance_config(
|
||||||
@@ -323,13 +315,18 @@ def disable_feishu_instance_config(
|
|||||||
*,
|
*,
|
||||||
instance_id: str = DEFAULT_INSTANCE_ID,
|
instance_id: str = DEFAULT_INSTANCE_ID,
|
||||||
) -> None:
|
) -> None:
|
||||||
data = read_config_data(config_path)
|
def mutate(config: Config) -> None:
|
||||||
channels = data.setdefault("channels", {})
|
existing = getattr(config.channels, "feishu", {})
|
||||||
existing = channels.get("feishu", {})
|
if not isinstance(existing, dict):
|
||||||
if not isinstance(existing, dict):
|
existing = {}
|
||||||
existing = {}
|
config.channels.feishu = set_feishu_instance_enabled(
|
||||||
channels["feishu"] = set_feishu_instance_enabled(existing, defaults, instance_id, False)
|
existing,
|
||||||
write_config_data(config_path, data)
|
defaults,
|
||||||
|
instance_id,
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
|
||||||
|
get_config_repository(config_path).update(mutate)
|
||||||
|
|
||||||
|
|
||||||
def channel_enabled(config: Config, name: str) -> bool:
|
def channel_enabled(config: Config, name: str) -> bool:
|
||||||
@@ -443,16 +440,16 @@ def optional_features_payload(
|
|||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
from nanobot.channels.registry import discover_channel_names, discover_plugins
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
config_provided = config is not None
|
config_provided = config is not None
|
||||||
config = config or load_config()
|
config = config or load_raw_config()
|
||||||
if not config_provided:
|
if not config_provided:
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
from nanobot.channels.feishu import refresh_saved_feishu_identities
|
from nanobot.channels.feishu import refresh_saved_feishu_identities
|
||||||
|
|
||||||
if refresh_saved_feishu_identities(config):
|
if refresh_saved_feishu_identities(config):
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
builtin_channels = set(discover_channel_names())
|
builtin_channels = set(discover_channel_names())
|
||||||
plugin_channels = discover_plugins()
|
plugin_channels = discover_plugins()
|
||||||
|
|||||||
@@ -278,9 +278,9 @@ def load_provider_snapshot(
|
|||||||
*,
|
*,
|
||||||
preset_name: str | None = None,
|
preset_name: str | None = None,
|
||||||
) -> ProviderSnapshot:
|
) -> ProviderSnapshot:
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
from nanobot.config.loader import load_effective_config
|
||||||
|
|
||||||
return build_provider_snapshot(
|
return build_provider_snapshot(
|
||||||
resolve_config_env_vars(load_config(config_path)),
|
load_effective_config(config_path),
|
||||||
preset_name=preset_name,
|
preset_name=preset_name,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import httpx
|
|||||||
|
|
||||||
from nanobot.channels import feishu
|
from nanobot.channels import feishu
|
||||||
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id
|
from nanobot.channels._feishu_instances import DEFAULT_INSTANCE_ID, validate_instance_id
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
|
|
||||||
class ChannelConnectError(Exception):
|
class ChannelConnectError(Exception):
|
||||||
@@ -394,7 +394,7 @@ class WeixinConnectStore:
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.weixin import WeixinChannel
|
from nanobot.channels.weixin import WeixinChannel
|
||||||
|
|
||||||
section = getattr(load_config().channels, "weixin", None)
|
section = getattr(load_raw_config().channels, "weixin", None)
|
||||||
if hasattr(section, "model_dump"):
|
if hasattr(section, "model_dump"):
|
||||||
config = section.model_dump(mode="json", by_alias=True)
|
config = section.model_dump(mode="json", by_alias=True)
|
||||||
elif isinstance(section, dict):
|
elif isinstance(section, dict):
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from nanobot.channels._setup import channel_setup_spec
|
from nanobot.channels._setup import channel_setup_spec
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
from nanobot.security.network import resolve_url_target
|
from nanobot.security.network import resolve_url_target
|
||||||
|
|
||||||
CheckStatus = str
|
CheckStatus = str
|
||||||
@@ -42,7 +42,7 @@ def validate_channel_config(
|
|||||||
if not channel:
|
if not channel:
|
||||||
return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")])
|
return _payload("unknown", "unsupported", [_check("channel", "Channel", "fail", "Missing channel name")])
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
section = getattr(config.channels, channel, None)
|
section = getattr(config.channels, channel, None)
|
||||||
values = _channel_config(channel, section, instance_id=instance_id)
|
values = _channel_config(channel, section, instance_id=instance_id)
|
||||||
values = _merge_form_values(channel, values, raw_values or {})
|
values = _merge_form_values(channel, values, raw_values or {})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _manager() -> CliAppManager:
|
def _manager() -> CliAppManager:
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ from typing import Any, Literal, Mapping
|
|||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_effective_config, load_raw_config, update_config
|
||||||
from nanobot.config.paths import get_runtime_subdir
|
from nanobot.config.paths import get_runtime_subdir
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import Config, MCPServerConfig
|
||||||
from nanobot.utils.helpers import ensure_dir
|
from nanobot.utils.helpers import ensure_dir
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
@@ -416,7 +416,7 @@ def _known_preset_names() -> set[str]:
|
|||||||
def _known_mcp_names() -> set[str]:
|
def _known_mcp_names() -> set[str]:
|
||||||
names = _known_preset_names()
|
names = _known_preset_names()
|
||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
names.update(load_config().tools.mcp_servers)
|
names.update(load_raw_config().tools.mcp_servers)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
@@ -822,7 +822,7 @@ def mcp_presets_payload(
|
|||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -921,7 +921,7 @@ async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = resolve_config_env_vars(load_config())
|
config = load_effective_config()
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(last_action={
|
return mcp_presets_payload(last_action={
|
||||||
"ok": False,
|
"ok": False,
|
||||||
@@ -1180,19 +1180,16 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
config = load_config()
|
|
||||||
if action == "custom":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
name, cfg = _custom_server_from_query(query)
|
||||||
config.tools.mcp_servers[name] = cfg
|
update_config(lambda config: config.tools.mcp_servers.__setitem__(name, cfg))
|
||||||
save_config(config)
|
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||||
config.tools.mcp_servers.update(servers)
|
update_config(lambda config: config.tools.mcp_servers.update(servers))
|
||||||
save_config(config)
|
|
||||||
payload = mcp_presets_payload(last_action={
|
payload = mcp_presets_payload(last_action={
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
@@ -1202,12 +1199,15 @@ def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
|
|
||||||
if action == "tools":
|
if action == "tools":
|
||||||
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
name = _validated_server_name((_query_first(query, "name") or "").strip())
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
|
||||||
if cfg is None:
|
def mutate(config: Config) -> None:
|
||||||
raise McpPresetError("unknown MCP server", status=404)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
if cfg is None:
|
||||||
config.tools.mcp_servers[name] = cfg
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
save_config(config)
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
|
config.tools.mcp_servers[name] = cfg
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
@@ -1221,31 +1221,42 @@ def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
|||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(name)
|
preset = _preset_by_name_optional(name)
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
existing = config.tools.mcp_servers.get(name)
|
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
|
||||||
save_config(config)
|
def mutate(config: Config) -> None:
|
||||||
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
config.tools.mcp_servers[preset.name] = _materialize_server(
|
||||||
|
preset,
|
||||||
|
query,
|
||||||
|
existing,
|
||||||
|
)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action == "remove":
|
if action == "remove":
|
||||||
if preset is None and name not in config.tools.mcp_servers:
|
|
||||||
raise McpPresetError("unknown MCP server", status=404)
|
|
||||||
removed_runtime_files = False
|
removed_runtime_files = False
|
||||||
cleanup_error = ""
|
cleanup_error = ""
|
||||||
if name in config.tools.mcp_servers:
|
removed: dict[str, MCPServerConfig] = {}
|
||||||
existing_cfg = config.tools.mcp_servers[name]
|
|
||||||
|
def mutate(config: Config) -> None:
|
||||||
|
existing_cfg = config.tools.mcp_servers.get(name)
|
||||||
|
if preset is None and existing_cfg is None:
|
||||||
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
|
if existing_cfg is not None:
|
||||||
|
removed[name] = existing_cfg
|
||||||
|
del config.tools.mcp_servers[name]
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
|
if existing_cfg := removed.get(name):
|
||||||
try:
|
try:
|
||||||
removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg)
|
removed_runtime_files = _remove_managed_stdio_cwd(name, existing_cfg)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
|
||||||
save_config(config)
|
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
|
|||||||
+266
-294
@@ -22,8 +22,13 @@ from nanobot.audio.transcription_registry import (
|
|||||||
resolve_transcription_provider,
|
resolve_transcription_provider,
|
||||||
transcription_provider_names,
|
transcription_provider_names,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import (
|
||||||
from nanobot.config.schema import ModelPresetConfig, ProviderConfig
|
get_config_path,
|
||||||
|
load_effective_config,
|
||||||
|
load_raw_config,
|
||||||
|
update_config,
|
||||||
|
)
|
||||||
|
from nanobot.config.schema import Config, ModelPresetConfig, ProviderConfig
|
||||||
from nanobot.providers.image_generation import (
|
from nanobot.providers.image_generation import (
|
||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
image_gen_provider_names,
|
image_gen_provider_names,
|
||||||
@@ -512,7 +517,7 @@ def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -749,7 +754,7 @@ def settings_payload(
|
|||||||
restart_required_sections: list[str] | None = None,
|
restart_required_sections: list[str] | None = None,
|
||||||
apply_state: dict[str, Any] | None = None,
|
apply_state: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
try:
|
try:
|
||||||
@@ -956,105 +961,91 @@ def settings_payload(
|
|||||||
|
|
||||||
def settings_usage_payload() -> dict[str, Any]:
|
def settings_usage_payload() -> dict[str, Any]:
|
||||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
config = load_config()
|
|
||||||
defaults = config.agents.defaults
|
|
||||||
changed = False
|
|
||||||
restart_required = False
|
restart_required = False
|
||||||
|
|
||||||
if "model_preset" in query or "modelPreset" in query:
|
def mutate(config: Config) -> None:
|
||||||
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
nonlocal restart_required
|
||||||
preset_value = None if not preset or preset == "default" else preset
|
defaults = config.agents.defaults
|
||||||
if preset_value is not None and preset_value not in config.model_presets:
|
|
||||||
raise WebUISettingsError("unknown model preset")
|
if "model_preset" in query or "modelPreset" in query:
|
||||||
if defaults.model_preset != preset_value:
|
preset = (_query_first_alias(query, "model_preset", "modelPreset") or "").strip()
|
||||||
|
preset_value = None if not preset or preset == "default" else preset
|
||||||
|
if preset_value is not None and preset_value not in config.model_presets:
|
||||||
|
raise WebUISettingsError("unknown model preset")
|
||||||
defaults.model_preset = preset_value
|
defaults.model_preset = preset_value
|
||||||
changed = True
|
|
||||||
|
|
||||||
model = _query_first(query, "model")
|
model = _query_first(query, "model")
|
||||||
if model is not None:
|
if model is not None:
|
||||||
model = model.strip()
|
model = model.strip()
|
||||||
if not model:
|
if not model:
|
||||||
raise WebUISettingsError("model is required")
|
raise WebUISettingsError("model is required")
|
||||||
if defaults.model != model:
|
|
||||||
defaults.model = model
|
defaults.model = model
|
||||||
changed = True
|
|
||||||
|
|
||||||
provider = _query_first(query, "provider")
|
provider = _query_first(query, "provider")
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
provider = provider.strip()
|
provider = provider.strip()
|
||||||
if not provider:
|
if not provider:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
if defaults.provider != provider:
|
|
||||||
defaults.provider = provider
|
defaults.provider = provider
|
||||||
changed = True
|
|
||||||
|
|
||||||
context_window_tokens = _parse_context_window_tokens(
|
context_window_tokens = _parse_context_window_tokens(
|
||||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
||||||
)
|
)
|
||||||
if (
|
if context_window_tokens is not None:
|
||||||
context_window_tokens is not None
|
defaults.context_window_tokens = context_window_tokens
|
||||||
and defaults.context_window_tokens != context_window_tokens
|
|
||||||
):
|
|
||||||
defaults.context_window_tokens = context_window_tokens
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
timezone = _query_first(query, "timezone")
|
timezone = _query_first(query, "timezone")
|
||||||
if timezone is not None:
|
if timezone is not None:
|
||||||
timezone = timezone.strip()
|
timezone = timezone.strip()
|
||||||
if not timezone:
|
if not timezone:
|
||||||
raise WebUISettingsError("timezone is required")
|
raise WebUISettingsError("timezone is required")
|
||||||
try:
|
try:
|
||||||
ZoneInfo(timezone)
|
ZoneInfo(timezone)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise WebUISettingsError("invalid timezone") from None
|
raise WebUISettingsError("invalid timezone") from None
|
||||||
if defaults.timezone != timezone:
|
if defaults.timezone != timezone:
|
||||||
defaults.timezone = timezone
|
defaults.timezone = timezone
|
||||||
changed = True
|
restart_required = True
|
||||||
restart_required = True
|
|
||||||
|
|
||||||
bot_name = _query_first_alias(query, "bot_name", "botName")
|
bot_name = _query_first_alias(query, "bot_name", "botName")
|
||||||
if bot_name is not None:
|
if bot_name is not None:
|
||||||
bot_name = bot_name.strip()
|
bot_name = bot_name.strip()
|
||||||
if not bot_name:
|
if not bot_name:
|
||||||
raise WebUISettingsError("bot_name is required")
|
raise WebUISettingsError("bot_name is required")
|
||||||
if defaults.bot_name != bot_name:
|
if defaults.bot_name != bot_name:
|
||||||
defaults.bot_name = bot_name
|
defaults.bot_name = bot_name
|
||||||
changed = True
|
restart_required = True
|
||||||
restart_required = True
|
|
||||||
|
|
||||||
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
|
bot_icon = _query_first_alias(query, "bot_icon", "botIcon")
|
||||||
if bot_icon is not None:
|
if bot_icon is not None:
|
||||||
bot_icon = bot_icon.strip()
|
bot_icon = bot_icon.strip()
|
||||||
if defaults.bot_icon != bot_icon:
|
if defaults.bot_icon != bot_icon:
|
||||||
defaults.bot_icon = bot_icon
|
defaults.bot_icon = bot_icon
|
||||||
changed = True
|
restart_required = True
|
||||||
restart_required = True
|
|
||||||
|
|
||||||
tool_hint_max_length = _query_first_alias(
|
tool_hint_max_length = _query_first_alias(
|
||||||
query,
|
query,
|
||||||
"tool_hint_max_length",
|
"tool_hint_max_length",
|
||||||
"toolHintMaxLength",
|
"toolHintMaxLength",
|
||||||
)
|
)
|
||||||
if tool_hint_max_length is not None:
|
if tool_hint_max_length is not None:
|
||||||
try:
|
try:
|
||||||
parsed = int(tool_hint_max_length)
|
parsed = int(tool_hint_max_length)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise WebUISettingsError("tool_hint_max_length must be an integer") from None
|
raise WebUISettingsError("tool_hint_max_length must be an integer") from None
|
||||||
if parsed < 20 or parsed > 500:
|
if parsed < 20 or parsed > 500:
|
||||||
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
|
raise WebUISettingsError("tool_hint_max_length must be between 20 and 500")
|
||||||
if defaults.tool_hint_max_length != parsed:
|
if defaults.tool_hint_max_length != parsed:
|
||||||
defaults.tool_hint_max_length = parsed
|
defaults.tool_hint_max_length = parsed
|
||||||
changed = True
|
restart_required = True
|
||||||
restart_required = True
|
|
||||||
|
|
||||||
if changed:
|
update_config(mutate)
|
||||||
save_config(config)
|
|
||||||
return settings_payload(requires_restart=restart_required)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
@@ -1072,23 +1063,23 @@ def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
name = _model_configuration_slug(raw_name or label)
|
name = _model_configuration_slug(raw_name or label)
|
||||||
config = load_config()
|
def mutate(config: Config) -> None:
|
||||||
if name in config.model_presets:
|
if name in config.model_presets:
|
||||||
raise WebUISettingsError("configuration already exists", status=409)
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
|
base = config.resolve_default_preset()
|
||||||
|
config.model_presets[name] = ModelPresetConfig(
|
||||||
|
label=label,
|
||||||
|
model=model,
|
||||||
|
provider=provider,
|
||||||
|
max_tokens=base.max_tokens,
|
||||||
|
context_window_tokens=base.context_window_tokens,
|
||||||
|
temperature=base.temperature,
|
||||||
|
reasoning_effort=base.reasoning_effort,
|
||||||
|
)
|
||||||
|
config.agents.defaults.model_preset = name
|
||||||
|
|
||||||
base = config.resolve_default_preset()
|
update_config(mutate)
|
||||||
config.model_presets[name] = ModelPresetConfig(
|
|
||||||
label=label,
|
|
||||||
model=model,
|
|
||||||
provider=provider,
|
|
||||||
max_tokens=base.max_tokens,
|
|
||||||
context_window_tokens=base.context_window_tokens,
|
|
||||||
temperature=base.temperature,
|
|
||||||
reasoning_effort=base.reasoning_effort,
|
|
||||||
)
|
|
||||||
config.agents.defaults.model_preset = name
|
|
||||||
save_config(config)
|
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
@@ -1097,56 +1088,41 @@ def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = load_config()
|
def mutate(config: Config) -> None:
|
||||||
preset = config.model_presets.get(name)
|
preset = config.model_presets.get(name)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
|
|
||||||
changed = False
|
label = _query_first_alias(query, "label", "displayName")
|
||||||
label = _query_first_alias(query, "label", "displayName")
|
if label is not None:
|
||||||
if label is not None:
|
label = label.strip()
|
||||||
label = label.strip()
|
if not label:
|
||||||
if not label:
|
raise WebUISettingsError("label is required")
|
||||||
raise WebUISettingsError("label is required")
|
|
||||||
if preset.label != label:
|
|
||||||
preset.label = label
|
preset.label = label
|
||||||
changed = True
|
|
||||||
|
|
||||||
model = _query_first(query, "model")
|
model = _query_first(query, "model")
|
||||||
if model is not None:
|
if model is not None:
|
||||||
model = model.strip()
|
model = model.strip()
|
||||||
if not model:
|
if not model:
|
||||||
raise WebUISettingsError("model is required")
|
raise WebUISettingsError("model is required")
|
||||||
if preset.model != model:
|
|
||||||
preset.model = model
|
preset.model = model
|
||||||
changed = True
|
|
||||||
|
|
||||||
provider = _query_first(query, "provider")
|
provider = _query_first(query, "provider")
|
||||||
if provider is not None:
|
if provider is not None:
|
||||||
provider = provider.strip()
|
provider = provider.strip()
|
||||||
if not provider:
|
if not provider:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
if preset.provider != provider:
|
|
||||||
preset.provider = provider
|
preset.provider = provider
|
||||||
changed = True
|
|
||||||
|
|
||||||
context_window_tokens = _parse_context_window_tokens(
|
context_window_tokens = _parse_context_window_tokens(
|
||||||
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
_query_first_alias(query, "context_window_tokens", "contextWindowTokens")
|
||||||
)
|
)
|
||||||
if (
|
if context_window_tokens is not None:
|
||||||
context_window_tokens is not None
|
preset.context_window_tokens = context_window_tokens
|
||||||
and preset.context_window_tokens != context_window_tokens
|
|
||||||
):
|
|
||||||
preset.context_window_tokens = context_window_tokens
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if config.agents.defaults.model_preset != name:
|
|
||||||
config.agents.defaults.model_preset = name
|
config.agents.defaults.model_preset = name
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
update_config(mutate)
|
||||||
save_config(config)
|
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
@@ -1155,43 +1131,37 @@ def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = load_config()
|
provider_key = ""
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
|
||||||
if resolved_provider is None:
|
|
||||||
raise WebUISettingsError("unknown provider")
|
|
||||||
spec, provider_key, provider_config = resolved_provider
|
|
||||||
if spec.is_oauth:
|
|
||||||
raise WebUISettingsError("unknown provider")
|
|
||||||
|
|
||||||
changed = False
|
def mutate(config: Config) -> None:
|
||||||
if "api_key" in query or "apiKey" in query:
|
nonlocal provider_key
|
||||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
api_key = (api_key or "").strip() or None
|
if resolved_provider is None:
|
||||||
if provider_config.api_key != api_key:
|
raise WebUISettingsError("unknown provider")
|
||||||
provider_config.api_key = api_key
|
spec, provider_key, provider_config = resolved_provider
|
||||||
changed = True
|
if spec.is_oauth:
|
||||||
|
raise WebUISettingsError("unknown provider")
|
||||||
|
|
||||||
if "api_base" in query or "apiBase" in query:
|
if "api_key" in query or "apiKey" in query:
|
||||||
api_base = _query_first_alias(query, "api_base", "apiBase")
|
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||||
api_base = (api_base or "").strip() or None
|
provider_config.api_key = (api_key or "").strip() or None
|
||||||
if provider_config.api_base != api_base:
|
|
||||||
provider_config.api_base = api_base
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if "api_type" in query:
|
if "api_base" in query or "apiBase" in query:
|
||||||
if spec.name == "openai":
|
api_base = _query_first_alias(query, "api_base", "apiBase")
|
||||||
|
provider_config.api_base = (api_base or "").strip() or None
|
||||||
|
|
||||||
|
if "api_type" in query and spec.name == "openai":
|
||||||
api_type = (_query_first(query, "api_type") or "").strip()
|
api_type = (_query_first(query, "api_type") or "").strip()
|
||||||
try:
|
try:
|
||||||
parsed_api_type = type(provider_config)(api_type=api_type).api_type
|
provider_config.api_type = type(provider_config)(api_type=api_type).api_type
|
||||||
except Exception:
|
except Exception:
|
||||||
raise WebUISettingsError("api_type must be auto, chat_completions, or responses") from None
|
raise WebUISettingsError(
|
||||||
if provider_config.api_type != parsed_api_type:
|
"api_type must be auto, chat_completions, or responses"
|
||||||
provider_config.api_type = parsed_api_type
|
) from None
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
commit = update_config(mutate)
|
||||||
save_config(config)
|
changed = bool(commit.changed_paths)
|
||||||
image_config = config.tools.image_generation
|
image_config = commit.after.config.tools.image_generation
|
||||||
restart_required = (
|
restart_required = (
|
||||||
changed
|
changed
|
||||||
and image_config.enabled
|
and image_config.enabled
|
||||||
@@ -1218,7 +1188,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
proxy = load_effective_config().providers.openai_codex.proxy or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
token = None
|
token = None
|
||||||
@@ -1299,16 +1269,18 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if raw_allow is None and raw_default_access_mode is None:
|
if raw_allow is None and raw_default_access_mode is None:
|
||||||
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
changed = False
|
|
||||||
if raw_allow is not None:
|
if raw_allow is not None:
|
||||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
if config.tools.webui_allow_local_service_access != webui_allow_local_service_access:
|
commit = update_config(
|
||||||
config.tools.webui_allow_local_service_access = webui_allow_local_service_access
|
lambda config: setattr(
|
||||||
changed = True
|
config.tools,
|
||||||
|
"webui_allow_local_service_access",
|
||||||
if changed:
|
webui_allow_local_service_access,
|
||||||
save_config(config)
|
)
|
||||||
|
)
|
||||||
|
changed = bool(commit.changed_paths)
|
||||||
|
else:
|
||||||
|
changed = False
|
||||||
if raw_default_access_mode is not None:
|
if raw_default_access_mode is not None:
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
if default_access_mode == "restricted":
|
if default_access_mode == "restricted":
|
||||||
@@ -1328,134 +1300,120 @@ def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if provider_option is None:
|
if provider_option is None:
|
||||||
raise WebUISettingsError("unknown web search provider")
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
search_config = config.tools.web.search
|
|
||||||
web_config = config.tools.web
|
|
||||||
previous_provider = search_config.provider
|
|
||||||
changed = False
|
|
||||||
restart_required = False
|
restart_required = False
|
||||||
|
|
||||||
def set_search_value(attr: str, value: object) -> None:
|
def mutate(config: Config) -> None:
|
||||||
nonlocal changed
|
nonlocal restart_required
|
||||||
if getattr(search_config, attr) != value:
|
search_config = config.tools.web.search
|
||||||
setattr(search_config, attr, value)
|
web_config = config.tools.web
|
||||||
changed = True
|
previous_provider = search_config.provider
|
||||||
|
|
||||||
def set_fetch_value(attr: str, value: object) -> None:
|
|
||||||
nonlocal changed
|
|
||||||
if getattr(web_config.fetch, attr) != value:
|
|
||||||
setattr(web_config.fetch, attr, value)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if search_config.provider != provider_name:
|
|
||||||
search_config.provider = provider_name
|
search_config.provider = provider_name
|
||||||
changed = True
|
|
||||||
|
|
||||||
credential = provider_option["credential"]
|
credential = provider_option["credential"]
|
||||||
if credential == "none":
|
if credential == "none":
|
||||||
set_search_value("api_key", "")
|
search_config.api_key = ""
|
||||||
set_search_value("base_url", "")
|
search_config.base_url = ""
|
||||||
elif credential == "base_url":
|
elif credential == "base_url":
|
||||||
base_url = _query_first_alias(query, "base_url", "baseUrl")
|
base_url = _query_first_alias(query, "base_url", "baseUrl")
|
||||||
base_url = base_url.strip() if base_url is not None else None
|
base_url = base_url.strip() if base_url is not None else None
|
||||||
if not base_url and previous_provider == provider_name and search_config.base_url:
|
if not base_url and previous_provider == provider_name and search_config.base_url:
|
||||||
base_url = search_config.base_url
|
base_url = search_config.base_url
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise WebUISettingsError("base_url is required")
|
raise WebUISettingsError("base_url is required")
|
||||||
set_search_value("base_url", base_url)
|
search_config.base_url = base_url
|
||||||
set_search_value("api_key", "")
|
search_config.api_key = ""
|
||||||
elif credential in {"api_key", "optional_api_key"}:
|
elif credential in {"api_key", "optional_api_key"}:
|
||||||
raw_api_key = _query_first_alias(query, "api_key", "apiKey")
|
raw_api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||||
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
api_key = raw_api_key.strip() if raw_api_key is not None else None
|
||||||
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
if api_key is None and previous_provider == provider_name and search_config.api_key:
|
||||||
api_key = search_config.api_key
|
api_key = search_config.api_key
|
||||||
if credential == "api_key" and not api_key:
|
if credential == "api_key" and not api_key:
|
||||||
raise WebUISettingsError("api_key is required")
|
raise WebUISettingsError("api_key is required")
|
||||||
set_search_value("api_key", api_key or "")
|
search_config.api_key = api_key or ""
|
||||||
set_search_value("base_url", "")
|
search_config.base_url = ""
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("unknown web search credential type")
|
raise WebUISettingsError("unknown web search credential type")
|
||||||
|
|
||||||
max_results = _query_first_alias(query, "max_results", "maxResults")
|
max_results = _query_first_alias(query, "max_results", "maxResults")
|
||||||
if max_results is not None:
|
if max_results is not None:
|
||||||
try:
|
try:
|
||||||
parsed = int(max_results)
|
parsed = int(max_results)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise WebUISettingsError("max_results must be an integer") from None
|
raise WebUISettingsError("max_results must be an integer") from None
|
||||||
if parsed < 1 or parsed > 10:
|
if parsed < 1 or parsed > 10:
|
||||||
raise WebUISettingsError("max_results must be between 1 and 10")
|
raise WebUISettingsError("max_results must be between 1 and 10")
|
||||||
set_search_value("max_results", parsed)
|
search_config.max_results = parsed
|
||||||
|
|
||||||
timeout = _query_first(query, "timeout")
|
timeout = _query_first(query, "timeout")
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
try:
|
try:
|
||||||
parsed_timeout = int(timeout)
|
parsed_timeout = int(timeout)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise WebUISettingsError("timeout must be an integer") from None
|
raise WebUISettingsError("timeout must be an integer") from None
|
||||||
if parsed_timeout < 1 or parsed_timeout > 120:
|
if parsed_timeout < 1 or parsed_timeout > 120:
|
||||||
raise WebUISettingsError("timeout must be between 1 and 120")
|
raise WebUISettingsError("timeout must be between 1 and 120")
|
||||||
set_search_value("timeout", parsed_timeout)
|
search_config.timeout = parsed_timeout
|
||||||
|
|
||||||
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
|
use_jina_reader = _query_first_alias(query, "use_jina_reader", "useJinaReader")
|
||||||
if use_jina_reader is not None:
|
if use_jina_reader is not None:
|
||||||
normalized = use_jina_reader.strip().lower()
|
normalized = use_jina_reader.strip().lower()
|
||||||
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
if normalized not in {"1", "0", "true", "false", "yes", "no"}:
|
||||||
raise WebUISettingsError("use_jina_reader must be boolean")
|
raise WebUISettingsError("use_jina_reader must be boolean")
|
||||||
previous_jina_reader = web_config.fetch.use_jina_reader
|
enabled = normalized in {"1", "true", "yes"}
|
||||||
set_fetch_value("use_jina_reader", normalized in {"1", "true", "yes"})
|
if web_config.fetch.use_jina_reader != enabled:
|
||||||
if web_config.fetch.use_jina_reader != previous_jina_reader:
|
web_config.fetch.use_jina_reader = enabled
|
||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
update_config(mutate)
|
||||||
save_config(config)
|
|
||||||
return settings_payload(requires_restart=restart_required)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
"""Update the managed OpenAI-compatible API configuration."""
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
config = load_config()
|
def mutate(config: Config) -> None:
|
||||||
api = config.api
|
api = config.api
|
||||||
|
|
||||||
host = _query_first(query, "host")
|
host = _query_first(query, "host")
|
||||||
if host is not None:
|
if host is not None:
|
||||||
host = host.strip()
|
host = host.strip()
|
||||||
if not host:
|
if not host:
|
||||||
raise WebUISettingsError("host is required")
|
raise WebUISettingsError("host is required")
|
||||||
api.host = host
|
api.host = host
|
||||||
|
|
||||||
port = _query_first(query, "port")
|
port = _query_first(query, "port")
|
||||||
if port is not None:
|
if port is not None:
|
||||||
try:
|
try:
|
||||||
parsed_port = int(port)
|
parsed_port = int(port)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise WebUISettingsError("port must be an integer") from None
|
raise WebUISettingsError("port must be an integer") from None
|
||||||
if parsed_port < 1 or parsed_port > 65535:
|
if parsed_port < 1 or parsed_port > 65535:
|
||||||
raise WebUISettingsError("port must be between 1 and 65535")
|
raise WebUISettingsError("port must be between 1 and 65535")
|
||||||
api.port = parsed_port
|
api.port = parsed_port
|
||||||
|
|
||||||
timeout = _query_first(query, "timeout")
|
timeout = _query_first(query, "timeout")
|
||||||
if timeout is not None:
|
if timeout is not None:
|
||||||
try:
|
try:
|
||||||
parsed_timeout = float(timeout)
|
parsed_timeout = float(timeout)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise WebUISettingsError("timeout must be a number") from None
|
raise WebUISettingsError("timeout must be a number") from None
|
||||||
if parsed_timeout < 1 or parsed_timeout > 3600:
|
if parsed_timeout < 1 or parsed_timeout > 3600:
|
||||||
raise WebUISettingsError("timeout must be between 1 and 3600")
|
raise WebUISettingsError("timeout must be between 1 and 3600")
|
||||||
api.timeout = parsed_timeout
|
api.timeout = parsed_timeout
|
||||||
|
|
||||||
api_key = _query_first_alias(query, "api_key", "apiKey")
|
api_key = _query_first_alias(query, "api_key", "apiKey")
|
||||||
if api_key is not None:
|
if api_key is not None:
|
||||||
api.api_key = api_key.strip()
|
api.api_key = api_key.strip()
|
||||||
|
|
||||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||||
raise WebUISettingsError("an API key is required when the API is available on the network")
|
raise WebUISettingsError(
|
||||||
|
"an API key is required when the API is available on the network"
|
||||||
|
)
|
||||||
|
|
||||||
save_config(config)
|
update_config(mutate)
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
def _apply_image_generation_settings(config: Config, query: QueryParams) -> bool:
|
||||||
config = load_config()
|
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -1547,13 +1505,21 @@ def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
if not selected_provider or not selected_provider["configured"]:
|
if not selected_provider or not selected_provider["configured"]:
|
||||||
raise WebUISettingsError("image generation provider is not configured")
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
|
||||||
if changed:
|
return changed
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
|
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
def mutate(config: Config) -> None:
|
||||||
|
nonlocal changed
|
||||||
|
changed = _apply_image_generation_settings(config, query)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
return settings_payload(requires_restart=changed)
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
def _apply_transcription_settings(config: Config, query: QueryParams) -> bool:
|
||||||
config = load_config()
|
|
||||||
transcription = config.transcription
|
transcription = config.transcription
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -1617,6 +1583,12 @@ def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
|||||||
transcription.max_upload_mb = parsed_upload
|
transcription.max_upload_mb = parsed_upload
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
return changed
|
||||||
save_config(config)
|
|
||||||
|
|
||||||
|
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
|
def mutate(config: Config) -> None:
|
||||||
|
_apply_transcription_settings(config, query)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
return settings_payload()
|
return settings_payload()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from nanobot.agent.tools.mcp import request_mcp_reload
|
|||||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels._setup import channel_setup_spec
|
from nanobot.channels._setup import channel_setup_spec
|
||||||
from nanobot.config.loader import get_config_path, load_config, save_config
|
from nanobot.config.loader import get_config_path, load_raw_config, update_config
|
||||||
from nanobot.optional_features import (
|
from nanobot.optional_features import (
|
||||||
OptionalFeatureError,
|
OptionalFeatureError,
|
||||||
extra_installed,
|
extra_installed,
|
||||||
@@ -397,7 +397,7 @@ class WebUISettingsRouter:
|
|||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
update_api_settings(self._parse_api_service_settings_query(request))
|
update_api_settings(self._parse_api_service_settings_query(request))
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
runtime = self._api_runtime()
|
runtime = self._api_runtime()
|
||||||
options = ApiStartOptions(
|
options = ApiStartOptions(
|
||||||
host=config.api.host,
|
host=config.api.host,
|
||||||
@@ -465,7 +465,7 @@ class WebUISettingsRouter:
|
|||||||
return ApiRuntime(paths=api_runtime_paths(config_path))
|
return ApiRuntime(paths=api_runtime_paths(config_path))
|
||||||
|
|
||||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
status = self._api_runtime().status()
|
status = self._api_runtime().status()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
@@ -773,51 +773,56 @@ class WebUISettingsRouter:
|
|||||||
if not raw_values:
|
if not raw_values:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
config = load_config()
|
|
||||||
section = getattr(config.channels, name, None)
|
|
||||||
if name == "feishu":
|
|
||||||
from nanobot.channels._feishu_instances import feishu_instance_specs
|
|
||||||
from nanobot.channels.feishu import FeishuChannel
|
|
||||||
|
|
||||||
specs = feishu_instance_specs(section, FeishuChannel.default_config())
|
|
||||||
selected = next((spec for spec in specs if spec.instance_id == instance_id), None)
|
|
||||||
channel_config = dict(selected.config) if selected is not None else {}
|
|
||||||
elif hasattr(section, "model_dump"):
|
|
||||||
channel_config = section.model_dump(mode="json", by_alias=True)
|
|
||||||
elif isinstance(section, dict):
|
|
||||||
channel_config = dict(section)
|
|
||||||
else:
|
|
||||||
channel_config = {}
|
|
||||||
|
|
||||||
saved: list[str] = []
|
saved: list[str] = []
|
||||||
prefix = f"channels.{name}."
|
|
||||||
for raw_key, raw_value in raw_values.items():
|
|
||||||
if not isinstance(raw_key, str) or not raw_key:
|
|
||||||
raise WebUISettingsError("channel settings payload contains an invalid key")
|
|
||||||
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
|
||||||
value_type = field_types.get(field)
|
|
||||||
if value_type is None:
|
|
||||||
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
|
||||||
value = self._coerce_channel_value(raw_key, raw_value, value_type)
|
|
||||||
if value is _SKIP_FIELD:
|
|
||||||
continue
|
|
||||||
self._assign_channel_config_value(channel_config, field, value)
|
|
||||||
saved.append(raw_key)
|
|
||||||
|
|
||||||
if name == "feishu":
|
def mutate(config: Any) -> None:
|
||||||
from nanobot.channels._feishu_instances import upsert_feishu_instance
|
section = getattr(config.channels, name, None)
|
||||||
from nanobot.channels.feishu import FeishuChannel
|
if name == "feishu":
|
||||||
|
from nanobot.channels._feishu_instances import feishu_instance_specs
|
||||||
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
existing = getattr(config.channels, name, None)
|
specs = feishu_instance_specs(section, FeishuChannel.default_config())
|
||||||
channel_config = upsert_feishu_instance(
|
selected = next(
|
||||||
existing if isinstance(existing, dict) else {},
|
(spec for spec in specs if spec.instance_id == instance_id),
|
||||||
FeishuChannel.default_config(),
|
None,
|
||||||
instance_id,
|
)
|
||||||
channel_config,
|
channel_config = dict(selected.config) if selected is not None else {}
|
||||||
)
|
elif hasattr(section, "model_dump"):
|
||||||
|
channel_config = section.model_dump(mode="json", by_alias=True)
|
||||||
|
elif isinstance(section, dict):
|
||||||
|
channel_config = dict(section)
|
||||||
|
else:
|
||||||
|
channel_config = {}
|
||||||
|
|
||||||
setattr(config.channels, name, channel_config)
|
prefix = f"channels.{name}."
|
||||||
save_config(config)
|
for raw_key, raw_value in raw_values.items():
|
||||||
|
if not isinstance(raw_key, str) or not raw_key:
|
||||||
|
raise WebUISettingsError("channel settings payload contains an invalid key")
|
||||||
|
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
||||||
|
value_type = field_types.get(field)
|
||||||
|
if value_type is None:
|
||||||
|
raise WebUISettingsError(f"'{raw_key}' cannot be configured from WebUI")
|
||||||
|
value = self._coerce_channel_value(raw_key, raw_value, value_type)
|
||||||
|
if value is _SKIP_FIELD:
|
||||||
|
continue
|
||||||
|
self._assign_channel_config_value(channel_config, field, value)
|
||||||
|
saved.append(raw_key)
|
||||||
|
|
||||||
|
if name == "feishu":
|
||||||
|
from nanobot.channels._feishu_instances import upsert_feishu_instance
|
||||||
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
existing = getattr(config.channels, name, None)
|
||||||
|
channel_config = upsert_feishu_instance(
|
||||||
|
existing if isinstance(existing, dict) else {},
|
||||||
|
FeishuChannel.default_config(),
|
||||||
|
instance_id,
|
||||||
|
channel_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(config.channels, name, channel_config)
|
||||||
|
|
||||||
|
update_config(mutate)
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1058,7 +1063,7 @@ class WebUISettingsRouter:
|
|||||||
if _is_local_browser_request(connection, request.headers):
|
if _is_local_browser_request(connection, request.headers):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
return bool(load_config().tools.webui_allow_remote_package_install)
|
return bool(load_raw_config().tools.webui_allow_remote_package_install)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load remote package install policy")
|
self.logger.exception("failed to load remote package install policy")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from nanobot.audio.transcription import (
|
|||||||
resolve_transcription_config,
|
resolve_transcription_config,
|
||||||
transcribe_audio_data_url,
|
transcribe_audio_data_url,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
|
|
||||||
_MAX_REQUEST_ID_LENGTH = 80
|
_MAX_REQUEST_ID_LENGTH = 80
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ async def webui_transcription_event(envelope: dict[str, Any]) -> tuple[str, dict
|
|||||||
try:
|
try:
|
||||||
text = await transcribe_audio_data_url(
|
text = await transcribe_audio_data_url(
|
||||||
envelope.get("data_url"),
|
envelope.get("data_url"),
|
||||||
resolve_transcription_config(load_config()),
|
resolve_transcription_config(load_raw_config()),
|
||||||
duration_ms=envelope.get("duration_ms"),
|
duration_ms=envelope.get("duration_ms"),
|
||||||
)
|
)
|
||||||
except TranscriptionIngressError as exc:
|
except TranscriptionIngressError as exc:
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ def _decode_api_key(raw_key: str) -> str | None:
|
|||||||
|
|
||||||
def _default_model_name_from_config() -> str | None:
|
def _default_model_name_from_config() -> str | None:
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
model = load_config().resolve_preset().model.strip()
|
model = load_raw_config().resolve_preset().model.strip()
|
||||||
return model or None
|
return model or None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("bootstrap model_name could not load from config: {}", e)
|
logger.debug("bootstrap model_name could not load from config: {}", e)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from nanobot.agent.tools import mcp as mcp_runtime
|
|||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper
|
from nanobot.agent.tools.mcp import MCPResourceWrapper, MCPToolWrapper
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_raw_config, save_config
|
||||||
from nanobot.config.schema import MCPServerConfig
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
|
|||||||
):
|
):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||||
type="stdio",
|
type="stdio",
|
||||||
command="browserbase-mcp",
|
command="browserbase-mcp",
|
||||||
@@ -233,7 +233,7 @@ async def test_reload_mcp_servers_adds_and_removes_tools_without_restart(
|
|||||||
assert loop.tools.has("mcp_browserbase_navigate")
|
assert loop.tools.has("mcp_browserbase_navigate")
|
||||||
assert "browserbase" in loop._mcp_stacks
|
assert "browserbase" in loop._mcp_stacks
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
del config.tools.mcp_servers["browserbase"]
|
del config.tools.mcp_servers["browserbase"]
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
|||||||
):
|
):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||||
type="stdio",
|
type="stdio",
|
||||||
command="browserbase-mcp",
|
command="browserbase-mcp",
|
||||||
@@ -292,7 +292,7 @@ async def test_request_mcp_reload_reaches_runtime_control_without_restart(
|
|||||||
assert result["requires_restart"] is False
|
assert result["requires_restart"] is False
|
||||||
assert loop.tools.has("mcp_browserbase_navigate")
|
assert loop.tools.has("mcp_browserbase_navigate")
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
del config.tools.mcp_servers["browserbase"]
|
del config.tools.mcp_servers["browserbase"]
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ async def test_reload_mcp_servers_retries_configured_server_without_live_stack(
|
|||||||
):
|
):
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
config.tools.mcp_servers["browserbase"] = MCPServerConfig(
|
||||||
type="stdio",
|
type="stdio",
|
||||||
command="browserbase-mcp",
|
command="browserbase-mcp",
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch
|
|||||||
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
monkeypatch.setattr(registry, "discover_channel_names", lambda: ["hot"])
|
||||||
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
monkeypatch.setattr(registry, "discover_plugins", lambda enabled_names=None: {})
|
||||||
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
|
monkeypatch.setattr(registry, "discover_enabled", discover_enabled)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs))
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda: next(configs))
|
||||||
|
|
||||||
manager = ChannelManager(disabled, MessageBus())
|
manager = ChannelManager(disabled, MessageBus())
|
||||||
manager._started = True
|
manager._started = True
|
||||||
@@ -95,7 +95,7 @@ async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_f
|
|||||||
"discover_enabled",
|
"discover_enabled",
|
||||||
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
|
lambda enabled_names, **_kwargs: {"hot": _HotChannel},
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda: enabled)
|
||||||
|
|
||||||
manager = ChannelManager(enabled, MessageBus())
|
manager = ChannelManager(enabled, MessageBus())
|
||||||
old_channel = manager.channels["hot"]
|
old_channel = manager.channels["hot"]
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
|
|||||||
seen["config"] = self.config
|
seen["config"] = self.config
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.channels.registry.discover_all",
|
"nanobot.channels.registry.discover_all",
|
||||||
lambda: {"fakeplugin": _LoginPlugin},
|
lambda: {"fakeplugin": _LoginPlugin},
|
||||||
@@ -660,7 +660,7 @@ def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
|
|||||||
async def login(self, force: bool = False) -> bool:
|
async def login(self, force: bool = False) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.config.loader.set_config_path",
|
"nanobot.config.loader.set_config_path",
|
||||||
lambda path: seen.__setitem__("config_path", path),
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
@@ -686,7 +686,7 @@ def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
|
|||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
config_path = tmp_path / "custom-config.json"
|
config_path = tmp_path / "custom-config.json"
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: Config())
|
monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: Config())
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.config.loader.set_config_path",
|
"nanobot.config.loader.set_config_path",
|
||||||
lambda path: seen.__setitem__("config_path", path),
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
@@ -707,7 +707,7 @@ def test_plugins_list_shows_available_features(monkeypatch):
|
|||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
config = Config.model_validate({"channels": {"weixin": {"enabled": True}}})
|
config = Config.model_validate({"channels": {"weixin": {"enabled": True}}})
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda config_path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_raw_config", lambda config_path=None: config)
|
||||||
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
|
monkeypatch.setattr("nanobot.channels.registry.discover_channel_names", lambda: ["weixin"])
|
||||||
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
monkeypatch.setattr("nanobot.channels.registry.discover_plugins", lambda: {})
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ from nanobot.channels.websocket import (
|
|||||||
_parse_inbound_payload,
|
_parse_inbound_payload,
|
||||||
publish_runtime_model_update,
|
publish_runtime_model_update,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_raw_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
@@ -2131,7 +2131,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.agents.defaults.model == "atomic_chat/test"
|
assert saved.agents.defaults.model == "atomic_chat/test"
|
||||||
assert saved.agents.defaults.provider == "atomic_chat"
|
assert saved.agents.defaults.provider == "atomic_chat"
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
@@ -2320,7 +2320,7 @@ def test_update_provider_settings_ignores_api_type_for_non_openai(monkeypatch, t
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert body["providers"]
|
assert body["providers"]
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
assert config.providers.custom.api_base == "https://example.test/v1"
|
assert config.providers.custom.api_base == "https://example.test/v1"
|
||||||
assert config.providers.custom.api_type == "auto"
|
assert config.providers.custom.api_type == "auto"
|
||||||
|
|
||||||
|
|||||||
@@ -1124,7 +1124,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
assert action == "enable"
|
assert action == "enable"
|
||||||
assert query == {"name": ["discord"]}
|
assert query == {"name": ["discord"]}
|
||||||
cfg = loader.load_config()
|
cfg = loader.load_raw_config()
|
||||||
section = dict(getattr(cfg.channels, "discord", {}) or {})
|
section = dict(getattr(cfg.channels, "discord", {}) or {})
|
||||||
section["enabled"] = True
|
section["enabled"] = True
|
||||||
setattr(cfg.channels, "discord", section)
|
setattr(cfg.channels, "discord", section)
|
||||||
@@ -1151,7 +1151,7 @@ async def test_channel_configure_route_saves_discord_config_and_hot_reloads(
|
|||||||
|
|
||||||
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
async def channel_feature_action(action: str, name: str) -> dict[str, Any]:
|
||||||
calls.append((action, name))
|
calls.append((action, name))
|
||||||
cfg = loader.load_config()
|
cfg = loader.load_raw_config()
|
||||||
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
|
assert getattr(cfg.channels, "discord")["token"] == "discord-token"
|
||||||
return {
|
return {
|
||||||
"handled": True,
|
"handled": True,
|
||||||
|
|||||||
+23
-20
@@ -224,7 +224,7 @@ def mock_paths():
|
|||||||
"""Mock config/workspace paths for test isolation."""
|
"""Mock config/workspace paths for test isolation."""
|
||||||
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
with patch("nanobot.config.loader.get_config_path") as mock_cp, \
|
||||||
patch("nanobot.config.loader.save_config") as mock_sc, \
|
patch("nanobot.config.loader.save_config") as mock_sc, \
|
||||||
patch("nanobot.config.loader.load_config") as mock_lc, \
|
patch("nanobot.config.loader.load_raw_config") as mock_lc, \
|
||||||
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
patch("nanobot.cli.commands.get_workspace_path") as mock_ws:
|
||||||
base_dir = Path("./test_onboard_data")
|
base_dir = Path("./test_onboard_data")
|
||||||
if base_dir.exists():
|
if base_dir.exists():
|
||||||
@@ -681,7 +681,7 @@ def test_provider_login_model_implies_set_main_provider(tmp_path):
|
|||||||
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.config.loader.load_config",
|
"nanobot.config.loader.load_effective_config",
|
||||||
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
lambda: Config.model_validate({"providers": {"openaiCodex": {"proxy": proxy}}}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -706,13 +706,12 @@ def test_provider_login_openai_codex_passes_configured_proxy(monkeypatch):
|
|||||||
assert captured["proxy"] == proxy
|
assert captured["proxy"] == proxy
|
||||||
|
|
||||||
|
|
||||||
def test_provider_login_openai_codex_resolves_proxy_env_ref(monkeypatch):
|
def test_provider_login_openai_codex_uses_resolved_proxy_from_effective_config(monkeypatch):
|
||||||
proxy = "http://127.0.0.1:23458"
|
proxy = "http://127.0.0.1:23458"
|
||||||
monkeypatch.setenv("CODEX_PROXY_FOR_TEST", proxy)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.config.loader.load_config",
|
"nanobot.config.loader.load_effective_config",
|
||||||
lambda: Config.model_validate(
|
lambda: Config.model_validate(
|
||||||
{"providers": {"openaiCodex": {"proxy": "${CODEX_PROXY_FOR_TEST}"}}}
|
{"providers": {"openaiCodex": {"proxy": proxy}}}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1278,8 +1277,9 @@ def mock_agent_runtime(tmp_path):
|
|||||||
config = Config()
|
config = Config()
|
||||||
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
config.agents.defaults.workspace = str(tmp_path / "default-workspace")
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
|
with patch(
|
||||||
patch("nanobot.config.loader.resolve_config_env_vars", side_effect=lambda c: c), \
|
"nanobot.config.loader.load_effective_config", return_value=config
|
||||||
|
) as mock_load_effective_config, \
|
||||||
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
|
||||||
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
patch("nanobot.providers.factory.make_provider", return_value=_fake_provider()), \
|
||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
@@ -1296,7 +1296,7 @@ def mock_agent_runtime(tmp_path):
|
|||||||
|
|
||||||
yield {
|
yield {
|
||||||
"config": config,
|
"config": config,
|
||||||
"load_config": mock_load_config,
|
"load_effective_config": mock_load_effective_config,
|
||||||
"sync_templates": mock_sync_templates,
|
"sync_templates": mock_sync_templates,
|
||||||
"from_config": mock_from_config,
|
"from_config": mock_from_config,
|
||||||
"agent_loop": agent_loop,
|
"agent_loop": agent_loop,
|
||||||
@@ -1319,7 +1319,7 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_
|
|||||||
result = runner.invoke(app, ["agent", "-m", "hello"])
|
result = runner.invoke(app, ["agent", "-m", "hello"])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_agent_runtime["load_config"].call_args.args == (None,)
|
assert mock_agent_runtime["load_effective_config"].call_args.args == (None,)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
assert mock_agent_runtime["sync_templates"].call_args.args == (
|
||||||
mock_agent_runtime["config"].workspace_path,
|
mock_agent_runtime["config"].workspace_path,
|
||||||
)
|
)
|
||||||
@@ -1338,7 +1338,9 @@ def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path):
|
|||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
assert mock_agent_runtime["load_effective_config"].call_args.args == (
|
||||||
|
config_path.resolve(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
||||||
@@ -1353,7 +1355,7 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
"nanobot.config.loader.set_config_path",
|
"nanobot.config.loader.set_config_path",
|
||||||
lambda path: seen.__setitem__("config_path", path),
|
lambda path: seen.__setitem__("config_path", path),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -1391,7 +1393,7 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
seen: dict[str, Path] = {}
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -1440,7 +1442,7 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
seen: dict[str, Path] = {}
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -1496,7 +1498,7 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
seen: dict[str, Path] = {}
|
seen: dict[str, Path] = {}
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: _fake_provider())
|
||||||
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object())
|
||||||
@@ -1556,7 +1558,9 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),)
|
assert mock_agent_runtime["load_effective_config"].call_args.args == (
|
||||||
|
config_path.resolve(),
|
||||||
|
)
|
||||||
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
|
||||||
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
|
||||||
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
passed_config = mock_agent_runtime["from_config"].call_args.args[0]
|
||||||
@@ -1675,8 +1679,7 @@ def _patch_cli_command_runtime(
|
|||||||
"nanobot.config.loader.set_config_path",
|
"nanobot.config.loader.set_config_path",
|
||||||
set_config_path or (lambda _path: None),
|
set_config_path or (lambda _path: None),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.config.loader.resolve_config_env_vars", lambda c: c)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands.sync_workspace_templates",
|
"nanobot.cli.commands.sync_workspace_templates",
|
||||||
sync_templates or (lambda _path: None),
|
sync_templates or (lambda _path: None),
|
||||||
@@ -2194,7 +2197,7 @@ def test_gateway_unbound_agent_cron_is_skipped(
|
|||||||
seen: dict[str, object] = {}
|
seen: dict[str, object] = {}
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
@@ -2321,7 +2324,7 @@ def test_gateway_bound_cron_runs_as_session_turn(
|
|||||||
seen: dict[str, object] = {"run_records": []}
|
seen: dict[str, object] = {"run_records": []}
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
|
monkeypatch.setattr("nanobot.config.loader.load_effective_config", lambda _path=None: config)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None)
|
||||||
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
monkeypatch.setattr("nanobot.providers.factory.make_provider", lambda _config: provider)
|
||||||
_patch_gateway_ports_free(monkeypatch)
|
_patch_gateway_ports_free(monkeypatch)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
from nanobot.config.schema import ApiConfig
|
from nanobot.config.schema import ApiConfig
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
def test_load_config_missing_file_uses_defaults(tmp_path) -> None:
|
||||||
config = load_config(tmp_path / "missing.json")
|
config = load_raw_config(tmp_path / "missing.json")
|
||||||
|
|
||||||
assert config.agents.defaults.model
|
assert config.agents.defaults.model
|
||||||
|
|
||||||
@@ -17,7 +17,15 @@ def test_load_config_invalid_json_fails_fast(tmp_path) -> None:
|
|||||||
config_path.write_text("{broken json", encoding="utf-8")
|
config_path.write_text("{broken json", encoding="utf-8")
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Failed to load config"):
|
with pytest.raises(ValueError, match="Failed to load config"):
|
||||||
load_config(config_path)
|
load_raw_config(config_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_rejects_non_object_root(tmp_path) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="config root must be a JSON object"):
|
||||||
|
load_raw_config(config_path)
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
||||||
@@ -28,7 +36,7 @@ def test_load_config_invalid_schema_fails_fast(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Failed to load config"):
|
with pytest.raises(ValueError, match="Failed to load config"):
|
||||||
load_config(config_path)
|
load_raw_config(config_path)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
|
@pytest.mark.parametrize("host", ["0.0.0.0", "::"])
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import apply_config_runtime_policies, load_raw_config, save_config
|
||||||
from nanobot.security.network import validate_url_target
|
from nanobot.security.network import validate_url_target
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path)
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.agents.defaults.max_tokens == 1234
|
||||||
assert config.agents.defaults.context_window_tokens == 200_000
|
assert config.agents.defaults.context_window_tokens == 200_000
|
||||||
@@ -56,7 +56,7 @@ def test_save_config_writes_context_window_tokens_but_not_memory_window(tmp_path
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
defaults = saved["agents"]["defaults"]
|
defaults = saved["agents"]["defaults"]
|
||||||
@@ -103,8 +103,8 @@ def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name)
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.config.loader.logger.warning") as warning:
|
with patch("nanobot.config.repository.logger.warning") as warning:
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.agents.defaults.max_tokens == 1234
|
||||||
assert not hasattr(config.agents.defaults, "max_messages")
|
assert not hasattr(config.agents.defaults, "max_messages")
|
||||||
@@ -121,8 +121,8 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("nanobot.config.loader.logger.warning"):
|
with patch("nanobot.config.repository.logger.warning"):
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ def test_load_config_migrates_legacy_my_tool_keys(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.tools.my.enable is False
|
assert config.tools.my.enable is False
|
||||||
assert config.tools.my.allow_set is True
|
assert config.tools.my.allow_set is True
|
||||||
@@ -213,7 +213,7 @@ def test_save_config_rewrites_legacy_my_tool_keys(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
@@ -238,13 +238,13 @@ def test_new_my_tool_keys_take_precedence_over_legacy(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.tools.my.enable is True
|
assert config.tools.my.enable is True
|
||||||
assert config.tools.my.allow_set is True
|
assert config.tools.my.allow_set is True
|
||||||
|
|
||||||
|
|
||||||
def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None:
|
def test_runtime_policy_application_resets_ssrf_whitelist(tmp_path) -> None:
|
||||||
whitelisted = tmp_path / "whitelisted.json"
|
whitelisted = tmp_path / "whitelisted.json"
|
||||||
whitelisted.write_text(
|
whitelisted.write_text(
|
||||||
json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}),
|
json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}),
|
||||||
@@ -253,12 +253,12 @@ def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -
|
|||||||
defaulted = tmp_path / "defaulted.json"
|
defaulted = tmp_path / "defaulted.json"
|
||||||
defaulted.write_text(json.dumps({}), encoding="utf-8")
|
defaulted.write_text(json.dumps({}), encoding="utf-8")
|
||||||
|
|
||||||
load_config(whitelisted)
|
apply_config_runtime_policies(load_raw_config(whitelisted))
|
||||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||||
ok, err = validate_url_target("http://ts.local/api")
|
ok, err = validate_url_target("http://ts.local/api")
|
||||||
assert ok, err
|
assert ok, err
|
||||||
|
|
||||||
load_config(defaulted)
|
apply_config_runtime_policies(load_raw_config(defaulted))
|
||||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||||
ok, _ = validate_url_target("http://ts.local/api")
|
ok, _ = validate_url_target("http://ts.local/api")
|
||||||
assert not ok
|
assert not ok
|
||||||
@@ -268,7 +268,7 @@ def test_load_config_defaults_local_service_access_to_enabled(tmp_path) -> None:
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
|
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.tools.webui_allow_local_service_access is True
|
assert config.tools.webui_allow_local_service_access is True
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ def test_load_config_accepts_legacy_local_preview_access(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.tools.webui_allow_local_service_access is False
|
assert config.tools.webui_allow_local_service_access is False
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ def test_load_config_defaults_remote_package_install_to_disabled(tmp_path) -> No
|
|||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
|
config_path.write_text(json.dumps({"tools": {}}), encoding="utf-8")
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
|
|
||||||
assert config.tools.webui_allow_remote_package_install is False
|
assert config.tools.webui_allow_remote_package_install is False
|
||||||
|
|
||||||
@@ -306,5 +306,5 @@ def test_load_config_accepts_remote_package_install_aliases(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert load_config(camel_path).tools.webui_allow_remote_package_install is True
|
assert load_raw_config(camel_path).tools.webui_allow_remote_package_install is True
|
||||||
assert load_config(snake_path).tools.webui_allow_remote_package_install is True
|
assert load_raw_config(snake_path).tools.webui_allow_remote_package_install is True
|
||||||
|
|||||||
@@ -2,54 +2,31 @@ import json
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import (
|
from nanobot.config.loader import load_raw_config, save_config
|
||||||
_resolve_env_vars,
|
from nanobot.config.repository import resolve_config_env_vars
|
||||||
load_config,
|
|
||||||
resolve_config_env_vars,
|
|
||||||
save_config,
|
|
||||||
)
|
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
|
|
||||||
class TestResolveEnvVars:
|
class TestResolveConfig:
|
||||||
def test_replaces_string_value(self, monkeypatch):
|
def test_resolves_multiple_refs_inside_a_value(self, monkeypatch):
|
||||||
monkeypatch.setenv("MY_SECRET", "hunter2")
|
monkeypatch.setenv("TEST_USER", "alice")
|
||||||
assert _resolve_env_vars("${MY_SECRET}") == "hunter2"
|
monkeypatch.setenv("TEST_PASSWORD", "secret")
|
||||||
|
config = Config.model_validate(
|
||||||
|
{"providers": {"groq": {"apiKey": "${TEST_USER}:${TEST_PASSWORD}"}}}
|
||||||
|
)
|
||||||
|
|
||||||
def test_partial_replacement(self, monkeypatch):
|
resolved = resolve_config_env_vars(config)
|
||||||
monkeypatch.setenv("HOST", "example.com")
|
|
||||||
assert _resolve_env_vars("https://${HOST}/api") == "https://example.com/api"
|
|
||||||
|
|
||||||
def test_multiple_vars_in_one_string(self, monkeypatch):
|
assert resolved.providers.groq.api_key == "alice:secret"
|
||||||
monkeypatch.setenv("USER", "alice")
|
|
||||||
monkeypatch.setenv("PASS", "secret")
|
|
||||||
assert _resolve_env_vars("${USER}:${PASS}") == "alice:secret"
|
|
||||||
|
|
||||||
def test_nested_dicts(self, monkeypatch):
|
|
||||||
monkeypatch.setenv("TOKEN", "abc123")
|
|
||||||
data = {"channels": {"telegram": {"token": "${TOKEN}"}}}
|
|
||||||
result = _resolve_env_vars(data)
|
|
||||||
assert result["channels"]["telegram"]["token"] == "abc123"
|
|
||||||
|
|
||||||
def test_lists(self, monkeypatch):
|
|
||||||
monkeypatch.setenv("VAL", "x")
|
|
||||||
assert _resolve_env_vars(["${VAL}", "plain"]) == ["x", "plain"]
|
|
||||||
|
|
||||||
def test_ignores_non_strings(self):
|
|
||||||
assert _resolve_env_vars(42) == 42
|
|
||||||
assert _resolve_env_vars(True) is True
|
|
||||||
assert _resolve_env_vars(None) is None
|
|
||||||
assert _resolve_env_vars(3.14) == 3.14
|
|
||||||
|
|
||||||
def test_plain_strings_unchanged(self):
|
|
||||||
assert _resolve_env_vars("no vars here") == "no vars here"
|
|
||||||
|
|
||||||
def test_missing_var_raises(self):
|
def test_missing_var_raises(self):
|
||||||
|
config = Config.model_validate(
|
||||||
|
{"providers": {"groq": {"apiKey": "${DOES_NOT_EXIST}"}}}
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="DOES_NOT_EXIST"):
|
with pytest.raises(ValueError, match="DOES_NOT_EXIST"):
|
||||||
_resolve_env_vars("${DOES_NOT_EXIST}")
|
resolve_config_env_vars(config)
|
||||||
|
|
||||||
|
|
||||||
class TestResolveConfig:
|
|
||||||
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
|
def test_resolves_env_vars_in_config(self, tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
@@ -60,7 +37,7 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = load_config(config_path)
|
raw = load_raw_config(config_path)
|
||||||
assert raw.providers.groq.api_key == "${TEST_API_KEY}"
|
assert raw.providers.groq.api_key == "${TEST_API_KEY}"
|
||||||
|
|
||||||
resolved = resolve_config_env_vars(raw)
|
resolved = resolve_config_env_vars(raw)
|
||||||
@@ -76,7 +53,7 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = load_config(config_path)
|
raw = load_raw_config(config_path)
|
||||||
save_config(raw, config_path)
|
save_config(raw, config_path)
|
||||||
|
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
@@ -91,14 +68,14 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
config.agents.defaults.max_tokens = 1234
|
config.agents.defaults.max_tokens = 1234
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *"
|
assert saved["agents"]["defaults"]["dream"]["cron"] == "0 */4 * * *"
|
||||||
|
|
||||||
reloaded = load_config(config_path)
|
reloaded = load_raw_config(config_path)
|
||||||
schedule = reloaded.agents.defaults.dream.build_schedule("UTC")
|
schedule = reloaded.agents.defaults.dream.build_schedule("UTC")
|
||||||
assert schedule.kind == "cron"
|
assert schedule.kind == "cron"
|
||||||
assert schedule.expr == "0 */4 * * *"
|
assert schedule.expr == "0 */4 * * *"
|
||||||
@@ -119,7 +96,7 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
config = load_raw_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
|
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
@@ -149,7 +126,7 @@ class TestResolveConfig:
|
|||||||
assert saved["providers"]["openaiCodex"] == {"proxy": proxy}
|
assert saved["providers"]["openaiCodex"] == {"proxy": proxy}
|
||||||
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
assert saved["providers"]["groq"]["apiKey"] == "groq-secret"
|
||||||
|
|
||||||
reloaded = load_config(config_path)
|
reloaded = load_raw_config(config_path)
|
||||||
assert reloaded.providers.openai_codex.proxy == proxy
|
assert reloaded.providers.openai_codex.proxy == proxy
|
||||||
assert reloaded.providers.openai_codex.api_key is None
|
assert reloaded.providers.openai_codex.api_key is None
|
||||||
|
|
||||||
@@ -166,7 +143,7 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = load_config(config_path)
|
raw = load_raw_config(config_path)
|
||||||
assert raw.providers.openai_codex.api_key == "secret"
|
assert raw.providers.openai_codex.api_key == "secret"
|
||||||
|
|
||||||
resolved = resolve_config_env_vars(raw)
|
resolved = resolve_config_env_vars(raw)
|
||||||
@@ -190,7 +167,7 @@ class TestResolveConfig:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
raw = load_config(config_path)
|
raw = load_raw_config(config_path)
|
||||||
resolved = resolve_config_env_vars(raw)
|
resolved = resolve_config_env_vars(raw)
|
||||||
|
|
||||||
assert resolved.providers.groq.api_key == "resolved-key"
|
assert resolved.providers.groq.api_key == "resolved-key"
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import stat
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.config.loader import load_raw_config
|
||||||
|
from nanobot.config.repository import ConfigConflictError, FileConfigRepository
|
||||||
|
from nanobot.security.network import configure_ssrf_whitelist, validate_url_target
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_resolve(host: str, results: list[str]):
|
||||||
|
def _resolver(hostname, port, family=0, type_=0):
|
||||||
|
if hostname == host:
|
||||||
|
return [
|
||||||
|
(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0))
|
||||||
|
for ip in results
|
||||||
|
]
|
||||||
|
raise socket.gaierror(f"cannot resolve {hostname}")
|
||||||
|
|
||||||
|
return _resolver
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_api_exposes_explicit_loaders_without_legacy_alias() -> None:
|
||||||
|
import nanobot.config as config_api
|
||||||
|
from nanobot.config import loader
|
||||||
|
|
||||||
|
assert config_api.load_raw_config is loader.load_raw_config
|
||||||
|
assert config_api.load_effective_config is loader.load_effective_config
|
||||||
|
assert not hasattr(config_api, "load_config")
|
||||||
|
assert not hasattr(loader, "load_config")
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_and_effective_snapshots_keep_secret_templates_separate(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"providers": {"groq": {"apiKey": "${GROQ_TOKEN}"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("GROQ_TOKEN", "resolved-secret")
|
||||||
|
repository = FileConfigRepository(path)
|
||||||
|
|
||||||
|
raw = repository.load_raw()
|
||||||
|
effective = repository.load_effective()
|
||||||
|
|
||||||
|
assert raw.config.providers.groq.api_key == "${GROQ_TOKEN}"
|
||||||
|
assert effective.config.providers.groq.api_key == "resolved-secret"
|
||||||
|
assert effective.config is not raw.config
|
||||||
|
assert effective.revision == raw.revision
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_uses_latest_raw_config_and_reports_changed_paths(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"providers": {"groq": {"apiKey": "${GROQ_TOKEN}"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("GROQ_TOKEN", "resolved-secret")
|
||||||
|
repository = FileConfigRepository(path)
|
||||||
|
|
||||||
|
commit = repository.update(
|
||||||
|
lambda config: setattr(config.agents.defaults, "timezone", "Asia/Shanghai")
|
||||||
|
)
|
||||||
|
|
||||||
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["providers"]["groq"]["apiKey"] == "${GROQ_TOKEN}"
|
||||||
|
assert saved["agents"]["defaults"]["timezone"] == "Asia/Shanghai"
|
||||||
|
assert commit.before.revision != commit.after.revision
|
||||||
|
assert commit.changed_paths == frozenset({"agents.defaults.timezone"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_rejects_stale_expected_revision(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
repository = FileConfigRepository(path)
|
||||||
|
initial = repository.load_raw()
|
||||||
|
repository.update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
|
||||||
|
with pytest.raises(ConfigConflictError, match="Config changed"):
|
||||||
|
repository.update(
|
||||||
|
lambda config: setattr(config.api, "port", 9002),
|
||||||
|
expected_revision=initial.revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert repository.load_raw().config.api.port == 9001
|
||||||
|
|
||||||
|
|
||||||
|
def test_repositories_for_same_path_serialize_threaded_updates(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
first = FileConfigRepository(path)
|
||||||
|
second = FileConfigRepository(path)
|
||||||
|
first_mutator_entered = threading.Event()
|
||||||
|
release_first = threading.Event()
|
||||||
|
second_mutator_entered = threading.Event()
|
||||||
|
|
||||||
|
def update_first() -> None:
|
||||||
|
def mutate(config):
|
||||||
|
first_mutator_entered.set()
|
||||||
|
assert release_first.wait(timeout=5)
|
||||||
|
config.api.port = 9001
|
||||||
|
|
||||||
|
first.update(mutate)
|
||||||
|
|
||||||
|
def update_second() -> None:
|
||||||
|
def mutate(config):
|
||||||
|
second_mutator_entered.set()
|
||||||
|
config.agents.defaults.timezone = "Asia/Shanghai"
|
||||||
|
|
||||||
|
second.update(mutate)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
first_future = pool.submit(update_first)
|
||||||
|
assert first_mutator_entered.wait(timeout=5)
|
||||||
|
second_future = pool.submit(update_second)
|
||||||
|
assert not second_mutator_entered.wait(timeout=0.1)
|
||||||
|
release_first.set()
|
||||||
|
first_future.result(timeout=5)
|
||||||
|
second_future.result(timeout=5)
|
||||||
|
|
||||||
|
config = first.load_raw().config
|
||||||
|
assert config.api.port == 9001
|
||||||
|
assert config.agents.defaults.timezone == "Asia/Shanghai"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repositories_for_different_paths_are_isolated(tmp_path: Path) -> None:
|
||||||
|
first_path = tmp_path / "first.json"
|
||||||
|
second_path = tmp_path / "second.json"
|
||||||
|
first = FileConfigRepository(first_path)
|
||||||
|
second = FileConfigRepository(second_path)
|
||||||
|
|
||||||
|
first.update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
second.update(lambda config: setattr(config.api, "port", 9002))
|
||||||
|
|
||||||
|
assert first.load_raw().config.api.port == 9001
|
||||||
|
assert second.load_raw().config.api.port == 9002
|
||||||
|
assert json.loads(first_path.read_text(encoding="utf-8"))["api"]["port"] == 9001
|
||||||
|
assert json.loads(second_path.read_text(encoding="utf-8"))["api"]["port"] == 9002
|
||||||
|
|
||||||
|
|
||||||
|
def test_noop_update_keeps_revision_and_does_not_rewrite(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
repository = FileConfigRepository(path)
|
||||||
|
repository.update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
before = repository.load_raw()
|
||||||
|
|
||||||
|
commit = repository.update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
|
||||||
|
assert commit.changed_paths == frozenset()
|
||||||
|
assert commit.before.revision == before.revision
|
||||||
|
assert commit.after.revision == before.revision
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_save_keeps_previous_file_when_replace_fails(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
|
||||||
|
repository = FileConfigRepository(path)
|
||||||
|
|
||||||
|
with patch("nanobot.config.repository.os.replace", side_effect=OSError("replace failed")):
|
||||||
|
with pytest.raises(OSError, match="replace failed"):
|
||||||
|
repository.update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8"))["api"]["port"] == 9000
|
||||||
|
assert list(tmp_path.glob(".config.json.*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Windows does not expose POSIX file modes")
|
||||||
|
def test_atomic_save_preserves_existing_file_mode(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
FileConfigRepository(path).update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
|
||||||
|
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_save_restores_mode_before_writing(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
observed_sizes: list[int] = []
|
||||||
|
real_chmod = os.chmod
|
||||||
|
|
||||||
|
def capture_size_before_chmod(target: str | bytes | Path, mode: int) -> None:
|
||||||
|
observed_sizes.append(Path(target).stat().st_size)
|
||||||
|
real_chmod(target, mode)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.repository.os.chmod", capture_size_before_chmod)
|
||||||
|
|
||||||
|
FileConfigRepository(path).update(lambda config: setattr(config.api, "port", 9001))
|
||||||
|
|
||||||
|
assert observed_sizes == [0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Windows handles chmod differently")
|
||||||
|
def test_atomic_save_keeps_previous_file_when_mode_restore_fails(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text('{"api": {"port": 9000}}', encoding="utf-8")
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
with patch("nanobot.config.repository.os.chmod", side_effect=OSError("chmod failed")):
|
||||||
|
with pytest.raises(OSError, match="chmod failed"):
|
||||||
|
FileConfigRepository(path).update(
|
||||||
|
lambda config: setattr(config.api, "port", 9001)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert json.loads(path.read_text(encoding="utf-8"))["api"]["port"] == 9000
|
||||||
|
assert list(tmp_path.glob(".config.json.*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_loading_config_does_not_change_process_network_policy(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "config.json"
|
||||||
|
path.write_text(json.dumps({"tools": {"ssrfWhitelist": []}}), encoding="utf-8")
|
||||||
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
||||||
|
try:
|
||||||
|
load_raw_config(path)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"nanobot.security.network.socket.getaddrinfo",
|
||||||
|
_fake_resolve("ts.local", ["100.100.1.1"]),
|
||||||
|
):
|
||||||
|
ok, error = validate_url_target("http://ts.local/api")
|
||||||
|
assert ok, error
|
||||||
|
finally:
|
||||||
|
configure_ssrf_whitelist([])
|
||||||
@@ -120,7 +120,7 @@ def test_from_config_rejects_multiple_model_selectors(tmp_path):
|
|||||||
def test_from_config_default_path():
|
def test_from_config_default_path():
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
|
||||||
with patch("nanobot.config.loader.load_config") as mock_load, \
|
with patch("nanobot.config.loader.load_effective_config") as mock_load, \
|
||||||
patch("nanobot.providers.factory.make_provider") as mock_prov:
|
patch("nanobot.providers.factory.make_provider") as mock_prov:
|
||||||
mock_load.return_value = Config()
|
mock_load.return_value = Config()
|
||||||
mock_prov.return_value = MagicMock()
|
mock_prov.return_value = MagicMock()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_raw_config, save_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.webui import channel_validation
|
from nanobot.webui import channel_validation
|
||||||
from nanobot.webui.channel_validation import validate_channel_config
|
from nanobot.webui.channel_validation import validate_channel_config
|
||||||
@@ -35,7 +35,7 @@ def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.Mo
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert payload["status"] == "connected"
|
assert payload["status"] == "connected"
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.channels.slack["appToken"] == "xapp-old"
|
assert saved.channels.slack["appToken"] == "xapp-old"
|
||||||
assert saved.channels.slack["botToken"] == "xoxb-old"
|
assert saved.channels.slack["botToken"] == "xoxb-old"
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ def test_validate_email_presets_are_checked_without_saving(
|
|||||||
|
|
||||||
assert payload["status"] == "connected"
|
assert payload["status"] == "connected"
|
||||||
assert payload["can_enable"] is True
|
assert payload["can_enable"] is True
|
||||||
assert not hasattr(load_config(config_path).channels, "email")
|
assert not hasattr(load_raw_config(config_path).channels, "email")
|
||||||
|
|
||||||
|
|
||||||
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_raw_config
|
||||||
from nanobot.webui.mcp_presets_api import (
|
from nanobot.webui.mcp_presets_api import (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
custom_mcp_action,
|
custom_mcp_action,
|
||||||
@@ -76,7 +76,7 @@ def test_enable_browserbase_writes_scrubbed_config_payload(
|
|||||||
assert preset["installed"] is True
|
assert preset["installed"] is True
|
||||||
assert preset["configured"] is True
|
assert preset["configured"] is True
|
||||||
assert "bb_live_secret" not in str(payload)
|
assert "bb_live_secret" not in str(payload)
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
|
assert "browserbaseApiKey=bb_live_secret" in config.tools.mcp_servers["browserbase"].url
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ def test_enable_context7_optional_api_key_appends_arg(
|
|||||||
assert "ctx7_secret" not in str(payload)
|
assert "ctx7_secret" not in str(payload)
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "context7")
|
row = next(item for item in payload["presets"] if item["name"] == "context7")
|
||||||
assert row["configured"] is True
|
assert row["configured"] is True
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert config.tools.mcp_servers["context7"].args == [
|
assert config.tools.mcp_servers["context7"].args == [
|
||||||
"-y",
|
"-y",
|
||||||
"@upstash/context7-mcp@latest",
|
"@upstash/context7-mcp@latest",
|
||||||
@@ -124,7 +124,7 @@ def test_enable_stdio_preset_uses_config_scoped_cwd(
|
|||||||
|
|
||||||
mcp_presets_action("enable", {"name": ["playwright"]})
|
mcp_presets_action("enable", {"name": ["playwright"]})
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
cwd = config.tools.mcp_servers["playwright"].cwd
|
cwd = config.tools.mcp_servers["playwright"].cwd
|
||||||
assert cwd == str(tmp_path / "mcp" / "playwright")
|
assert cwd == str(tmp_path / "mcp" / "playwright")
|
||||||
assert (tmp_path / "mcp" / "playwright").is_dir()
|
assert (tmp_path / "mcp" / "playwright").is_dir()
|
||||||
@@ -137,7 +137,7 @@ def test_enable_no_auth_remote_presets_write_url(tmp_path, monkeypatch: pytest.M
|
|||||||
mcp_presets_action("enable", {"name": ["exa"]})
|
mcp_presets_action("enable", {"name": ["exa"]})
|
||||||
mcp_presets_action("enable", {"name": ["firecrawl"]})
|
mcp_presets_action("enable", {"name": ["firecrawl"]})
|
||||||
|
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
|
assert config.tools.mcp_servers["microsoft-learn"].url == "https://learn.microsoft.com/api/mcp"
|
||||||
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
|
assert config.tools.mcp_servers["exa"].url == "https://mcp.exa.ai/mcp"
|
||||||
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
|
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
|
||||||
@@ -154,7 +154,7 @@ def test_firecrawl_preset_is_keyless(tmp_path, monkeypatch: pytest.MonkeyPatch)
|
|||||||
assert row["required_fields"] == []
|
assert row["required_fields"] == []
|
||||||
assert row["configured"] is True
|
assert row["configured"] is True
|
||||||
assert "Keyless" in row["note"]
|
assert "Keyless" in row["note"]
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert config.tools.mcp_servers["firecrawl"].type == "streamableHttp"
|
assert config.tools.mcp_servers["firecrawl"].type == "streamableHttp"
|
||||||
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
|
assert config.tools.mcp_servers["firecrawl"].url == "https://mcp.firecrawl.dev/v2/mcp"
|
||||||
assert config.tools.mcp_servers["firecrawl"].env == {}
|
assert config.tools.mcp_servers["firecrawl"].env == {}
|
||||||
@@ -173,7 +173,7 @@ def test_remove_mcp_preset_updates_config(tmp_path, monkeypatch: pytest.MonkeyPa
|
|||||||
assert payload["last_action"]["removed"] is True
|
assert payload["last_action"]["removed"] is True
|
||||||
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
|
assert payload["last_action"]["managed_paths_removed"] == ["runtime:mcp/playwright"]
|
||||||
assert not managed_cwd.exists()
|
assert not managed_cwd.exists()
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert "playwright" not in config.tools.mcp_servers
|
assert "playwright" not in config.tools.mcp_servers
|
||||||
|
|
||||||
|
|
||||||
@@ -196,7 +196,7 @@ def test_remove_custom_mcp_server_preserves_user_cwd(tmp_path, monkeypatch: pyte
|
|||||||
|
|
||||||
assert payload["last_action"]["ok"] is True
|
assert payload["last_action"]["ok"] is True
|
||||||
assert user_cwd.exists()
|
assert user_cwd.exists()
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert "internal-docs" not in config.tools.mcp_servers
|
assert "internal-docs" not in config.tools.mcp_servers
|
||||||
|
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ def test_custom_mcp_server_writes_config_and_catalog_row(
|
|||||||
assert row["manifest"]["capabilities"][0]["command"] == "node"
|
assert row["manifest"]["capabilities"][0]["command"] == "node"
|
||||||
assert "server.js" not in str(row["manifest"])
|
assert "server.js" not in str(row["manifest"])
|
||||||
assert "docs-secret-value" not in str(payload)
|
assert "docs-secret-value" not in str(payload)
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
|
assert config.tools.mcp_servers["internal-docs"].args == ["server.js"]
|
||||||
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
|
assert config.tools.mcp_servers["internal-docs"].env["DOCS_TOKEN"] == "docs-secret-value"
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
|
assert payload["last_action"]["message"] == "Imported 2 MCP server(s)."
|
||||||
config = load_config()
|
config = load_raw_config()
|
||||||
assert config.tools.mcp_servers["docs"].command == "npx"
|
assert config.tools.mcp_servers["docs"].command == "npx"
|
||||||
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
|
assert config.tools.mcp_servers["docs"].args == ["-y", "docs-mcp"]
|
||||||
assert config.tools.mcp_servers["remote-docs"].type == "sse"
|
assert config.tools.mcp_servers["remote-docs"].type == "sse"
|
||||||
@@ -374,7 +374,7 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||||
assert row["enabled_tools"] == ["mcp_docs_search"]
|
assert row["enabled_tools"] == ["mcp_docs_search"]
|
||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
|
assert load_raw_config().tools.mcp_servers["docs"].enabled_tools == ["mcp_docs_search"]
|
||||||
|
|
||||||
payload = custom_mcp_action(
|
payload = custom_mcp_action(
|
||||||
"tools",
|
"tools",
|
||||||
@@ -386,7 +386,7 @@ def test_import_mcp_config_and_tool_allowlist(
|
|||||||
|
|
||||||
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
row = next(item for item in payload["presets"] if item["name"] == "docs")
|
||||||
assert row["enabled_tools"] == []
|
assert row["enabled_tools"] == []
|
||||||
assert load_config().tools.mcp_servers["docs"].enabled_tools == []
|
assert load_raw_config().tools.mcp_servers["docs"].enabled_tools == []
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
def test_normalize_mcp_preset_mentions_accepts_configured_custom_server(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_raw_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.providers.registry import find_by_name
|
from nanobot.providers.registry import find_by_name
|
||||||
from nanobot.webui.settings_api import (
|
from nanobot.webui.settings_api import (
|
||||||
@@ -95,7 +95,7 @@ def test_update_api_settings_requires_key_for_network_access(
|
|||||||
"port": ["9900"],
|
"port": ["9900"],
|
||||||
"api_key": ["secret-token"],
|
"api_key": ["secret-token"],
|
||||||
})
|
})
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.api.host == "0.0.0.0"
|
assert saved.api.host == "0.0.0.0"
|
||||||
assert saved.api.port == 9900
|
assert saved.api.port == 9900
|
||||||
assert saved.api.api_key == "secret-token"
|
assert saved.api.api_key == "secret-token"
|
||||||
@@ -124,7 +124,7 @@ def test_update_api_settings_allows_alternate_loopback_without_key(
|
|||||||
|
|
||||||
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
|
update_api_settings({"host": ["127.0.0.2"], "port": ["8900"]})
|
||||||
|
|
||||||
assert load_config(config_path).api.host == "127.0.0.2"
|
assert load_raw_config(config_path).api.host == "127.0.0.2"
|
||||||
|
|
||||||
|
|
||||||
def _dynamic_provider_config(
|
def _dynamic_provider_config(
|
||||||
@@ -174,7 +174,7 @@ def test_create_model_configuration_writes_label_and_selects(
|
|||||||
rows = {row["name"]: row for row in payload["model_presets"]}
|
rows = {row["name"]: row for row in payload["model_presets"]}
|
||||||
assert rows["fast-writing"]["label"] == "Fast writing"
|
assert rows["fast-writing"]["label"] == "Fast writing"
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.agents.defaults.model_preset == "fast-writing"
|
assert saved.agents.defaults.model_preset == "fast-writing"
|
||||||
assert saved.model_presets["fast-writing"].label == "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"].model == "openai/gpt-4.1-mini"
|
||||||
@@ -209,7 +209,7 @@ def test_create_model_configuration_accepts_dynamic_custom_provider(
|
|||||||
|
|
||||||
assert payload["agent"]["model_preset"] == "tenant-model"
|
assert payload["agent"]["model_preset"] == "tenant-model"
|
||||||
assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME
|
assert payload["agent"]["provider"] == DYNAMIC_PROVIDER_NAME
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME
|
assert saved.model_presets["tenant-model"].provider == DYNAMIC_PROVIDER_NAME
|
||||||
assert saved.model_presets["tenant-model"].model == "gpt-4o-mini"
|
assert saved.model_presets["tenant-model"].model == "gpt-4o-mini"
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ def test_update_model_configuration_edits_named_preset_and_selects(
|
|||||||
|
|
||||||
assert payload["agent"]["model_preset"] == "codex"
|
assert payload["agent"]["model_preset"] == "codex"
|
||||||
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
|
assert payload["agent"]["model"] == "openai-codex/gpt-5.5"
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.agents.defaults.model_preset == "codex"
|
assert saved.agents.defaults.model_preset == "codex"
|
||||||
assert saved.model_presets["codex"].label == "Codex"
|
assert saved.model_presets["codex"].label == "Codex"
|
||||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||||
@@ -318,7 +318,7 @@ def test_update_provider_settings_updates_dynamic_custom_provider(
|
|||||||
providers = {row["name"]: row for row in payload["providers"]}
|
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_base"] == "https://new.example/v1"
|
||||||
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••"
|
assert providers[DYNAMIC_PROVIDER_NAME]["api_key_hint"] == "••••"
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
dynamic_provider = saved.providers.model_extra[DYNAMIC_PROVIDER_NAME]
|
dynamic_provider = saved.providers.model_extra[DYNAMIC_PROVIDER_NAME]
|
||||||
assert dynamic_provider.api_base == "https://new.example/v1"
|
assert dynamic_provider.api_base == "https://new.example/v1"
|
||||||
assert dynamic_provider.api_key == "sk-test"
|
assert dynamic_provider.api_key == "sk-test"
|
||||||
@@ -336,7 +336,7 @@ def test_update_agent_settings_accepts_context_window_options(
|
|||||||
payload = update_agent_settings({"context_window_tokens": ["200000"]})
|
payload = update_agent_settings({"context_window_tokens": ["200000"]})
|
||||||
|
|
||||||
assert payload["agent"]["context_window_tokens"] == 200000
|
assert payload["agent"]["context_window_tokens"] == 200000
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.agents.defaults.context_window_tokens == 200000
|
assert saved.agents.defaults.context_window_tokens == 200000
|
||||||
|
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ def test_update_model_configuration_accepts_context_window_options(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert payload["agent"]["context_window_tokens"] == 262144
|
assert payload["agent"]["context_window_tokens"] == 262144
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.model_presets["codex"].context_window_tokens == 262144
|
assert saved.model_presets["codex"].context_window_tokens == 262144
|
||||||
|
|
||||||
|
|
||||||
@@ -555,7 +555,7 @@ def test_update_web_search_settings_accepts_keenable_without_api_key(
|
|||||||
|
|
||||||
payload = update_web_search_settings({"provider": ["keenable"]})
|
payload = update_web_search_settings({"provider": ["keenable"]})
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.tools.web.search.provider == "keenable"
|
assert saved.tools.web.search.provider == "keenable"
|
||||||
assert saved.tools.web.search.api_key == ""
|
assert saved.tools.web.search.api_key == ""
|
||||||
option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable")
|
option = next(item for item in payload["web_search"]["providers"] if item["name"] == "keenable")
|
||||||
@@ -575,7 +575,7 @@ def test_update_web_search_settings_can_clear_optional_api_key(
|
|||||||
|
|
||||||
update_web_search_settings({"provider": ["keenable"], "api_key": [""]})
|
update_web_search_settings({"provider": ["keenable"], "api_key": [""]})
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.tools.web.search.provider == "keenable"
|
assert saved.tools.web.search.provider == "keenable"
|
||||||
assert saved.tools.web.search.api_key == ""
|
assert saved.tools.web.search.api_key == ""
|
||||||
|
|
||||||
@@ -720,7 +720,7 @@ def test_update_transcription_settings_writes_top_level_only(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.channels.transcription_provider == "openai"
|
assert saved.channels.transcription_provider == "openai"
|
||||||
assert saved.channels.transcription_language == "en"
|
assert saved.channels.transcription_language == "en"
|
||||||
assert saved.transcription.enabled is True
|
assert saved.transcription.enabled is True
|
||||||
@@ -750,7 +750,7 @@ def test_update_transcription_settings_accepts_openrouter(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.transcription.provider == "openrouter"
|
assert saved.transcription.provider == "openrouter"
|
||||||
assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3"
|
assert saved.transcription.model == "nvidia/parakeet-tdt-0.6b-v3"
|
||||||
assert payload["transcription"]["provider"] == "openrouter"
|
assert payload["transcription"]["provider"] == "openrouter"
|
||||||
@@ -775,7 +775,7 @@ def test_update_transcription_settings_accepts_xiaomi_mimo(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.transcription.provider == "xiaomi_mimo"
|
assert saved.transcription.provider == "xiaomi_mimo"
|
||||||
assert saved.transcription.model == "mimo-v2.5-asr"
|
assert saved.transcription.model == "mimo-v2.5-asr"
|
||||||
assert saved.transcription.language == "zh"
|
assert saved.transcription.language == "zh"
|
||||||
@@ -800,7 +800,7 @@ def test_update_transcription_settings_accepts_assemblyai(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.transcription.provider == "assemblyai"
|
assert saved.transcription.provider == "assemblyai"
|
||||||
assert saved.transcription.model == "universal-3-pro"
|
assert saved.transcription.model == "universal-3-pro"
|
||||||
assert payload["transcription"]["provider"] == "assemblyai"
|
assert payload["transcription"]["provider"] == "assemblyai"
|
||||||
@@ -881,7 +881,7 @@ def test_update_network_safety_settings_writes_local_service_flag(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
|
saved_raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
assert saved.tools.webui_allow_local_service_access is False
|
assert saved.tools.webui_allow_local_service_access is False
|
||||||
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
|
assert saved_raw["tools"]["webuiAllowLocalServiceAccess"] is False
|
||||||
@@ -917,7 +917,7 @@ def test_update_network_safety_settings_default_access_is_webui_only(
|
|||||||
|
|
||||||
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
|
payload = update_network_safety_settings({"webui_default_access_mode": ["full"]})
|
||||||
|
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert config_path.read_text(encoding="utf-8") == before
|
assert config_path.read_text(encoding="utf-8") == before
|
||||||
assert saved.tools.restrict_to_workspace is False
|
assert saved.tools.restrict_to_workspace is False
|
||||||
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
assert payload["advanced"]["webui_default_access_mode"] == "full"
|
||||||
@@ -1225,7 +1225,7 @@ def test_create_model_configuration_accepts_configured_oauth_provider(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert payload["agent"]["model_preset"] == "codex"
|
assert payload["agent"]["model_preset"] == "codex"
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.model_presets["codex"].provider == "openai_codex"
|
assert saved.model_presets["codex"].provider == "openai_codex"
|
||||||
|
|
||||||
|
|
||||||
@@ -1312,7 +1312,7 @@ def test_create_model_configuration_accepts_azure_openai_aad_mode(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert payload["agent"]["model_preset"] == "azure-aad"
|
assert payload["agent"]["model_preset"] == "azure-aad"
|
||||||
saved = load_config(config_path)
|
saved = load_raw_config(config_path)
|
||||||
assert saved.model_presets["azure-aad"].provider == "azure_openai"
|
assert saved.model_presets["azure-aad"].provider == "azure_openai"
|
||||||
assert saved.model_presets["azure-aad"].model == "my-deployment"
|
assert saved.model_presets["azure-aad"].model == "my-deployment"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user