Spaces:
Running
Running
File size: 6,982 Bytes
399b80c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | """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")
|