Compare commits
6 Commits
f65d1a9857
...
6cd8a9eac7
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cd8a9eac7 | |||
|
|
a9621e109f | ||
|
|
40a022afd9 | ||
|
|
c4cc2a9fb4 | ||
|
|
db37ecbfd2 | ||
|
|
22e129b514 |
@@ -7,6 +7,7 @@ import select
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
# Force UTF-8 encoding for Windows console
|
# Force UTF-8 encoding for Windows console
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
@@ -281,14 +282,15 @@ def onboard():
|
|||||||
save_config(config)
|
save_config(config)
|
||||||
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
|
console.print(f"[green]✓[/green] Config refreshed at {config_path} (existing values preserved)")
|
||||||
else:
|
else:
|
||||||
save_config(Config())
|
config = Config()
|
||||||
|
save_config(config)
|
||||||
console.print(f"[green]✓[/green] Created config at {config_path}")
|
console.print(f"[green]✓[/green] Created config at {config_path}")
|
||||||
|
|
||||||
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
|
console.print("[dim]Config template now uses `maxTokens` + `contextWindowTokens`; `memoryWindow` is no longer a runtime setting.[/dim]")
|
||||||
|
|
||||||
# Create workspace
|
_onboard_plugins(config_path)
|
||||||
workspace = get_workspace_path()
|
|
||||||
|
|
||||||
|
# Create workspace, preferring the configured workspace path.
|
||||||
|
workspace = get_workspace_path(config.workspace_path)
|
||||||
if not workspace.exists():
|
if not workspace.exists():
|
||||||
workspace.mkdir(parents=True, exist_ok=True)
|
workspace.mkdir(parents=True, exist_ok=True)
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||||
@@ -303,7 +305,45 @@ def onboard():
|
|||||||
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_missing_defaults(existing: Any, defaults: Any) -> Any:
|
||||||
|
"""Recursively fill in missing values from defaults without overwriting user config."""
|
||||||
|
if not isinstance(existing, dict) or not isinstance(defaults, dict):
|
||||||
|
return existing
|
||||||
|
|
||||||
|
merged = dict(existing)
|
||||||
|
for key, value in defaults.items():
|
||||||
|
if key not in merged:
|
||||||
|
merged[key] = value
|
||||||
|
else:
|
||||||
|
merged[key] = _merge_missing_defaults(merged[key], value)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _onboard_plugins(config_path: Path) -> None:
|
||||||
|
"""Inject default config for all discovered channels (built-in + plugins)."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from nanobot.channels.registry import discover_all
|
||||||
|
|
||||||
|
all_channels = discover_all()
|
||||||
|
if not all_channels:
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
channels = data.setdefault("channels", {})
|
||||||
|
for name, cls in all_channels.items():
|
||||||
|
default_config = getattr(cls, "default_config", None)
|
||||||
|
if not callable(default_config):
|
||||||
|
continue
|
||||||
|
if name not in channels:
|
||||||
|
channels[name] = default_config()
|
||||||
|
else:
|
||||||
|
channels[name] = _merge_missing_defaults(channels[name], default_config())
|
||||||
|
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
def _make_provider(config: Config):
|
||||||
@@ -326,6 +366,7 @@ def _make_provider(config: Config):
|
|||||||
api_key=p.api_key if p else "no-key",
|
api_key=p.api_key if p else "no-key",
|
||||||
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
|
||||||
default_model=model,
|
default_model=model,
|
||||||
|
extra_headers=p.extra_headers if p else None,
|
||||||
)
|
)
|
||||||
# Azure OpenAI: direct Azure OpenAI endpoint with deployment name
|
# Azure OpenAI: direct Azure OpenAI endpoint with deployment name
|
||||||
elif provider_name == "azure_openai":
|
elif provider_name == "azure_openai":
|
||||||
|
|||||||
@@ -13,14 +13,25 @@ from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
|||||||
|
|
||||||
class CustomProvider(LLMProvider):
|
class CustomProvider(LLMProvider):
|
||||||
|
|
||||||
def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"):
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str = "no-key",
|
||||||
|
api_base: str = "http://localhost:8000/v1",
|
||||||
|
default_model: str = "default",
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
):
|
||||||
super().__init__(api_key, api_base)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
self.default_model = default_model
|
||||||
# Keep affinity stable for this provider instance to improve backend cache locality.
|
# Keep affinity stable for this provider instance to improve backend cache locality,
|
||||||
|
# while still letting users attach provider-specific headers for custom gateways.
|
||||||
|
default_headers = {
|
||||||
|
"x-session-affinity": uuid.uuid4().hex,
|
||||||
|
**(extra_headers or {}),
|
||||||
|
}
|
||||||
self._client = AsyncOpenAI(
|
self._client = AsyncOpenAI(
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
base_url=api_base,
|
base_url=api_base,
|
||||||
default_headers={"x-session-affinity": uuid.uuid4().hex},
|
default_headers=default_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from nanobot.cli.commands import app
|
from nanobot.cli.commands import _make_provider, app
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||||
@@ -38,7 +38,7 @@ def mock_paths():
|
|||||||
mock_ws.return_value = workspace_dir
|
mock_ws.return_value = workspace_dir
|
||||||
mock_sc.side_effect = lambda config: config_file.write_text("{}")
|
mock_sc.side_effect = lambda config: config_file.write_text("{}")
|
||||||
|
|
||||||
yield config_file, workspace_dir
|
yield config_file, workspace_dir, mock_ws
|
||||||
|
|
||||||
if base_dir.exists():
|
if base_dir.exists():
|
||||||
shutil.rmtree(base_dir)
|
shutil.rmtree(base_dir)
|
||||||
@@ -46,7 +46,7 @@ def mock_paths():
|
|||||||
|
|
||||||
def test_onboard_fresh_install(mock_paths):
|
def test_onboard_fresh_install(mock_paths):
|
||||||
"""No existing config — should create from scratch."""
|
"""No existing config — should create from scratch."""
|
||||||
config_file, workspace_dir = mock_paths
|
config_file, workspace_dir, mock_ws = mock_paths
|
||||||
|
|
||||||
result = runner.invoke(app, ["onboard"])
|
result = runner.invoke(app, ["onboard"])
|
||||||
|
|
||||||
@@ -57,11 +57,13 @@ def test_onboard_fresh_install(mock_paths):
|
|||||||
assert config_file.exists()
|
assert config_file.exists()
|
||||||
assert (workspace_dir / "AGENTS.md").exists()
|
assert (workspace_dir / "AGENTS.md").exists()
|
||||||
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
assert (workspace_dir / "memory" / "MEMORY.md").exists()
|
||||||
|
expected_workspace = Config().workspace_path
|
||||||
|
assert mock_ws.call_args.args == (expected_workspace,)
|
||||||
|
|
||||||
|
|
||||||
def test_onboard_existing_config_refresh(mock_paths):
|
def test_onboard_existing_config_refresh(mock_paths):
|
||||||
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
|
"""Config exists, user declines overwrite — should refresh (load-merge-save)."""
|
||||||
config_file, workspace_dir = mock_paths
|
config_file, workspace_dir, _ = mock_paths
|
||||||
config_file.write_text('{"existing": true}')
|
config_file.write_text('{"existing": true}')
|
||||||
|
|
||||||
result = runner.invoke(app, ["onboard"], input="n\n")
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||||
@@ -75,7 +77,7 @@ def test_onboard_existing_config_refresh(mock_paths):
|
|||||||
|
|
||||||
def test_onboard_existing_config_overwrite(mock_paths):
|
def test_onboard_existing_config_overwrite(mock_paths):
|
||||||
"""Config exists, user confirms overwrite — should reset to defaults."""
|
"""Config exists, user confirms overwrite — should reset to defaults."""
|
||||||
config_file, workspace_dir = mock_paths
|
config_file, workspace_dir, _ = mock_paths
|
||||||
config_file.write_text('{"existing": true}')
|
config_file.write_text('{"existing": true}')
|
||||||
|
|
||||||
result = runner.invoke(app, ["onboard"], input="y\n")
|
result = runner.invoke(app, ["onboard"], input="y\n")
|
||||||
@@ -88,7 +90,7 @@ def test_onboard_existing_config_overwrite(mock_paths):
|
|||||||
|
|
||||||
def test_onboard_existing_workspace_safe_create(mock_paths):
|
def test_onboard_existing_workspace_safe_create(mock_paths):
|
||||||
"""Workspace exists — should not recreate, but still add missing templates."""
|
"""Workspace exists — should not recreate, but still add missing templates."""
|
||||||
config_file, workspace_dir = mock_paths
|
config_file, workspace_dir, _ = mock_paths
|
||||||
workspace_dir.mkdir(parents=True)
|
workspace_dir.mkdir(parents=True)
|
||||||
config_file.write_text("{}")
|
config_file.write_text("{}")
|
||||||
|
|
||||||
@@ -192,6 +194,33 @@ def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
|
|||||||
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_provider_passes_extra_headers_to_custom_provider():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"agents": {"defaults": {"provider": "custom", "model": "gpt-4o-mini"}},
|
||||||
|
"providers": {
|
||||||
|
"custom": {
|
||||||
|
"apiKey": "test-key",
|
||||||
|
"apiBase": "https://example.com/v1",
|
||||||
|
"extraHeaders": {
|
||||||
|
"APP-Code": "demo-app",
|
||||||
|
"x-session-affinity": "sticky-session",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("nanobot.providers.custom_provider.AsyncOpenAI") as mock_async_openai:
|
||||||
|
_make_provider(config)
|
||||||
|
|
||||||
|
kwargs = mock_async_openai.call_args.kwargs
|
||||||
|
assert kwargs["api_key"] == "test-key"
|
||||||
|
assert kwargs["base_url"] == "https://example.com/v1"
|
||||||
|
assert kwargs["default_headers"]["APP-Code"] == "demo-app"
|
||||||
|
assert kwargs["default_headers"]["x-session-affinity"] == "sticky-session"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_agent_runtime(tmp_path):
|
def mock_agent_runtime(tmp_path):
|
||||||
"""Mock agent command dependencies for focused CLI tests."""
|
"""Mock agent command dependencies for focused CLI tests."""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ def test_onboard_refresh_rewrites_legacy_config_template(tmp_path, monkeypatch)
|
|||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
|
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda: workspace)
|
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace)
|
||||||
|
|
||||||
result = runner.invoke(app, ["onboard"], input="n\n")
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||||
|
|
||||||
@@ -86,3 +87,46 @@ def test_onboard_refresh_rewrites_legacy_config_template(tmp_path, monkeypatch)
|
|||||||
assert defaults["maxTokens"] == 3333
|
assert defaults["maxTokens"] == 3333
|
||||||
assert defaults["contextWindowTokens"] == 65_536
|
assert defaults["contextWindowTokens"] == 65_536
|
||||||
assert "memoryWindow" not in defaults
|
assert "memoryWindow" not in defaults
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) -> None:
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": False,
|
||||||
|
"appId": "",
|
||||||
|
"secret": "",
|
||||||
|
"allowFrom": [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
|
||||||
|
monkeypatch.setattr("nanobot.cli.commands.get_workspace_path", lambda _workspace=None: workspace)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.channels.registry.discover_all",
|
||||||
|
lambda: {
|
||||||
|
"qq": SimpleNamespace(
|
||||||
|
default_config=lambda: {
|
||||||
|
"enabled": False,
|
||||||
|
"appId": "",
|
||||||
|
"secret": "",
|
||||||
|
"allowFrom": [],
|
||||||
|
"msgFormat": "plain",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["onboard"], input="n\n")
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert saved["channels"]["qq"]["msgFormat"] == "plain"
|
||||||
|
|||||||
Reference in New Issue
Block a user