feat(config): add actionable startup diagnostics and WebUI recovery (#5110)
This commit is contained in:
@@ -33,7 +33,10 @@ def load_model_preset_catalog(
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
return configured_model_presets(
|
||||
resolve_config_env_vars(load_config(config_path)),
|
||||
resolve_config_env_vars(
|
||||
load_config(config_path),
|
||||
config_path=config_path,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+218
-25
@@ -54,6 +54,7 @@ from prompt_toolkit.history import FileHistory # noqa: E402
|
||||
from prompt_toolkit.key_binding import KeyBindings # noqa: E402
|
||||
from prompt_toolkit.keys import Keys # noqa: E402
|
||||
from prompt_toolkit.patch_stdout import patch_stdout # noqa: E402
|
||||
from pydantic import ValidationError # noqa: E402
|
||||
from rich.console import Console # noqa: E402
|
||||
from rich.markdown import Markdown # noqa: E402
|
||||
from rich.markup import escape # noqa: E402
|
||||
@@ -797,9 +798,89 @@ def _model_display(config: Config) -> tuple[str, str]:
|
||||
return resolved.model, tag
|
||||
|
||||
|
||||
def _print_config_error(error: Exception) -> None:
|
||||
"""Render a configuration failure without exposing traceback internals."""
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
|
||||
console.print(Text(str(error), style="red"))
|
||||
if isinstance(error, ConfigLoadError):
|
||||
command = _status_command(error.path)
|
||||
console.print(f"[dim]Check again after editing: {escape(command)}[/dim]")
|
||||
|
||||
|
||||
def _print_runtime_config_validation_error(
|
||||
error: ValidationError,
|
||||
*,
|
||||
config_path: Path,
|
||||
summary: str,
|
||||
path_prefix: tuple[str | int, ...],
|
||||
retry_command: str,
|
||||
) -> None:
|
||||
"""Render a runtime-owned Pydantic config error without exposing input values."""
|
||||
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||
|
||||
issues = tuple(
|
||||
ConfigIssue(
|
||||
path=(*path_prefix, *issue.path),
|
||||
message=issue.message,
|
||||
)
|
||||
for issue in validation_issues(error)
|
||||
)
|
||||
diagnostic = ConfigLoadError(
|
||||
config_path,
|
||||
kind="invalid_schema",
|
||||
summary=summary,
|
||||
issues=issues,
|
||||
)
|
||||
console.print(Text(str(diagnostic), style="red"))
|
||||
console.print(f"[dim]Fix the listed setting, then retry: {escape(retry_command)}[/dim]")
|
||||
|
||||
|
||||
def _status_command(config_path: Path) -> str:
|
||||
return f'nanobot status --config "{config_path}"'
|
||||
|
||||
|
||||
def _print_model_setup_steps(config_path: Path) -> None:
|
||||
"""Show the shortest setup routes shared by Status and Agent startup."""
|
||||
config_arg = f'--config "{config_path}"'
|
||||
console.print(
|
||||
f" WebUI: run [cyan]nanobot webui {escape(config_arg)}[/cyan], "
|
||||
"then open Settings → Models"
|
||||
)
|
||||
console.print(f" CLI: run [cyan]nanobot onboard --wizard {escape(config_arg)}[/cyan]")
|
||||
console.print(f" Check: [cyan]{escape(_status_command(config_path))}[/cyan]")
|
||||
|
||||
|
||||
def _print_agent_start_error(error: ValueError) -> None:
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
console.print(Text(f"Agent cannot start: {error}", style="red"))
|
||||
console.print("Complete provider/model setup:")
|
||||
_print_model_setup_steps(get_config_path())
|
||||
|
||||
|
||||
def _load_config_for_cli(
|
||||
config_path: Path | None = None,
|
||||
*,
|
||||
resolve_env: bool = False,
|
||||
) -> Config:
|
||||
"""Load CLI configuration and turn expected failures into a clean exit."""
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
try:
|
||||
loaded = load_config(config_path)
|
||||
if resolve_env:
|
||||
loaded = resolve_config_env_vars(loaded)
|
||||
return loaded
|
||||
except ConfigLoadError as exc:
|
||||
_print_config_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
|
||||
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
|
||||
"""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 set_config_path
|
||||
|
||||
config_path = None
|
||||
if config:
|
||||
@@ -810,11 +891,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
||||
set_config_path(config_path)
|
||||
console.print(f"[dim]Using config: {config_path}[/dim]")
|
||||
|
||||
try:
|
||||
loaded = resolve_config_env_vars(load_config(config_path))
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1)
|
||||
loaded = _load_config_for_cli(config_path, resolve_env=True)
|
||||
if workspace:
|
||||
loaded.agents.defaults.workspace = workspace
|
||||
return loaded
|
||||
@@ -840,6 +917,7 @@ def _load_inspection_config(
|
||||
workspace: str | None = None,
|
||||
) -> tuple[Path, Config]:
|
||||
"""Load config for diagnostic commands without resolving secret env refs."""
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import get_config_path, load_config, set_config_path
|
||||
|
||||
config_path = None
|
||||
@@ -851,6 +929,9 @@ def _load_inspection_config(
|
||||
display_path = config_path or get_config_path()
|
||||
try:
|
||||
loaded = load_config(config_path)
|
||||
except ConfigLoadError as exc:
|
||||
_print_config_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
@@ -901,21 +982,15 @@ def _resolve_webui_config_path(config: str | None) -> Path:
|
||||
|
||||
def _load_webui_setup_config(config_path: Path) -> Config:
|
||||
"""Load config for first-run mutation without resolving env-var placeholders."""
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
try:
|
||||
return load_config(config_path)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
return _load_config_for_cli(config_path)
|
||||
|
||||
|
||||
def _provider_setup_error(config: Config) -> str | None:
|
||||
"""Return the provider setup error, or None when the current model can start."""
|
||||
from nanobot.providers.factory import build_provider_snapshot
|
||||
"""Return a local provider/model configuration error, or None."""
|
||||
from nanobot.providers.factory import validate_provider_setup
|
||||
|
||||
try:
|
||||
build_provider_snapshot(config)
|
||||
validate_provider_setup(config)
|
||||
except ValueError as exc:
|
||||
return str(exc)
|
||||
return None
|
||||
@@ -937,6 +1012,60 @@ def _webui_channel_enabled(config: Config) -> bool:
|
||||
return bool(WebSocketConfig.model_validate(current).enabled)
|
||||
|
||||
|
||||
def _validate_gateway_startup(config: Config) -> str | None:
|
||||
"""Validate gateway startup and return a provider error recoverable through WebUI."""
|
||||
from nanobot.config.loader import get_config_path
|
||||
|
||||
config_path = get_config_path()
|
||||
try:
|
||||
webui_config = _webui_config_dict(config)
|
||||
except ValidationError as exc:
|
||||
retry_command = f'nanobot gateway --config "{config_path}"'
|
||||
_print_runtime_config_validation_error(
|
||||
exc,
|
||||
config_path=config_path,
|
||||
summary="Gateway configuration is invalid.",
|
||||
path_prefix=("channels", "websocket"),
|
||||
retry_command=retry_command,
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
provider_error = _provider_setup_error(config)
|
||||
if not provider_error:
|
||||
return None
|
||||
|
||||
if bool(webui_config["enabled"]):
|
||||
console.print(
|
||||
Text(f"Provider/model setup is incomplete: {provider_error}", style="yellow")
|
||||
)
|
||||
console.print(
|
||||
"Gateway will start so you can configure a provider and model "
|
||||
"in WebUI Settings → Models."
|
||||
)
|
||||
browser_url = _webui_browser_url(config)
|
||||
webui_url = browser_url.split("/#/", 1)[0]
|
||||
console.print(Text(f"WebUI: {webui_url}", style="cyan"))
|
||||
if browser_url != webui_url:
|
||||
secret_key = (
|
||||
"tokenIssueSecret"
|
||||
if str(webui_config.get("tokenIssueSecret") or "").strip()
|
||||
else "token"
|
||||
)
|
||||
console.print(
|
||||
Text(
|
||||
f"If prompted, enter the configured channels.websocket.{secret_key} "
|
||||
f"value (see {config_path}).",
|
||||
style="dim",
|
||||
)
|
||||
)
|
||||
return provider_error
|
||||
|
||||
console.print(Text(f"Gateway cannot start: {provider_error}", style="red"))
|
||||
console.print("Complete provider/model setup:")
|
||||
_print_model_setup_steps(config_path)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _prepare_webui_bundle_for_gateway(
|
||||
config: Config,
|
||||
*,
|
||||
@@ -1234,14 +1363,20 @@ def _gateway_instance_command(
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
|
||||
def _run_quick_start_for_webui(config: Config, *, yes: bool) -> Config:
|
||||
def _run_quick_start_for_webui(
|
||||
config: Config,
|
||||
*,
|
||||
yes: bool,
|
||||
config_path: Path,
|
||||
) -> Config:
|
||||
"""Offer the existing Quick Start flow when provider setup is missing."""
|
||||
if yes:
|
||||
console.print(
|
||||
"[red]Error: provider/model setup is incomplete, and --yes cannot answer "
|
||||
"provider credentials. Run `nanobot webui` interactively or "
|
||||
"`nanobot onboard --wizard`.[/red]"
|
||||
"provider credentials.[/red]"
|
||||
)
|
||||
console.print("Complete provider/model setup:")
|
||||
_print_model_setup_steps(config_path)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print()
|
||||
@@ -1433,9 +1568,12 @@ def webui(
|
||||
setup_config.agents.defaults.workspace = workspace
|
||||
|
||||
try:
|
||||
resolved_setup_config = resolve_config_env_vars(setup_config.model_copy(deep=True))
|
||||
resolved_setup_config = resolve_config_env_vars(
|
||||
setup_config.model_copy(deep=True),
|
||||
config_path=config_path,
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
_print_config_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
provider_error = _provider_setup_error(resolved_setup_config)
|
||||
@@ -1451,7 +1589,11 @@ def webui(
|
||||
raise typer.Exit(1)
|
||||
elif provider_error:
|
||||
console.print(f"[dim]Provider check: {provider_error}[/dim]")
|
||||
setup_config = _run_quick_start_for_webui(setup_config, yes=yes)
|
||||
setup_config = _run_quick_start_for_webui(
|
||||
setup_config,
|
||||
yes=yes,
|
||||
config_path=config_path,
|
||||
)
|
||||
if workspace:
|
||||
setup_config.agents.defaults.workspace = workspace
|
||||
|
||||
@@ -1463,6 +1605,16 @@ def webui(
|
||||
)
|
||||
_warn_webui_bind_scope(setup_config)
|
||||
webui_url = _webui_browser_url(setup_config)
|
||||
except ValidationError as exc:
|
||||
retry_command = f'nanobot webui --config "{config_path}"'
|
||||
_print_runtime_config_validation_error(
|
||||
exc,
|
||||
config_path=config_path,
|
||||
summary="WebUI configuration is invalid.",
|
||||
path_prefix=("channels", "websocket"),
|
||||
retry_command=retry_command,
|
||||
)
|
||||
raise typer.Exit(1) from exc
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: invalid WebUI channel config: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
@@ -2228,6 +2380,7 @@ app.add_typer(
|
||||
log_handler_id=_log_handler_id,
|
||||
load_runtime_config=_load_runtime_config,
|
||||
run_gateway=_run_gateway,
|
||||
validate_startup_config=_validate_gateway_startup,
|
||||
prepare_webui_bundle=lambda config, mode: _prepare_webui_bundle_for_gateway(
|
||||
config,
|
||||
mode=mode,
|
||||
@@ -2254,9 +2407,16 @@ def agent(
|
||||
"""Interact with the agent directly."""
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.providers.factory import make_provider
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
|
||||
config = _load_runtime_config(config, workspace)
|
||||
try:
|
||||
provider = make_provider(config)
|
||||
except ValueError as exc:
|
||||
_print_agent_start_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
|
||||
sync_workspace_templates(config.workspace_path)
|
||||
|
||||
bus = MessageBus()
|
||||
@@ -2274,12 +2434,13 @@ def agent(
|
||||
try:
|
||||
agent_loop = AgentLoop.from_config(
|
||||
config, bus,
|
||||
provider=provider,
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
_print_agent_start_error(exc)
|
||||
raise typer.Exit(1) from exc
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||
@@ -2683,11 +2844,32 @@ def status(
|
||||
)
|
||||
|
||||
if config_path.exists():
|
||||
from nanobot.config.errors import ConfigLoadError
|
||||
from nanobot.config.loader import resolve_config_env_vars, resolve_env_refs
|
||||
from nanobot.providers.registry import PROVIDERS
|
||||
|
||||
_model, _preset_tag = _model_display(loaded)
|
||||
console.print(f"Model: {_model}{_preset_tag}")
|
||||
|
||||
provider_ready = False
|
||||
try:
|
||||
resolved = resolve_config_env_vars(
|
||||
loaded.model_copy(deep=True),
|
||||
config_path=config_path,
|
||||
)
|
||||
except ConfigLoadError as exc:
|
||||
console.print("Agent: [red]✗ configuration is not ready[/red]")
|
||||
_print_config_error(exc)
|
||||
else:
|
||||
provider_error = _provider_setup_error(resolved)
|
||||
if provider_error:
|
||||
console.print(Text(f"Agent: ✗ {provider_error}", style="red"))
|
||||
console.print("Complete provider/model setup:")
|
||||
_print_model_setup_steps(config_path)
|
||||
else:
|
||||
provider_ready = True
|
||||
console.print("Agent: [green]✓ provider/model configuration is ready[/green]")
|
||||
|
||||
# Check API keys from registry
|
||||
for spec in PROVIDERS:
|
||||
p = getattr(loaded.providers, spec.name, None)
|
||||
@@ -2697,14 +2879,25 @@ def status(
|
||||
console.print(f"{spec.label}: [green]✓ (OAuth)[/green]")
|
||||
elif spec.is_local:
|
||||
# Local deployments show api_base instead of api_key
|
||||
if p.api_base:
|
||||
if resolve_env_refs(p.api_base or ""):
|
||||
console.print(f"{spec.label}: [green]✓ {p.api_base}[/green]")
|
||||
else:
|
||||
console.print(f"{spec.label}: [dim]not set[/dim]")
|
||||
else:
|
||||
has_key = bool(p.api_key)
|
||||
has_key = bool(resolve_env_refs(p.api_key or ""))
|
||||
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
|
||||
|
||||
if provider_ready:
|
||||
console.print()
|
||||
console.print('Next: [cyan]nanobot agent -m "Hello!"[/cyan]')
|
||||
console.print(
|
||||
"[dim]Status does not call the model or verify network access and credentials.[/dim]"
|
||||
)
|
||||
else:
|
||||
console.print("Agent: [red]✗ configuration file not found[/red]")
|
||||
console.print("Create the provider/model configuration:")
|
||||
_print_model_setup_steps(config_path)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# OAuth Login
|
||||
|
||||
+18
-1
@@ -29,6 +29,7 @@ from nanobot.webui.build import BuildMode
|
||||
|
||||
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
|
||||
GatewayRunner = Callable[..., None]
|
||||
GatewayConfigValidator = Callable[[Config], str | None]
|
||||
GatewayRuntimeFactory = Callable[..., Any]
|
||||
GatewayServiceFactory = Callable[[], Any]
|
||||
WebUIBundlePreparer = Callable[[Config, BuildMode], None]
|
||||
@@ -40,6 +41,7 @@ def create_gateway_app(
|
||||
log_handler_id: int,
|
||||
load_runtime_config: RuntimeConfigLoader,
|
||||
run_gateway: GatewayRunner,
|
||||
validate_startup_config: GatewayConfigValidator | None = None,
|
||||
runtime_factory: GatewayRuntimeFactory | None = None,
|
||||
service_factory: GatewayServiceFactory | None = None,
|
||||
prepare_webui_bundle: WebUIBundlePreparer | None = None,
|
||||
@@ -149,6 +151,8 @@ def create_gateway_app(
|
||||
raise typer.Exit(1)
|
||||
if background:
|
||||
cfg = load_runtime_config(config, workspace)
|
||||
if validate_startup_config is not None:
|
||||
validate_startup_config(cfg)
|
||||
if prepare_webui_bundle is not None:
|
||||
prepare_webui_bundle(cfg, interactive_build_mode())
|
||||
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||
@@ -171,7 +175,18 @@ def create_gateway_app(
|
||||
|
||||
configure_logging(verbose)
|
||||
cfg = load_runtime_config(config, workspace)
|
||||
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
|
||||
unconfigured_provider_error = None
|
||||
if validate_startup_config is not None:
|
||||
unconfigured_provider_error = validate_startup_config(cfg)
|
||||
if unconfigured_provider_error is None:
|
||||
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
|
||||
else:
|
||||
run_gateway(
|
||||
cfg,
|
||||
port=port,
|
||||
webui_bundle_mode=interactive_build_mode(),
|
||||
unconfigured_provider_error=unconfigured_provider_error,
|
||||
)
|
||||
|
||||
@gateway_app.command("status")
|
||||
def gateway_status(
|
||||
@@ -225,6 +240,8 @@ def create_gateway_app(
|
||||
) -> None:
|
||||
"""Restart the background gateway."""
|
||||
cfg = load_runtime_config(config, workspace)
|
||||
if validate_startup_config is not None:
|
||||
validate_startup_config(cfg)
|
||||
if prepare_webui_bundle is not None:
|
||||
prepare_webui_bundle(cfg, interactive_build_mode())
|
||||
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Configuration module for nanobot."""
|
||||
|
||||
from nanobot.config.errors import ConfigIssue, ConfigLoadError
|
||||
from nanobot.config.loader import get_config_path, load_config
|
||||
from nanobot.config.paths import (
|
||||
get_cli_history_path,
|
||||
@@ -17,6 +18,8 @@ from nanobot.config.schema import Config
|
||||
|
||||
__all__ = [
|
||||
"Config",
|
||||
"ConfigIssue",
|
||||
"ConfigLoadError",
|
||||
"load_config",
|
||||
"get_config_path",
|
||||
"get_data_dir",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""User-safe configuration diagnostics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
ConfigErrorKind = Literal[
|
||||
"invalid_json",
|
||||
"invalid_root",
|
||||
"invalid_schema",
|
||||
"missing_env",
|
||||
"io_error",
|
||||
]
|
||||
ConfigPathPart = str | int
|
||||
_SAFE_LOCATION_PART = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}")
|
||||
|
||||
|
||||
def _display_location_part(part: ConfigPathPart) -> str:
|
||||
if isinstance(part, int):
|
||||
return str(part)
|
||||
return part if _SAFE_LOCATION_PART.fullmatch(part) else "<redacted>"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigIssue:
|
||||
"""One actionable configuration problem."""
|
||||
|
||||
path: tuple[ConfigPathPart, ...]
|
||||
message: str
|
||||
|
||||
@property
|
||||
def location(self) -> str:
|
||||
# Pydantic locations can contain user-controlled mapping keys. Only
|
||||
# render conventional config identifiers so credential-bearing URLs
|
||||
# and other free-form values cannot leak through a redacted error.
|
||||
if not self.path:
|
||||
return "<root>"
|
||||
return ".".join(_display_location_part(part) for part in self.path)
|
||||
|
||||
|
||||
class ConfigLoadError(ValueError):
|
||||
"""A structured, user-safe configuration loading failure."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
kind: ConfigErrorKind,
|
||||
summary: str,
|
||||
issues: tuple[ConfigIssue, ...] = (),
|
||||
) -> None:
|
||||
self.path = path
|
||||
self.kind = kind
|
||||
self.summary = summary
|
||||
self.issues = issues
|
||||
super().__init__(summary)
|
||||
|
||||
def __str__(self) -> str:
|
||||
lines = [f"Invalid configuration: {self.path}", "", self.summary]
|
||||
for issue in self.issues[:10]:
|
||||
lines.extend(("", f" {issue.location}", f" {issue.message}"))
|
||||
remaining = len(self.issues) - 10
|
||||
if remaining > 0:
|
||||
lines.extend(("", f" … and {remaining} more issue(s)"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validation_issues(
|
||||
error: ValidationError,
|
||||
) -> tuple[ConfigIssue, ...]:
|
||||
"""Convert Pydantic details to actionable messages without exposing input values."""
|
||||
issues: list[ConfigIssue] = []
|
||||
for detail in error.errors(
|
||||
include_url=False,
|
||||
include_context=False,
|
||||
include_input=False,
|
||||
):
|
||||
location = tuple(detail.get("loc", ()))
|
||||
code = str(detail.get("type") or "")
|
||||
message = _friendly_validation_message(
|
||||
str(detail.get("msg") or "Invalid value"),
|
||||
code,
|
||||
)
|
||||
issues.append(ConfigIssue(path=location, message=message))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _friendly_validation_message(message: str, code: str) -> str:
|
||||
if code == "extra_forbidden":
|
||||
return "Unknown setting."
|
||||
if code == "missing":
|
||||
return "This setting is required."
|
||||
if code in {"assertion_error", "value_error"}:
|
||||
# Custom validators control these messages and may interpolate the
|
||||
# rejected value. Keep the field location, but never render that text.
|
||||
return "Value does not satisfy this setting's requirements."
|
||||
if message.startswith("Value error, "):
|
||||
message = message.removeprefix("Value error, ")
|
||||
elif message.startswith("Input should be "):
|
||||
message = "Must be " + message.removeprefix("Input should be ")
|
||||
elif message.startswith("Input should have "):
|
||||
message = "Must have " + message.removeprefix("Input should have ")
|
||||
if message:
|
||||
message = message[:1].upper() + message[1:]
|
||||
if message and message[-1] not in ".!?":
|
||||
message += "."
|
||||
return message or "Invalid value."
|
||||
+145
-14
@@ -6,9 +6,10 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pydantic
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic_settings import SettingsError
|
||||
|
||||
from nanobot.config.errors import ConfigIssue, ConfigLoadError, validation_issues
|
||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||
from nanobot.utils.helpers import _write_text_atomic
|
||||
|
||||
@@ -47,15 +48,79 @@ def load_config(config_path: Path | None = None) -> Config:
|
||||
|
||||
path = config_path or get_config_path()
|
||||
|
||||
config = Config()
|
||||
if path.exists():
|
||||
if not 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
|
||||
config = Config()
|
||||
except SettingsError as exc:
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="invalid_schema",
|
||||
summary=(
|
||||
"Environment-based configuration could not be parsed. "
|
||||
"Check that complex NANOBOT_* values use valid JSON."
|
||||
),
|
||||
) from exc
|
||||
except ValidationError as exc:
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="invalid_schema",
|
||||
summary="Environment-based configuration is invalid.",
|
||||
issues=validation_issues(exc),
|
||||
) from exc
|
||||
_apply_ssrf_whitelist(config)
|
||||
return config
|
||||
|
||||
try:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="invalid_json",
|
||||
summary=(
|
||||
f"JSON syntax error at line {exc.lineno}, column {exc.colno}: "
|
||||
f"{_sentence(exc.msg)}"
|
||||
),
|
||||
) from exc
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="io_error",
|
||||
summary="The file is not valid UTF-8.",
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
detail = exc.strerror or type(exc).__name__
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="io_error",
|
||||
summary=f"Unable to read the file: {_sentence(detail)}",
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
root_type = type(data).__name__
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="invalid_root",
|
||||
summary="The top level of config.json must be a JSON object.",
|
||||
issues=(
|
||||
ConfigIssue(
|
||||
path=(),
|
||||
message=f"Expected an object, but found {root_type}.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
data = _migrate_config(data)
|
||||
try:
|
||||
config = Config.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
issues = validation_issues(exc)
|
||||
raise ConfigLoadError(
|
||||
path,
|
||||
kind="invalid_schema",
|
||||
summary=f"Found {len(issues)} invalid setting(s).",
|
||||
issues=issues,
|
||||
) from exc
|
||||
|
||||
_apply_ssrf_whitelist(config)
|
||||
return config
|
||||
@@ -116,13 +181,25 @@ def merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||
_ENV_REF_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
def resolve_config_env_vars(config: Config) -> Config:
|
||||
def resolve_config_env_vars(
|
||||
config: Config,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
) -> Config:
|
||||
"""Return *config* with ``${VAR}`` env-var references resolved.
|
||||
|
||||
Walks in place so fields declared with ``exclude=True`` survive;
|
||||
returns the same instance when no references are present.
|
||||
Raises ``ValueError`` if a referenced variable is not set.
|
||||
Raises ``ConfigLoadError`` if a referenced variable is not set.
|
||||
"""
|
||||
missing = tuple(_missing_env_issues(config))
|
||||
if missing:
|
||||
raise ConfigLoadError(
|
||||
config_path or get_config_path(),
|
||||
kind="missing_env",
|
||||
summary=f"Found {len(missing)} missing environment variable reference(s).",
|
||||
issues=missing,
|
||||
)
|
||||
return _resolve_in_place(config)
|
||||
|
||||
|
||||
@@ -176,6 +253,42 @@ def _resolve_in_place(obj: Any) -> Any:
|
||||
return obj
|
||||
|
||||
|
||||
def _missing_env_issues(
|
||||
obj: Any,
|
||||
path: tuple[str | int, ...] = (),
|
||||
) -> list[ConfigIssue]:
|
||||
if isinstance(obj, str):
|
||||
return [
|
||||
ConfigIssue(
|
||||
path=path,
|
||||
message=f"Environment variable '{name}' is not set.",
|
||||
)
|
||||
for name in dict.fromkeys(_ENV_REF_PATTERN.findall(obj))
|
||||
if name not in os.environ
|
||||
]
|
||||
if isinstance(obj, BaseModel):
|
||||
issues: list[ConfigIssue] = []
|
||||
for name, field in type(obj).model_fields.items():
|
||||
alias = field.serialization_alias or field.alias or name
|
||||
part = alias if isinstance(alias, str) else name
|
||||
issues.extend(_missing_env_issues(getattr(obj, name), (*path, part)))
|
||||
for name, value in (obj.__pydantic_extra__ or {}).items():
|
||||
issues.extend(_missing_env_issues(value, (*path, name)))
|
||||
return issues
|
||||
if isinstance(obj, dict):
|
||||
issues = []
|
||||
for name, value in obj.items():
|
||||
part = name if isinstance(name, (str, int)) else str(name)
|
||||
issues.extend(_missing_env_issues(value, (*path, part)))
|
||||
return issues
|
||||
if isinstance(obj, list):
|
||||
issues = []
|
||||
for index, value in enumerate(obj):
|
||||
issues.extend(_missing_env_issues(value, (*path, index)))
|
||||
return issues
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_env_vars(obj: object) -> object:
|
||||
"""Recursively resolve ``${VAR}`` patterns in plain strings/dicts/lists."""
|
||||
if isinstance(obj, str):
|
||||
@@ -201,15 +314,26 @@ def _migrate_config(data: dict) -> dict:
|
||||
"""Migrate old config formats to current."""
|
||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||
tools = data.get("tools", {})
|
||||
if not isinstance(tools, dict):
|
||||
return data
|
||||
exec_cfg = tools.get("exec", {})
|
||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||||
if (
|
||||
isinstance(exec_cfg, dict)
|
||||
and "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", {})
|
||||
my_cfg = tools.get("my")
|
||||
if my_cfg is None:
|
||||
my_cfg = {}
|
||||
tools["my"] = my_cfg
|
||||
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:
|
||||
@@ -220,3 +344,10 @@ def _migrate_config(data: dict) -> dict:
|
||||
tools.pop("mySet", None)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _sentence(message: str) -> str:
|
||||
message = message.strip()
|
||||
if message and message[-1] not in ".!?":
|
||||
message += "."
|
||||
return message
|
||||
|
||||
+4
-1
@@ -105,7 +105,10 @@ class Nanobot:
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Config not found: {resolved}")
|
||||
|
||||
config: Config = resolve_config_env_vars(load_config(resolved))
|
||||
config: Config = resolve_config_env_vars(
|
||||
load_config(resolved),
|
||||
config_path=resolved,
|
||||
)
|
||||
if workspace is not None:
|
||||
config.agents.defaults.workspace = str(
|
||||
Path(workspace).expanduser().resolve()
|
||||
|
||||
@@ -21,6 +21,15 @@ class ProviderSnapshot:
|
||||
model_preset: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ProviderSetup:
|
||||
model: str
|
||||
provider_name: str
|
||||
provider_config: ProviderConfig | None
|
||||
spec: ProviderSpec | None
|
||||
backend: str
|
||||
|
||||
|
||||
def _resolve_model_preset(
|
||||
config: Config,
|
||||
*,
|
||||
@@ -40,18 +49,20 @@ def _provider_extra_headers(
|
||||
return headers or None
|
||||
|
||||
|
||||
def _make_provider_core(
|
||||
def _resolve_provider_setup(
|
||||
config: Config,
|
||||
*,
|
||||
preset: ModelPresetConfig,
|
||||
model: str | None = None,
|
||||
) -> LLMProvider:
|
||||
"""Create a plain LLM provider without failover wrapping."""
|
||||
) -> _ProviderSetup:
|
||||
"""Resolve and validate provider configuration without constructing a client."""
|
||||
model = model or preset.model
|
||||
provider_name = config.get_provider_name(model, preset=preset)
|
||||
p = config.get_provider(model, preset=preset)
|
||||
spec = find_by_name(provider_name) if provider_name else None
|
||||
if provider_name and not spec and p:
|
||||
if not provider_name:
|
||||
raise ValueError(f"No provider is configured for model '{model}'.")
|
||||
spec = find_by_name(provider_name)
|
||||
if not spec and p:
|
||||
if not p.api_base:
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
spec = create_dynamic_spec(
|
||||
@@ -79,12 +90,57 @@ def _make_provider_core(
|
||||
and not (p and p.api_base)
|
||||
):
|
||||
raise ValueError(f"Provider '{provider_name}' requires api_base in config.")
|
||||
elif backend == "openai_compat" and not model.startswith("bedrock/"):
|
||||
elif backend in {"anthropic", "openai_compat"} and not (
|
||||
backend == "openai_compat" and model.startswith("bedrock/")
|
||||
):
|
||||
needs_key = not (p and p.api_key)
|
||||
exempt = spec and (spec.is_oauth or spec.is_local or spec.is_direct)
|
||||
if needs_key and not exempt:
|
||||
raise ValueError(f"No API key configured for provider '{provider_name}'.")
|
||||
|
||||
return _ProviderSetup(
|
||||
model=model,
|
||||
provider_name=provider_name,
|
||||
provider_config=p,
|
||||
spec=spec,
|
||||
backend=backend,
|
||||
)
|
||||
|
||||
|
||||
def validate_provider_setup(
|
||||
config: Config,
|
||||
*,
|
||||
preset_name: str | None = None,
|
||||
preset: ModelPresetConfig | None = None,
|
||||
model: str | None = None,
|
||||
) -> None:
|
||||
"""Validate local provider/model settings without loading a provider client."""
|
||||
resolved = _resolve_model_preset(config, preset_name=preset_name, preset=preset)
|
||||
_resolve_provider_setup(
|
||||
config,
|
||||
preset=resolved,
|
||||
model=model,
|
||||
)
|
||||
|
||||
|
||||
def _make_provider_core(
|
||||
config: Config,
|
||||
*,
|
||||
preset: ModelPresetConfig,
|
||||
model: str | None = None,
|
||||
) -> LLMProvider:
|
||||
"""Create a plain LLM provider without failover wrapping."""
|
||||
setup = _resolve_provider_setup(
|
||||
config,
|
||||
preset=preset,
|
||||
model=model,
|
||||
)
|
||||
model = setup.model
|
||||
provider_name = setup.provider_name
|
||||
p = setup.provider_config
|
||||
spec = setup.spec
|
||||
backend = setup.backend
|
||||
|
||||
if backend == "openai_codex":
|
||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
||||
|
||||
@@ -315,6 +371,9 @@ def load_provider_snapshot(
|
||||
from nanobot.config.loader import load_config, resolve_config_env_vars
|
||||
|
||||
return build_provider_snapshot(
|
||||
resolve_config_env_vars(load_config(config_path)),
|
||||
resolve_config_env_vars(
|
||||
load_config(config_path),
|
||||
config_path=config_path,
|
||||
),
|
||||
preset_name=preset_name,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user