mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
381 lines
13 KiB
Python
381 lines
13 KiB
Python
"""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 pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
|
|
logger = logging.getLogger("openspace.host_detection")
|
|
|
|
_DEFAULT_MODEL = "openrouter/anthropic/claude-sonnet-4.5"
|
|
|
|
_PROVIDER_NATIVE_ENV_VARS: Dict[str, tuple[str, ...]] = {
|
|
"openrouter": ("OPENROUTER_API_KEY", "OR_API_KEY"),
|
|
"aihubmix": ("AIHUBMIX_API_KEY",),
|
|
"siliconflow": ("SILICONFLOW_API_KEY",),
|
|
"volcengine": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
|
|
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
"openai": ("OPENAI_API_KEY",),
|
|
"deepseek": ("DEEPSEEK_API_KEY",),
|
|
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
|
|
"zhipu": ("ZHIPU_API_KEY",),
|
|
"dashscope": ("DASHSCOPE_API_KEY",),
|
|
"moonshot": ("MOONSHOT_API_KEY",),
|
|
"minimax": ("MINIMAX_API_KEY",),
|
|
"groq": ("GROQ_API_KEY",),
|
|
}
|
|
|
|
_env_loaded = False
|
|
|
|
|
|
def _load_env_once() -> None:
|
|
"""Load .env files once per process.
|
|
|
|
Search order (first-loaded wins for each key):
|
|
1. ``openspace/.env`` (package root — works regardless of CWD)
|
|
2. ``CWD/.env`` (project-level fallback)
|
|
|
|
Uses ``override=False`` so env vars already in the process (e.g. set
|
|
by the host agent or the shell) are never overwritten.
|
|
|
|
Set ``OPENSPACE_SKIP_DOTENV=1`` in the process environment to disable
|
|
automatic .env loading in managed runtimes such as CI, containers, or
|
|
host-agent launches.
|
|
"""
|
|
global _env_loaded
|
|
if _env_loaded:
|
|
return
|
|
_env_loaded = True
|
|
|
|
if os.environ.get("OPENSPACE_SKIP_DOTENV", "").strip().lower() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
"on",
|
|
}:
|
|
return
|
|
|
|
try:
|
|
from dotenv import load_dotenv
|
|
except ImportError:
|
|
return
|
|
|
|
pkg_env = Path(__file__).resolve().parent.parent / ".env"
|
|
if pkg_env.is_file():
|
|
load_dotenv(pkg_env)
|
|
load_dotenv()
|
|
|
|
|
|
def load_runtime_env() -> None:
|
|
"""Public wrapper for one-time runtime .env loading."""
|
|
_load_env_once()
|
|
|
|
|
|
def _pick_first_env(names: tuple[str, ...]) -> str:
|
|
for name in names:
|
|
value = os.environ.get(name, "").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def _ensure_local_no_proxy() -> None:
|
|
required_hosts = ("127.0.0.1", "localhost")
|
|
for env_name in ("NO_PROXY", "no_proxy"):
|
|
current = os.environ.get(env_name, "")
|
|
entries = [entry.strip() for entry in current.split(",") if entry.strip()]
|
|
updated = False
|
|
for host in required_hosts:
|
|
if host not in entries:
|
|
entries.append(host)
|
|
updated = True
|
|
if updated:
|
|
os.environ[env_name] = ",".join(entries)
|
|
|
|
|
|
def _infer_provider_name(model: str) -> Optional[str]:
|
|
"""Infer the provider name from a model string using PROVIDER_REGISTRY."""
|
|
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
|
|
|
|
model_lower = (model or "").lower()
|
|
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
|
|
normalized_prefix = model_prefix.replace("-", "_")
|
|
|
|
for name, _keywords, _default_base in PROVIDER_REGISTRY:
|
|
if model_prefix and normalized_prefix == name:
|
|
return name
|
|
|
|
for name, keywords, _default_base in PROVIDER_REGISTRY:
|
|
if any(keyword in model_lower for keyword in keywords):
|
|
return name
|
|
|
|
return None
|
|
|
|
|
|
def _has_provider_native_env(model: str) -> bool:
|
|
"""Check if a provider-native API key (e.g. OPENROUTER_API_KEY) exists.
|
|
|
|
When True, the key from .env or the process environment is sufficient
|
|
for litellm to authenticate — no need to read nanobot / host config.
|
|
"""
|
|
provider = _infer_provider_name(model)
|
|
if not provider:
|
|
return False
|
|
|
|
env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider)
|
|
if not env_names:
|
|
return False
|
|
|
|
return bool(_pick_first_env(env_names))
|
|
|
|
|
|
def _provider_native_api_key(model: str) -> str:
|
|
provider = _infer_provider_name(model)
|
|
if not provider:
|
|
return ""
|
|
env_names = _PROVIDER_NATIVE_ENV_VARS.get(provider)
|
|
if not env_names:
|
|
return ""
|
|
return _pick_first_env(env_names)
|
|
|
|
|
|
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 — Provider-native env vars already present in the process
|
|
(including values loaded from ``openspace/.env``)::
|
|
|
|
OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ...
|
|
|
|
These take precedence over host-agent config so local/standalone
|
|
launches are not hijacked by unrelated host config files.
|
|
|
|
Tier 3 — Host-agent config file fallback (only when Tier 1+2 absent)::
|
|
|
|
nanobot → ``~/.nanobot/config.json``
|
|
openclaw → ``~/.openclaw/openclaw.json``
|
|
|
|
Returns:
|
|
``(resolved_model, llm_kwargs_dict)``
|
|
"""
|
|
_load_env_once()
|
|
|
|
kwargs: Dict[str, Any] = {}
|
|
resolved_model = model
|
|
source = "inherited env"
|
|
|
|
has_explicit_llm_override = bool(
|
|
os.environ.get("OPENSPACE_LLM_API_BASE")
|
|
or os.environ.get("OPENSPACE_LLM_API_KEY")
|
|
)
|
|
provider_native_env_used = _has_provider_native_env(
|
|
resolved_model or _DEFAULT_MODEL
|
|
)
|
|
|
|
# --- Tier 3: host config fallback (only when no local keys) ---
|
|
host_config = None
|
|
host_source = None
|
|
if not has_explicit_llm_override and not provider_native_env_used:
|
|
from openspace.host_detection.nanobot import try_read_nanobot_config
|
|
host_config = try_read_nanobot_config(model)
|
|
if host_config:
|
|
host_source = "nanobot config"
|
|
else:
|
|
from openspace.host_detection.openclaw import try_read_openclaw_config
|
|
host_config = try_read_openclaw_config(model)
|
|
if host_config:
|
|
host_source = "openclaw config"
|
|
|
|
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
|
|
|
|
_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}/")
|
|
and not (model and has_explicit_llm_override)
|
|
):
|
|
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 = host_source or "host config"
|
|
|
|
native_api_key = _provider_native_api_key(resolved_model or _DEFAULT_MODEL)
|
|
if native_api_key and not kwargs.get("api_key"):
|
|
kwargs["api_key"] = native_api_key
|
|
source = "provider-native env"
|
|
|
|
# --- 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 = _DEFAULT_MODEL
|
|
|
|
# Ollama models must use the Ollama-native API base, even when unrelated
|
|
# OPENSPACE_LLM_* env vars are present for a different provider.
|
|
if resolved_model.lower().startswith("ollama/"):
|
|
ollama_base = os.environ.get("OLLAMA_API_BASE", "").strip() or "http://127.0.0.1:11434"
|
|
_ensure_local_no_proxy()
|
|
kwargs["api_base"] = ollama_base.rstrip("/")
|
|
kwargs["api_key"] = os.environ.get("OLLAMA_API_KEY", "").strip() or kwargs.get("api_key") or "ollama"
|
|
kwargs.pop("extra_headers", None)
|
|
source = "ollama runtime"
|
|
|
|
# Provider-specific adjustments for litellm routing
|
|
if resolved_model and "minimax" in resolved_model.lower():
|
|
final_key = kwargs.get("api_key")
|
|
final_base = kwargs.get("api_base", "")
|
|
|
|
if final_key:
|
|
os.environ.setdefault("MINIMAX_API_KEY", final_key)
|
|
if final_base:
|
|
os.environ.setdefault("MINIMAX_API_BASE", final_base)
|
|
|
|
if (
|
|
resolved_model.lower().startswith("minimax/")
|
|
and "minimaxi.com" in final_base
|
|
):
|
|
original = resolved_model
|
|
resolved_model = "openai/" + resolved_model.split("/", 1)[1]
|
|
logger.info(
|
|
"Switched model prefix for minimaxi.com compat: %s -> %s",
|
|
original, resolved_model,
|
|
)
|
|
|
|
if kwargs:
|
|
safe = {
|
|
k: ("<redacted>" if k == "api_key" and isinstance(v, str) else v)
|
|
for k, v in kwargs.items()
|
|
}
|
|
logger.info("LLM kwargs resolved (source=%s): %s", source, safe)
|
|
elif provider_native_env_used:
|
|
logger.info(
|
|
"LLM credentials resolved from provider-native env for model=%r",
|
|
resolved_model,
|
|
)
|
|
|
|
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.
|
|
"""
|
|
_load_env_once()
|
|
|
|
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")
|