OpenSpace/openspace/host_detection/nanobot.py
2026-04-05 15:13:07 +08:00

226 lines
8 KiB
Python

"""Nanobot host-agent config reader.
Reads ``~/.nanobot/config.json`` to auto-detect:
- LLM provider credentials (``providers.*``)
- MCP env block for the ``openspace`` server
- Default model and forced provider settings
Provider keyword → config field mapping mirrors ``nanobot/providers/registry.py``.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger("openspace.host_detection")
PROVIDER_REGISTRY: List[tuple] = [
# Gateways
("openrouter", ("openrouter",), "https://openrouter.ai/api/v1"),
("aihubmix", ("aihubmix",), "https://aihubmix.com/v1"),
("siliconflow", ("siliconflow",), "https://api.siliconflow.cn/v1"),
("volcengine", ("volcengine", "volces", "ark"), "https://ark.cn-beijing.volces.com/api/v3"),
# Standard providers
("anthropic", ("anthropic", "claude"), ""),
("openai", ("openai", "gpt"), ""),
("deepseek", ("deepseek",), ""),
("gemini", ("gemini",), ""),
("zhipu", ("zhipu", "glm", "zai"), ""),
("dashscope", ("qwen", "dashscope"), ""),
("moonshot", ("moonshot", "kimi"), "https://api.moonshot.ai/v1"),
("minimax", ("minimax",), "https://api.minimaxi.com/v1"),
("groq", ("groq",), ""),
]
def _resolve_nanobot_config_path() -> Path:
"""Resolve the nanobot config path from env overrides or defaults."""
explicit = os.environ.get("NANOBOT_CONFIG_PATH", "").strip()
if explicit:
return Path(explicit).expanduser()
state_dir = os.environ.get("NANOBOT_STATE_DIR", "").strip()
if state_dir:
return Path(state_dir).expanduser() / "config.json"
return Path.home() / ".nanobot" / "config.json"
def _load_nanobot_config() -> Optional[Dict[str, Any]]:
"""Load and parse nanobot config.json. Returns None on failure."""
config_path = _resolve_nanobot_config_path()
if not config_path.is_file():
return None
try:
with open(config_path, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read nanobot config %s: %s", config_path, e)
return None
def match_provider(
providers: Dict[str, Any],
model: str,
forced_provider: str = "auto",
) -> Optional[Dict[str, Any]]:
"""Match a provider config dict from nanobot's ``providers`` section.
Resolution order:
1. ``forced_provider`` (if not "auto") → use that config field directly.
2. Prefix match: model's first ``/``-separated segment == config field name.
3. Keyword match: iterate ``PROVIDER_REGISTRY`` by priority.
4. Fallback: first provider with a non-empty ``apiKey``.
Returns:
``{"api_key": ..., "api_base": ..., "extra_headers": ...}``
(litellm-compatible), or None.
"""
def _extract(prov_dict: Dict[str, Any], default_base: str = "") -> Optional[Dict[str, Any]]:
api_key = prov_dict.get("apiKey") or prov_dict.get("api_key") or ""
if not api_key:
return None
result: Dict[str, Any] = {"api_key": api_key}
api_base = prov_dict.get("apiBase") or prov_dict.get("api_base") or default_base
if api_base:
result["api_base"] = api_base
extra = prov_dict.get("extraHeaders") or prov_dict.get("extra_headers")
if extra and isinstance(extra, dict):
result["extra_headers"] = extra
return result
model_lower = model.lower()
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
# 1. Forced provider
if forced_provider and forced_provider != "auto":
p = providers.get(forced_provider)
if p and isinstance(p, dict):
# Look up the default api_base for this provider from the registry
forced_default_base = ""
for _name, _kws, _base in PROVIDER_REGISTRY:
if _name == forced_provider:
forced_default_base = _base
break
return _extract(p, forced_default_base)
# 2. Prefix match
for name, _kws, default_base in PROVIDER_REGISTRY:
if model_prefix and normalized_prefix == name:
p = providers.get(name)
if p and isinstance(p, dict):
result = _extract(p, default_base)
if result:
return result
# 3. Keyword match
for name, keywords, default_base in PROVIDER_REGISTRY:
if any(kw in model_lower for kw in keywords):
p = providers.get(name)
if p and isinstance(p, dict):
result = _extract(p, default_base)
if result:
return result
# 4. Fallback: first provider with an api_key
for name, _kws, default_base in PROVIDER_REGISTRY:
p = providers.get(name)
if p and isinstance(p, dict):
result = _extract(p, default_base)
if result:
return result
return None
def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]:
"""Read LLM credentials from ``~/.nanobot/config.json``.
Returns litellm kwargs dict (``api_key``, ``api_base``, ``extra_headers``),
or None. May include a ``"_model"`` key with the nanobot default model.
"""
data = _load_nanobot_config()
if data is None:
return None
providers = data.get("providers", {})
if not isinstance(providers, dict):
return None
agents = data.get("agents", {})
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
nanobot_model = defaults.get("model", "") if isinstance(defaults, dict) else ""
forced_provider = defaults.get("provider", "auto") if isinstance(defaults, dict) else "auto"
match_model = model or nanobot_model or ""
result = match_provider(providers, match_model, forced_provider)
if result and nanobot_model:
result["_model"] = nanobot_model
if result and forced_provider and forced_provider != "auto":
result["_forced_provider"] = forced_provider
if result:
config_path = _resolve_nanobot_config_path()
logger.info(
"Auto-detected LLM credentials from nanobot config (%s), "
"provider matched for model=%r",
config_path, match_model,
)
return result
def read_nanobot_mcp_env() -> Dict[str, str]:
"""Read ``tools.mcpServers.openspace.env`` from nanobot config.
Returns the env dict (empty if not found / parse error).
"""
data = _load_nanobot_config()
if data is None:
return {}
tools = data.get("tools", {})
if not isinstance(tools, dict):
return {}
mcp_servers = tools.get("mcpServers") or tools.get("mcp_servers") or {}
if not isinstance(mcp_servers, dict):
return {}
openspace_cfg = mcp_servers.get("openspace", {})
if not isinstance(openspace_cfg, dict):
return {}
env_block = openspace_cfg.get("env", {})
return env_block if isinstance(env_block, dict) else {}
def get_openai_api_key() -> Optional[str]:
"""Get OpenAI API key for embedding generation.
Resolution:
1. ``OPENAI_API_KEY`` env var
2. nanobot config ``providers.openai.apiKey``
3. None
"""
import os
key = os.environ.get("OPENAI_API_KEY")
if key:
return key
data = _load_nanobot_config()
if data:
providers = data.get("providers", {})
if isinstance(providers, dict):
openai_cfg = providers.get("openai", {})
if isinstance(openai_cfg, dict):
api_key = openai_cfg.get("apiKey")
if api_key:
logger.debug("Using OpenAI API key from nanobot config for embeddings")
return api_key
return None