feat(webui): add model settings runtime refresh

This commit is contained in:
Xubin Ren
2026-04-25 18:05:06 +00:00
parent a58d9fd357
commit b440e76d2f
14 changed files with 793 additions and 172 deletions
+43
View File
@@ -18,6 +18,7 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
from nanobot.agent.memory import Consolidator, Dream
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
from nanobot.agent.runtime import AgentRuntime
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.ask import (
@@ -194,6 +195,8 @@ class AgentLoop:
unified_session: bool = False,
disabled_skills: list[str] | None = None,
tools_config: ToolsConfig | None = None,
runtime_loader: Callable[[], AgentRuntime] | None = None,
runtime_signature: tuple[object, ...] | None = None,
):
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
@@ -202,6 +205,8 @@ class AgentLoop:
self.bus = bus
self.channels_config = channels_config
self.provider = provider
self._runtime_loader = runtime_loader
self._runtime_signature = runtime_signature
self.workspace = workspace
self.model = model or provider.get_default_model()
self.max_iterations = (
@@ -288,6 +293,43 @@ class AgentLoop:
self.commands = CommandRouter()
register_builtin_commands(self.commands)
def _apply_runtime(self, runtime: AgentRuntime) -> None:
"""Swap model/provider for future turns without disturbing an active one."""
provider = runtime.provider
model = runtime.model
context_window_tokens = runtime.context_window_tokens
if self.provider is provider and self.model == model:
return
old_model = self.model
self.provider = provider
self.model = model
self.context_window_tokens = context_window_tokens
self.runner.provider = provider
self.subagents.provider = provider
self.subagents.model = model
self.subagents.runner.provider = provider
self.consolidator.provider = provider
self.consolidator.model = model
self.consolidator.context_window_tokens = context_window_tokens
self.consolidator.max_completion_tokens = provider.generation.max_tokens
self.dream.provider = provider
self.dream.model = model
self.dream._runner.provider = provider
self._runtime_signature = runtime.signature
logger.info("Runtime model switched for next turn: {} -> {}", old_model, model)
def _refresh_runtime(self) -> None:
if self._runtime_loader is None:
return
try:
runtime = self._runtime_loader()
except Exception:
logger.exception("Failed to refresh runtime config")
return
if runtime.signature == self._runtime_signature:
return
self._apply_runtime(runtime)
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = (
@@ -766,6 +808,7 @@ class AgentLoop:
pending_queue: asyncio.Queue | None = None,
) -> OutboundMessage | None:
"""Process a single inbound message and return the response."""
self._refresh_runtime()
# System messages: parse origin from chat_id ("channel:chat_id")
if msg.channel == "system":
channel, chat_id = (
+112
View File
@@ -0,0 +1,112 @@
"""Runtime model/provider resolution for agent turns."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from nanobot.config.schema import Config
from nanobot.providers.base import GenerationSettings, LLMProvider
from nanobot.providers.registry import find_by_name
@dataclass(frozen=True)
class AgentRuntime:
provider: LLMProvider
model: str
context_window_tokens: int
signature: tuple[object, ...]
def make_provider(config: Config) -> LLMProvider:
"""Create the LLM provider implied by config."""
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
if backend == "azure_openai":
if not p or not p.api_key or not p.api_base:
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
elif backend == "openai_compat" and not 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}'.")
if backend == "openai_codex":
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
provider = OpenAICodexProvider(default_model=model)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
provider = AzureOpenAIProvider(
api_key=p.api_key,
api_base=p.api_base,
default_model=model,
)
elif backend == "github_copilot":
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
provider = GitHubCopilotProvider(default_model=model)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
provider = AnthropicProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
provider = OpenAICompatProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
spec=spec,
)
defaults = config.agents.defaults
provider.generation = GenerationSettings(
temperature=defaults.temperature,
max_tokens=defaults.max_tokens,
reasoning_effort=defaults.reasoning_effort,
)
return provider
def runtime_signature(config: Config) -> tuple[object, ...]:
"""Return the config fields that affect the primary LLM runtime."""
model = config.agents.defaults.model
defaults = config.agents.defaults
return (
model,
defaults.provider,
config.get_provider_name(model),
config.get_api_key(model),
config.get_api_base(model),
defaults.max_tokens,
defaults.temperature,
defaults.reasoning_effort,
defaults.context_window_tokens,
)
def build_agent_runtime(config: Config) -> AgentRuntime:
return AgentRuntime(
provider=make_provider(config),
model=config.agents.defaults.model,
context_window_tokens=config.agents.defaults.context_window_tokens,
signature=runtime_signature(config),
)
def load_agent_runtime(config_path: Path | None = None) -> AgentRuntime:
from nanobot.config.loader import load_config, resolve_config_env_vars
return build_agent_runtime(resolve_config_env_vars(load_config(config_path)))
+75
View File
@@ -539,6 +539,12 @@ class WebSocketChannel(BaseChannel):
if got == "/api/sessions":
return self._handle_sessions_list(request)
if got == "/api/settings":
return self._handle_settings(request)
if got == "/api/settings/update":
return self._handle_settings_update(request)
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -647,6 +653,75 @@ class WebSocketChannel(BaseChannel):
]
return _http_json_response({"sessions": cleaned})
def _settings_payload(self, *, requires_restart: bool = False) -> dict[str, Any]:
from nanobot.config.loader import get_config_path, load_config
from nanobot.providers.registry import PROVIDERS, find_by_name
config = load_config()
defaults = config.agents.defaults
provider_name = config.get_provider_name(defaults.model) or defaults.provider
provider = config.get_provider(defaults.model)
selected_provider = provider_name
if defaults.provider != "auto":
spec = find_by_name(defaults.provider)
selected_provider = spec.name if spec else provider_name
return {
"agent": {
"model": defaults.model,
"provider": selected_provider,
"resolved_provider": provider_name,
"has_api_key": bool(provider and provider.api_key),
},
"providers": [
{"name": "auto", "label": "Auto"}
] + [
{"name": spec.name, "label": spec.label}
for spec in PROVIDERS
],
"runtime": {
"config_path": str(get_config_path().expanduser()),
},
"requires_restart": requires_restart,
}
def _handle_settings(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
return _http_json_response(self._settings_payload())
def _handle_settings_update(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
from nanobot.config.loader import load_config, save_config
from nanobot.providers.registry import find_by_name
query = _parse_query(request.path)
config = load_config()
defaults = config.agents.defaults
changed = False
model = _query_first(query, "model")
if model is not None:
model = model.strip()
if not model:
return _http_error(400, "model is required")
if defaults.model != model:
defaults.model = model
changed = True
provider = _query_first(query, "provider")
if provider is not None:
provider = provider.strip() or "auto"
if provider != "auto" and find_by_name(provider) is None:
return _http_error(400, "unknown provider")
if defaults.provider != provider:
defaults.provider = provider
changed = True
if changed:
save_config(config)
return _http_json_response(self._settings_payload(requires_restart=changed))
@staticmethod
def _is_webui_session_key(key: str) -> bool:
"""Return True when *key* belongs to the webui's websocket-only surface."""
+17 -69
View File
@@ -412,73 +412,13 @@ def _make_provider(config: Config):
Routing is driven by ``ProviderSpec.backend`` in the registry.
"""
from nanobot.providers.base import GenerationSettings
from nanobot.providers.registry import find_by_name
from nanobot.agent.runtime import make_provider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
# --- validation ---
if backend == "azure_openai":
if not p or not p.api_key or not p.api_base:
console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]")
console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section")
console.print("Use the model field to specify the deployment name.")
raise typer.Exit(1)
elif backend == "openai_compat" and not 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:
console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1)
# --- instantiation by backend ---
if backend == "openai_codex":
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
provider = OpenAICodexProvider(default_model=model)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
provider = AzureOpenAIProvider(
api_key=p.api_key,
api_base=p.api_base,
default_model=model,
)
elif backend == "github_copilot":
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
provider = GitHubCopilotProvider(default_model=model)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
provider = AnthropicProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
provider = OpenAICompatProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
spec=spec,
)
defaults = config.agents.defaults
provider.generation = GenerationSettings(
temperature=defaults.temperature,
max_tokens=defaults.max_tokens,
reasoning_effort=defaults.reasoning_effort,
)
return provider
try:
return make_provider(config)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
@@ -656,6 +596,7 @@ def _run_gateway(
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.runtime import build_agent_runtime, load_agent_runtime
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -668,7 +609,12 @@ def _run_gateway(
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
sync_workspace_templates(config.workspace_path)
bus = MessageBus()
provider = _make_provider(config)
try:
runtime = build_agent_runtime(config)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
provider = runtime.provider
session_manager = SessionManager(config.workspace_path)
# Preserve existing single-workspace installs, but keep custom workspaces clean.
@@ -684,9 +630,9 @@ def _run_gateway(
bus=bus,
provider=provider,
workspace=config.workspace_path,
model=config.agents.defaults.model,
model=runtime.model,
max_iterations=config.agents.defaults.max_tool_iterations,
context_window_tokens=config.agents.defaults.context_window_tokens,
context_window_tokens=runtime.context_window_tokens,
web_config=config.tools.web,
context_block_limit=config.agents.defaults.context_block_limit,
max_tool_result_chars=config.agents.defaults.max_tool_result_chars,
@@ -702,6 +648,8 @@ def _run_gateway(
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
tools_config=config.tools,
runtime_loader=load_agent_runtime,
runtime_signature=runtime.signature,
)
# Set cron callback (needs agent)
+2 -58
View File
@@ -119,62 +119,6 @@ class Nanobot:
def _make_provider(config: Any) -> Any:
"""Create the LLM provider from config (extracted from CLI)."""
from nanobot.providers.base import GenerationSettings
from nanobot.providers.registry import find_by_name
from nanobot.agent.runtime import make_provider
model = config.agents.defaults.model
provider_name = config.get_provider_name(model)
p = config.get_provider(model)
spec = find_by_name(provider_name) if provider_name else None
backend = spec.backend if spec else "openai_compat"
if backend == "azure_openai":
if not p or not p.api_key or not p.api_base:
raise ValueError("Azure OpenAI requires api_key and api_base in config.")
elif backend == "openai_compat" and not 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}'.")
if backend == "openai_codex":
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
provider = OpenAICodexProvider(default_model=model)
elif backend == "github_copilot":
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
provider = GitHubCopilotProvider(default_model=model)
elif backend == "azure_openai":
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
provider = AzureOpenAIProvider(
api_key=p.api_key, api_base=p.api_base, default_model=model
)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
provider = AnthropicProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
)
else:
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
provider = OpenAICompatProvider(
api_key=p.api_key if p else None,
api_base=config.get_api_base(model),
default_model=model,
extra_headers=p.extra_headers if p else None,
spec=spec,
)
defaults = config.agents.defaults
provider.generation = GenerationSettings(
temperature=defaults.temperature,
max_tokens=defaults.max_tokens,
reasoning_effort=defaults.reasoning_effort,
)
return provider
return make_provider(config)