From 1bd1a3d3778d1d22d727ba93faa5a4d1cffe1bff Mon Sep 17 00:00:00 2001 From: spidercatfly Date: Sun, 5 Apr 2026 15:13:07 +0800 Subject: [PATCH] clean up LLM credential resolution --- openspace/.env.example | 52 ++++-- openspace/agents/grounding_agent.py | 4 +- openspace/config/README.md | 72 +++++---- openspace/host_detection/__init__.py | 6 +- openspace/host_detection/nanobot.py | 26 ++- openspace/host_detection/openclaw.py | 230 +++++++++++++++++++++++---- openspace/host_detection/resolver.py | 161 ++++++++++++++++--- openspace/llm/client.py | 25 ++- 8 files changed, 449 insertions(+), 127 deletions(-) diff --git a/openspace/.env.example b/openspace/.env.example index d032801..d8e9465 100644 --- a/openspace/.env.example +++ b/openspace/.env.example @@ -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 diff --git a/openspace/agents/grounding_agent.py b/openspace/agents/grounding_agent.py index 4d983d2..72a4fe0 100644 --- a/openspace/agents/grounding_agent.py +++ b/openspace/agents/grounding_agent.py @@ -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 ) diff --git a/openspace/config/README.md b/openspace/config/README.md index 74327ff..f2e0875 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -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 | - diff --git a/openspace/host_detection/__init__.py b/openspace/host_detection/__init__.py index 820dbfc..d8125b3 100644 --- a/openspace/host_detection/__init__.py +++ b/openspace/host_detection/__init__.py @@ -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", ] diff --git a/openspace/host_detection/nanobot.py b/openspace/host_detection/nanobot.py index 8f787b1..c766cc4 100644 --- a/openspace/host_detection/nanobot.py +++ b/openspace/host_detection/nanobot.py @@ -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 diff --git a/openspace/host_detection/openclaw.py b/openspace/host_detection/openclaw.py index 1107b5c..a97104a 100644 --- a/openspace/host_detection/openclaw.py +++ b/openspace/host_detection/openclaw.py @@ -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..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 + + diff --git a/openspace/host_detection/resolver.py b/openspace/host_detection/resolver.py index d3d9cb0..ee43408 100644 --- a/openspace/host_detection/resolver.py +++ b/openspace/host_detection/resolver.py @@ -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") - diff --git a/openspace/llm/client.py b/openspace/llm/client.py index c3c4620..c7fd696 100644 --- a/openspace/llm/client.py +++ b/openspace/llm/client.py @@ -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)