clean up LLM credential resolution

This commit is contained in:
spidercatfly 2026-04-05 15:13:07 +08:00
parent 81f375e8bc
commit 1bd1a3d377
8 changed files with 449 additions and 127 deletions

View file

@ -3,10 +3,24 @@
# Copy this file to .env and fill in your keys
# ============================================
# ---- LLM API Keys ----
# At least one LLM API key is required for OpenSpace to function.
# OpenSpace uses LiteLLM for model routing, so the key you need depends on your chosen model.
# See https://docs.litellm.ai/docs/providers for supported providers.
# ── LLM Credentials ──────────────────────────────────────
#
# OpenSpace resolves LLM credentials in this order (first match wins):
#
# 1. OPENSPACE_LLM_* — explicit override, always highest priority
# 2. Provider-native vars — OPENROUTER_API_KEY, OPENAI_API_KEY, etc.
# 3. ~/.nanobot/config.json or ~/.openclaw/openclaw.json — fallback (only when no explicit or provider key found)
#
# For most users, setting ONE of the provider-native keys below is enough.
# LiteLLM reads them automatically. See https://docs.litellm.ai/docs/providers
#
# Full configuration guide: openspace/config/README.md
# --- Option A: Provider-native key (simplest) ---
# Set the key that matches your model's provider:
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
OPENROUTER_API_KEY=
# Anthropic (for anthropic/claude-* models)
# ANTHROPIC_API_KEY=
@ -14,8 +28,15 @@
# OpenAI (for openai/gpt-* models)
# OPENAI_API_KEY=
# OpenRouter (for openrouter/* models, e.g. openrouter/anthropic/claude-sonnet-4.5)
OPENROUTER_API_KEY=
# DeepSeek (for deepseek/* models)
# DEEPSEEK_API_KEY=
# --- Option B: Explicit OpenSpace override (takes priority over Option A) ---
# Use these when you need full control, e.g. custom API base or non-standard provider.
# OPENSPACE_MODEL=openrouter/anthropic/claude-sonnet-4.5
# OPENSPACE_LLM_API_KEY=sk-xxx
# OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1
# ── OpenSpace Cloud (optional) ──────────────────────────────
# Register at https://open-space.cloud to get your key.
@ -23,28 +44,25 @@ OPENROUTER_API_KEY=
OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx
# ---- GUI Backend (Anthropic Computer Use) ----
# Required only if using the GUI backend. Uses the same ANTHROPIC_API_KEY above.
# ── GUI Backend (optional) ──────────────────────────────────
# Required only if using the GUI backend (Anthropic Computer Use).
# Uses the same ANTHROPIC_API_KEY above.
# Optional backup key for rate limit fallback:
# ANTHROPIC_API_KEY_BACKUP=
# ---- Web Backend (Deep Research) ----
# Required only if using the Web backend for deep research.
# Uses OpenRouter API by default:
# OPENROUTER_API_KEY=
# ---- Embedding (Optional) ----
# ── Embedding (optional) ────────────────────────────────────
# For remote embedding API instead of local model.
# If not set, OpenSpace uses a local embedding model (BAAI/bge-small-en-v1.5).
# EMBEDDING_BASE_URL=
# EMBEDDING_API_KEY=
# EMBEDDING_MODEL= "openai/text-embedding-3-small"
# EMBEDDING_MODEL=openai/text-embedding-3-small
# ---- E2B Sandbox (Optional) ----
# ── E2B Sandbox (optional) ──────────────────────────────────
# Required only if sandbox mode is enabled in security config.
# E2B_API_KEY=
# ---- Local Server (Optional) ----
# ── Local Server (optional) ─────────────────────────────────
# Override the default local server URL (default: http://127.0.0.1:5000)
# Useful for remote VM integration (e.g., OSWorld).
# LOCAL_SERVER_URL=http://127.0.0.1:5000

View file

@ -749,6 +749,7 @@ class GroundingAgent(BaseAgent):
# Use dedicated visual analysis model if configured, otherwise use main LLM model
visual_model = self._visual_analysis_model or (self._llm_client.model if self._llm_client else "openrouter/anthropic/claude-sonnet-4.5")
_llm_extra = getattr(self._llm_client, 'litellm_kwargs', {}) if self._llm_client else {}
response = await asyncio.wait_for(
litellm.acompletion(
model=visual_model,
@ -756,7 +757,8 @@ class GroundingAgent(BaseAgent):
"role": "user",
"content": content
}],
timeout=self._visual_analysis_timeout
timeout=self._visual_analysis_timeout,
**_llm_extra,
),
timeout=self._visual_analysis_timeout + 5
)

View file

@ -1,40 +1,51 @@
# 🔧 Configuration Guide
All configuration applies to both Path A (host agent) and Path B (standalone). Configure once before the first run.
## 1. API Keys (`.env`)
## 1. LLM Credentials (`.env`)
> [!NOTE]
> Create a `.env` file and add your API keys (refer to [`.env.example`](../../.env.example)). When used via host agent (Path A), LLM keys are auto-detected from your agent's config — `.env` is mainly needed for standalone mode.
> Create `openspace/.env` from [`.env.example`](../../.env.example) and set at least one LLM API key.
Resolution priority (first match wins):
| Priority | Source | Example |
|----------|--------|---------|
| **Tier 1** | `OPENSPACE_LLM_*` env vars | `OPENSPACE_LLM_API_KEY=sk-xxx` |
| **Tier 2** | Provider-native env vars | `OPENROUTER_API_KEY=sk-or-xxx` |
| **Tier 3** | Host agent config | `~/.nanobot/config.json` / `~/.openclaw/openclaw.json` |
> [!IMPORTANT]
> Tier 2 blocks Tier 3 — if `.env` has a provider key, host agent config is skipped.
```bash
# Provider-native — litellm reads automatically
OPENROUTER_API_KEY=sk-or-v1-xxx
# Or: OpenSpace-native — higher priority, same effect
OPENSPACE_LLM_API_KEY=sk-or-v1-xxx
```
## 2. Environment Variables
Set via `.env`, MCP config `env` block, or system environment. OpenSpace reads these at startup.
Set via `.env`, MCP config `env` block, or system environment.
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENSPACE_HOST_SKILL_DIRS` | Path A only | Your agent's skill directories (comma-separated). Auto-registered on startup. |
| `OPENSPACE_WORKSPACE` | Recommended | OpenSpace project root. Used for recording logs and workspace resolution. |
| `OPENSPACE_API_KEY` | No | Cloud API key (`sk-xxx`). Register at https://open-space.cloud. |
| `OPENSPACE_MODEL` | No | LLM model override (default: auto-detected or `openrouter/anthropic/claude-sonnet-4.5`). |
| `OPENSPACE_MAX_ITERATIONS` | No | Max agent iterations per task (default: `20`). |
| `OPENSPACE_BACKEND_SCOPE` | No | Enabled backends, comma-separated (default: all — `shell,gui,mcp,web,system`). |
### Advanced env overrides (rarely needed)
| Variable | Description |
|----------|-------------|
| `OPENSPACE_LLM_API_KEY` | LLM API key (auto-detected from host agent in Path A) |
| `OPENSPACE_LLM_API_BASE` | LLM API base URL |
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra HTTP headers for LLM requests (JSON string) |
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON string) |
| `OPENSPACE_API_BASE` | Cloud API base URL (default `https://open-space.cloud/api/v1`) |
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON (deep-merged with defaults) |
| `OPENSPACE_SHELL_CONDA_ENV` | Conda environment for shell backend |
| `OPENSPACE_SHELL_WORKING_DIR` | Working directory for shell backend |
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON string, merged into `mcpServers`) |
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces (default: `true`) |
| `OPENSPACE_LOG_LEVEL` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
| Variable | Description | Default |
|----------|-------------|---------|
| `OPENSPACE_MODEL` | LLM model | `openrouter/anthropic/claude-sonnet-4.5` |
| `OPENSPACE_LLM_API_KEY` | LLM API key (Tier 1 override) | — |
| `OPENSPACE_LLM_API_BASE` | LLM API base URL | — |
| `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — |
| `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — |
| `OPENSPACE_API_KEY` | Cloud API key ([open-space.cloud](https://open-space.cloud)) | — |
| `OPENSPACE_MAX_ITERATIONS` | Max agent iterations per task | `20` |
| `OPENSPACE_BACKEND_SCOPE` | Enabled backends (comma-separated) | `shell,gui,mcp,web,system` |
| `OPENSPACE_HOST_SKILL_DIRS` | Agent skill directories (comma-separated) | — |
| `OPENSPACE_WORKSPACE` | Project root for logs/workspace | — |
| `OPENSPACE_SHELL_CONDA_ENV` | Conda env for shell backend | — |
| `OPENSPACE_SHELL_WORKING_DIR` | Working dir for shell backend | — |
| `OPENSPACE_CONFIG_PATH` | Custom grounding config JSON | — |
| `OPENSPACE_MCP_SERVERS_JSON` | MCP server definitions (JSON) | — |
| `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` |
| `OPENSPACE_LOG_LEVEL` | Log level | `INFO` |
## 3. MCP Servers (`config_mcp.json`)
@ -67,7 +78,7 @@ Shell and GUI backends support two execution modes, set via `"mode"` in `config_
| **How** | `asyncio.subprocess` in-process | HTTP → Flask → subprocess |
> [!TIP]
> **Use local mode** for most use cases. For server mode setup (how to enable, platform-specific deps, remote VM control), see [`../local_server/README.md`](../local_server/README.md).
> **Use local mode** for most use cases. For server mode setup, see [`../local_server/README.md`](../local_server/README.md).
## 5. Config Files (`openspace/config/`)
@ -112,4 +123,3 @@ Layered system — later files override earlier ones:
| `blocked_commands` | Platform-specific blacklists (common/linux/darwin/windows) | `rm -rf`, `shutdown`, `dd`, etc. |
| `sandbox_enabled` | Enable sandboxing for all operations | `false` |
| Per-backend overrides | Shell, MCP, GUI, Web each have independent security policies | Inherit global |

View file

@ -29,8 +29,8 @@ from openspace.host_detection.nanobot import (
)
from openspace.host_detection.openclaw import (
get_openclaw_openai_api_key as _openclaw_get_openai_api_key,
is_openclaw_host,
read_openclaw_skill_env,
try_read_openclaw_config,
)
logger = logging.getLogger("openspace.host_detection")
@ -82,10 +82,8 @@ __all__ = [
"build_grounding_config_path",
"get_openai_api_key",
"read_host_mcp_env",
# legacy re-exports
"read_nanobot_mcp_env",
"try_read_nanobot_config",
# openclaw-specific (for direct use if needed)
"is_openclaw_host",
"read_openclaw_skill_env",
"try_read_openclaw_config",
]

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
@ -35,19 +36,31 @@ PROVIDER_REGISTRY: List[tuple] = [
("groq", ("groq",), ""),
]
NANOBOT_CONFIG_PATH = Path.home() / ".nanobot" / "config.json"
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."""
if not NANOBOT_CONFIG_PATH.is_file():
"""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(NANOBOT_CONFIG_PATH, encoding="utf-8") as f:
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", NANOBOT_CONFIG_PATH, e)
logger.warning("Failed to read nanobot config %s: %s", config_path, e)
return None
@ -154,10 +167,11 @@ def try_read_nanobot_config(model: str) -> Optional[Dict[str, Any]]:
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",
NANOBOT_CONFIG_PATH, match_model,
config_path, match_model,
)
return result

View file

@ -1,7 +1,8 @@
"""OpenClaw host-agent config reader.
Reads ``~/.openclaw/openclaw.json`` to auto-detect:
- LLM provider credentials (via ``auth-profiles`` — not yet implemented)
- LLM provider credentials from env-style config blocks
(``skills.entries.openspace.env`` and ``env.vars``)
- Skill-level env block (``skills.entries.openspace.env``)
- OpenAI API key for embedding generation
@ -17,20 +18,74 @@ from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional
from openspace.host_detection.nanobot import PROVIDER_REGISTRY
logger = logging.getLogger("openspace.host_detection")
_STATE_DIRNAMES = [".openclaw", ".clawdbot", ".moldbot", ".moltbot"]
_CONFIG_FILENAMES = ["openclaw.json", "clawdbot.json", "moldbot.json", "moltbot.json"]
_PROVIDER_ENV_VARS: Dict[str, Dict[str, tuple[str, ...]]] = {
"openrouter": {
"api_key": ("OPENROUTER_API_KEY", "OR_API_KEY"),
"api_base": ("OPENROUTER_API_BASE",),
},
"aihubmix": {
"api_key": ("AIHUBMIX_API_KEY",),
"api_base": ("AIHUBMIX_API_BASE",),
},
"siliconflow": {
"api_key": ("SILICONFLOW_API_KEY",),
"api_base": ("SILICONFLOW_API_BASE",),
},
"volcengine": {
"api_key": ("VOLCENGINE_API_KEY", "ARK_API_KEY"),
"api_base": ("VOLCENGINE_API_BASE", "ARK_API_BASE"),
},
"anthropic": {
"api_key": ("ANTHROPIC_API_KEY",),
"api_base": ("ANTHROPIC_API_BASE",),
},
"openai": {
"api_key": ("OPENAI_API_KEY",),
"api_base": ("OPENAI_BASE_URL", "OPENAI_API_BASE"),
},
"deepseek": {
"api_key": ("DEEPSEEK_API_KEY",),
"api_base": ("DEEPSEEK_API_BASE",),
},
"gemini": {
"api_key": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
"api_base": ("GEMINI_API_BASE", "GOOGLE_API_BASE"),
},
"zhipu": {
"api_key": ("ZHIPU_API_KEY",),
"api_base": ("ZHIPU_API_BASE",),
},
"dashscope": {
"api_key": ("DASHSCOPE_API_KEY",),
"api_base": ("DASHSCOPE_API_BASE",),
},
"moonshot": {
"api_key": ("MOONSHOT_API_KEY",),
"api_base": ("MOONSHOT_API_BASE",),
},
"minimax": {
"api_key": ("MINIMAX_API_KEY",),
"api_base": ("MINIMAX_API_BASE",),
},
"groq": {
"api_key": ("GROQ_API_KEY",),
"api_base": ("GROQ_API_BASE",),
},
}
def _resolve_openclaw_config_path() -> Optional[Path]:
"""Find the OpenClaw config file on disk."""
import os
# 1. Explicit env override
explicit = os.environ.get("OPENCLAW_CONFIG_PATH", "").strip()
if explicit:
p = Path(explicit).expanduser()
@ -38,7 +93,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
return p
return None
# 2. State dir override
state_dir = os.environ.get("OPENCLAW_STATE_DIR", "").strip()
if state_dir:
for fname in _CONFIG_FILENAMES:
@ -46,7 +100,6 @@ def _resolve_openclaw_config_path() -> Optional[Path]:
if p.is_file():
return p
# 3. Default locations
home = Path.home()
for dirname in _STATE_DIRNAMES:
for fname in _CONFIG_FILENAMES:
@ -71,6 +124,119 @@ def _load_openclaw_config() -> Optional[Dict[str, Any]]:
return None
def _coerce_env_value(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _pick_env(env_block: Dict[str, Any], names: tuple[str, ...]) -> str:
for name in names:
value = _coerce_env_value(env_block.get(name))
if value:
return value
return ""
def _get_openclaw_env(skill_name: str = "openspace") -> Dict[str, Any]:
"""Merge OpenClaw top-level env vars with skill-level env overrides."""
merged: Dict[str, Any] = {}
data = _load_openclaw_config()
if data and isinstance(data, dict):
env_section = data.get("env", {})
if isinstance(env_section, dict):
vars_block = env_section.get("vars", {})
if isinstance(vars_block, dict):
merged.update(vars_block)
merged.update(read_openclaw_skill_env(skill_name))
return merged
def _extract_explicit_llm_kwargs(env_block: Dict[str, Any]) -> Dict[str, Any]:
"""Read OpenSpace-native LLM overrides from an env-like dict."""
result: Dict[str, Any] = {}
api_key = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_KEY"))
if api_key:
result["api_key"] = api_key
api_base = _coerce_env_value(env_block.get("OPENSPACE_LLM_API_BASE"))
if api_base:
result["api_base"] = api_base
extra_headers_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_EXTRA_HEADERS"))
if extra_headers_raw:
try:
headers = json.loads(extra_headers_raw)
if isinstance(headers, dict):
result["extra_headers"] = headers
except json.JSONDecodeError:
logger.warning(
"Invalid JSON in OpenClaw OPENSPACE_LLM_EXTRA_HEADERS: %r",
extra_headers_raw,
)
llm_config_raw = _coerce_env_value(env_block.get("OPENSPACE_LLM_CONFIG"))
if llm_config_raw:
try:
llm_config = json.loads(llm_config_raw)
if isinstance(llm_config, dict):
result.update(llm_config)
except json.JSONDecodeError:
logger.warning(
"Invalid JSON in OpenClaw OPENSPACE_LLM_CONFIG: %r",
llm_config_raw,
)
return result
def _extract_provider_env(
env_block: Dict[str, Any],
provider: str,
default_base: str = "",
) -> Optional[Dict[str, Any]]:
spec = _PROVIDER_ENV_VARS.get(provider)
if not spec:
return None
api_key = _pick_env(env_block, spec["api_key"])
if not api_key:
return None
result: Dict[str, Any] = {"api_key": api_key}
api_base = _pick_env(env_block, spec.get("api_base", ())) or default_base
if api_base:
result["api_base"] = api_base
return result
def _match_provider_env(model: str, env_block: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve provider-native env vars from OpenClaw config for a model."""
model_lower = model.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:
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
for name, keywords, default_base in PROVIDER_REGISTRY:
if any(keyword in model_lower for keyword in keywords):
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
for name, _keywords, default_base in PROVIDER_REGISTRY:
result = _extract_provider_env(env_block, name, default_base)
if result:
return result
return None
def read_openclaw_skill_env(skill_name: str = "openspace") -> Dict[str, str]:
"""Read ``skills.entries.<skill_name>.env`` from OpenClaw config.
@ -104,34 +270,40 @@ def get_openclaw_openai_api_key() -> Optional[str]:
Returns the key string, or None.
"""
# Try skill-level env
env = read_openclaw_skill_env("openspace")
key = env.get("OPENAI_API_KEY", "").strip()
env = _get_openclaw_env("openspace")
key = _coerce_env_value(env.get("OPENAI_API_KEY"))
if key:
logger.debug("Using OpenAI API key from OpenClaw skill env config")
return key
# Try top-level config env.vars
data = _load_openclaw_config()
if data:
env_section = data.get("env", {})
if isinstance(env_section, dict):
vars_block = env_section.get("vars", {})
if isinstance(vars_block, dict):
key = vars_block.get("OPENAI_API_KEY", "").strip()
if key:
logger.debug("Using OpenAI API key from OpenClaw env.vars config")
return key
return None
def is_openclaw_host() -> bool:
"""Detect if the current environment is running under OpenClaw."""
import os
# Check OpenClaw-specific env vars
if os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENCLAW_CONFIG_PATH"):
return True
# Check if config exists
return _resolve_openclaw_config_path() is not None
def try_read_openclaw_config(model: str) -> Optional[Dict[str, Any]]:
"""Read LLM credentials from OpenClaw's env-style config blocks."""
env_block = _get_openclaw_env("openspace")
if not env_block:
return None
explicit_kwargs = _extract_explicit_llm_kwargs(env_block)
provider_kwargs = _match_provider_env(model or "", env_block)
if not explicit_kwargs and not provider_kwargs:
return None
result: Dict[str, Any] = {}
if provider_kwargs:
result.update(provider_kwargs)
if explicit_kwargs:
result.update(explicit_kwargs)
config_path = _resolve_openclaw_config_path()
logger.info(
"Auto-detected LLM credentials from OpenClaw config (%s), provider matched for model=%r",
config_path,
model,
)
return result

View file

@ -10,10 +10,101 @@ 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.
"""
global _env_loaded
if _env_loaded:
return
_env_loaded = True
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 _pick_first_env(names: tuple[str, ...]) -> str:
for name in names:
value = os.environ.get(name, "").strip()
if value:
return value
return ""
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 build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
"""Build litellm kwargs and resolve model for OpenSpace's LLM client.
@ -27,47 +118,63 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
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::
Tier 2 — Provider-native env vars already present in the process
(including values loaded from ``openspace/.env``)::
~/.nanobot/config.json → providers.{matched}.apiKey / apiBase
OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / ...
Tier 3 — Provider-native env vars inherited from the parent process
(e.g. ``OPENROUTER_API_KEY``). Read by litellm automatically.
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)``
"""
from openspace.host_detection.nanobot import try_read_nanobot_config
_load_env_once()
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)
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
# 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).
# Skip when the user explicitly provided a model (via OPENSPACE_MODEL
# or --model) AND explicit OPENSPACE_LLM_* overrides — the user knows
# exactly which endpoint they want to hit.
_GATEWAY_PROVIDERS = {"openrouter", "aihubmix", "siliconflow"}
_has_explicit_llm_override = bool(
os.environ.get("OPENSPACE_LLM_API_BASE")
or os.environ.get("OPENSPACE_LLM_API_KEY")
)
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)
and not (model and has_explicit_llm_override)
):
resolved_model = f"{forced_provider}/{resolved_model}"
logger.info(
@ -75,7 +182,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
resolved_model, forced_provider,
)
kwargs.update(host_config)
source = "nanobot config"
source = host_source or "host config"
# --- Tier 1: explicit env vars override everything ---
api_key = os.environ.get("OPENSPACE_LLM_API_KEY")
@ -108,7 +215,7 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
# Default model fallback
if not resolved_model:
resolved_model = "openrouter/anthropic/claude-sonnet-4.5"
resolved_model = _DEFAULT_MODEL
# Provider-specific adjustments for litellm routing
if resolved_model and "minimax" in resolved_model.lower():
@ -120,10 +227,6 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
if final_base:
os.environ.setdefault("MINIMAX_API_BASE", final_base)
# api.minimaxi.com (domestic) is OpenAI-compatible but does not fully
# support litellm's minimax-specific request transformations (e.g. tool
# calling format). Switch to the generic openai/ prefix so litellm
# sends standard OpenAI-format requests that minimaxi.com accepts.
if (
resolved_model.lower().startswith("minimax/")
and "minimaxi.com" in final_base
@ -141,6 +244,11 @@ def build_llm_kwargs(model: str) -> tuple[str, Dict[str, Any]]:
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
@ -158,6 +266,8 @@ def build_grounding_config_path() -> Optional[str]:
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:
@ -213,4 +323,3 @@ def build_grounding_config_path() -> Optional[str]:
logger.warning("Failed to write config overrides: %s", e)
return os.environ.get("OPENSPACE_CONFIG_PATH")

View file

@ -2,21 +2,15 @@ import litellm
import json
import asyncio
import time
from pathlib import Path
from typing import List, Sequence, Union, Dict, Optional
from dotenv import load_dotenv
from openai.types.chat import ChatCompletionToolParam
from openspace.grounding.core.types import ToolSchema, ToolResult, ToolStatus
from openspace.grounding.core.tool import BaseTool
from openspace.utils.logging import Logger
# Load .env from openspace package root (works regardless of CWD),
# then fall back to CWD/.env. override=False (default) means first-loaded wins.
_PKG_ENV = Path(__file__).resolve().parent.parent / ".env" # openspace/.env
if _PKG_ENV.is_file():
load_dotenv(_PKG_ENV)
load_dotenv() # also try CWD/.env for any remaining vars
# .env loading is centralized in host_detection.resolver._load_env_once()
# which is called by build_llm_kwargs / build_grounding_config_path.
# Disable LiteLLM verbose logging to prevent stdout blocking with large tool schemas
litellm.set_verbose = False
@ -229,7 +223,8 @@ async def _summarize_tool_result(
tool_name: str,
task: str = "",
model: str = "openrouter/anthropic/claude-sonnet-4.5",
timeout: float = 120.0
timeout: float = 120.0,
litellm_kwargs: Optional[Dict] = None,
) -> str:
"""Use LLM to summarize large tool results."""
try:
@ -265,11 +260,13 @@ Content:
Concise summary:"""
_extra = litellm_kwargs or {}
response = await asyncio.wait_for(
litellm.acompletion(
model=model,
messages=[{"role": "user", "content": prompt}],
timeout=timeout
timeout=timeout,
**_extra,
),
timeout=timeout + 5
)
@ -296,7 +293,8 @@ async def _tool_result_to_message_async(
task: str = "",
summarize_threshold: int = DEFAULT_SUMMARIZE_THRESHOLD_CHARS,
summarize_model: str = "openrouter/anthropic/claude-sonnet-4.5",
enable_summarization: bool = True
enable_summarization: bool = True,
litellm_kwargs: Optional[Dict] = None,
) -> Dict:
"""Convert ToolResult to LLMClient usable message format with LLM summarization for large results.
@ -325,7 +323,7 @@ async def _tool_result_to_message_async(
# Use LLM summarization if content exceeds threshold
if original_len > summarize_threshold and enable_summarization:
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model)
summary = await _summarize_tool_result(text_content, tool_name, task, summarize_model, litellm_kwargs=litellm_kwargs)
if summary:
text_content = summary
elif original_len > MAX_TOOL_RESULT_CHARS:
@ -833,7 +831,8 @@ class LLMClient:
task=user_task,
summarize_threshold=self.summarize_threshold_chars,
summarize_model=self.model,
enable_summarization=self.enable_tool_result_summarization
enable_summarization=self.enable_tool_result_summarization,
litellm_kwargs=self.litellm_kwargs,
)
current_messages.append(tool_message)