mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-10-10 03:27:53 +00:00
clean up LLM credential resolution
This commit is contained in:
parent
81f375e8bc
commit
1bd1a3d377
8 changed files with 449 additions and 127 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue