darkfire514's picture
Upload 160 files
399b80c verified
"""LLM credential and grounding config resolution.
Resolves the model name and litellm kwargs for OpenSpace's LLM client,
and assembles grounding config from env-var overrides.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
from typing import Any, Dict, Optional
logger = logging.getLogger("openspace.host_detection")
def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
"""Build litellm kwargs and resolve model for OpenSpace's LLM client.
Resolution order (highest β†’ lowest priority):
Tier 1 β€” Explicit ``OPENSPACE_LLM_*`` env vars::
OPENSPACE_LLM_API_KEY β†’ litellm ``api_key``
OPENSPACE_LLM_API_BASE β†’ litellm ``api_base``
OPENSPACE_LLM_EXTRA_HEADERS β†’ litellm ``extra_headers`` (JSON string)
OPENSPACE_LLM_CONFIG β†’ arbitrary litellm kwargs (JSON string)
Tier 2 β€” Auto-detect from host agent config file::
~/.nanobot/config.json β†’ providers.{matched}.apiKey / apiBase
Tier 3 β€” Provider-native env vars inherited from the parent process
(e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically.
Returns:
``(resolved_model, llm_kwargs_dict)``
"""
from openspace.host_detection.nanobot import try_read_nanobot_config
kwargs: Dict[str, Any] = {}
resolved_model = model
source = "inherited env"
# --- Tier 2: auto-detect from host config (filled first, may be overridden) ---
host_config = try_read_nanobot_config(model)
if host_config:
host_model = host_config.pop("_model", None)
forced_provider = host_config.pop("_forced_provider", None)
if not resolved_model and host_model:
resolved_model = host_model
# If the host config forces a gateway provider (e.g. openrouter)
# and the model name doesn't already carry that prefix, prepend
# it so that litellm uses the correct request format (OpenAI-
# compatible for gateways vs native for direct providers).
_GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"}
if (
forced_provider
and forced_provider in _GATEWAY_PROVIDERS
and resolved_model
and not resolved_model.lower().startswith(f"{forced_provider}/")
):
resolved_model = f"{forced_provider}/{resolved_model}"
logger.info(
"Prepended gateway prefix: model=%r (forced_provider=%s)",
resolved_model, forced_provider,
)
kwargs.update(host_config)
source = "nanobot config"
# --- Tier 1: explicit env vars override everything ---
api_key = os.environ.get("OPENSPACE_LLM_API_KEY")
if api_key:
kwargs["api_key"] = api_key
source = "OPENSPACE_LLM_* env"
api_base = os.environ.get("OPENSPACE_LLM_API_BASE")
if api_base:
kwargs["api_base"] = api_base
extra_headers_raw = os.environ.get("OPENSPACE_LLM_EXTRA_HEADERS")
if extra_headers_raw:
try:
headers = json.loads(extra_headers_raw)
if isinstance(headers, dict):
kwargs["extra_headers"] = headers
except json.JSONDecodeError:
logger.warning("Invalid JSON in OPENSPACE_LLM_EXTRA_HEADERS: %r", extra_headers_raw)
llm_config_raw = os.environ.get("OPENSPACE_LLM_CONFIG")
if llm_config_raw:
try:
llm_config = json.loads(llm_config_raw)
if isinstance(llm_config, dict):
kwargs.update(llm_config)
source = "OPENSPACE_LLM_CONFIG env"
except json.JSONDecodeError:
logger.warning("Invalid JSON in OPENSPACE_LLM_CONFIG: %r", llm_config_raw)
# Default model fallback
if not resolved_model:
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
if kwargs:
safe = {
k: (v[:8] + "..." if k == "api_key" and isinstance(v, str) and len(v) > 8 else v)
for k, v in kwargs.items()
}
logger.info("LLM kwargs resolved (source=%s): %s", source, safe)
return resolved_model, kwargs
def build_grounding_config_path() -> Optional[str]:
"""Resolve grounding config: inline JSON > file path > None.
Supports:
* ``OPENSPACE_CONFIG_JSON`` β€” inline JSON string (written to a temp file)
* ``OPENSPACE_CONFIG_PATH`` β€” path to a JSON config file
Granular env-var overrides (``OPENSPACE_SHELL_*``, ``OPENSPACE_SKILLS_*``,
etc.) are merged before writing.
Returns:
Path to the resolved config file, or None.
"""
config_json_raw = os.environ.get("OPENSPACE_CONFIG_JSON", "").strip()
overrides: Dict[str, Any] = {}
if config_json_raw:
try:
overrides = json.loads(config_json_raw)
if not isinstance(overrides, dict):
logger.warning("OPENSPACE_CONFIG_JSON is not a dict, ignoring")
overrides = {}
else:
logger.info("Loaded inline config from OPENSPACE_CONFIG_JSON")
except json.JSONDecodeError as e:
logger.warning("Invalid JSON in OPENSPACE_CONFIG_JSON: %s", e)
# --- Granular env-var overrides ---
conda_env = os.environ.get("OPENSPACE_SHELL_CONDA_ENV", "").strip()
if conda_env:
overrides.setdefault("shell", {})["conda_env"] = conda_env
shell_wd = os.environ.get("OPENSPACE_SHELL_WORKING_DIR", "").strip()
if shell_wd:
overrides.setdefault("shell", {})["working_dir"] = shell_wd
skills_dirs_raw = os.environ.get("OPENSPACE_SKILLS_DIRS", "").strip()
if skills_dirs_raw:
dirs = [d.strip() for d in skills_dirs_raw.split(",") if d.strip()]
if dirs:
overrides.setdefault("skills", {})["skill_dirs"] = dirs
mcp_servers_raw = os.environ.get("OPENSPACE_MCP_SERVERS_JSON", "").strip()
if mcp_servers_raw:
try:
servers = json.loads(mcp_servers_raw)
if isinstance(servers, dict):
overrides["mcpServers"] = servers
except json.JSONDecodeError as e:
logger.warning("Invalid JSON in OPENSPACE_MCP_SERVERS_JSON: %s", e)
log_level = os.environ.get("OPENSPACE_LOG_LEVEL", "").strip().upper()
if log_level and log_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
overrides["log_level"] = log_level
if overrides:
try:
fd, tmp_path = tempfile.mkstemp(suffix=".json", prefix="openspace_cfg_")
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(overrides, f, ensure_ascii=False)
logger.info(
"Grounding config overrides written to %s (%d keys)",
tmp_path, len(overrides),
)
return tmp_path
except Exception as e:
logger.warning("Failed to write config overrides: %s", e)
return os.environ.get("OPENSPACE_CONFIG_PATH")