From d62af9ecb5e1600f417a45727741c7aa26a414a5 Mon Sep 17 00:00:00 2001 From: CCLCK <84182037+CCLCK@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:54:09 +0800 Subject: [PATCH] Add split embedding routing for local Codex sidecar --- AGENTS.md | 19 +++ docs/codex-desktop-sidecar-evolution.md | 25 ++++ docs/current-routing-flow.md | 81 +++++++++++++ openspace/.env.example | 41 ++++++- openspace/cloud/embedding.py | 152 ++++++++++++++++++++---- openspace/config/README.md | 30 +++++ openspace/skill_engine/skill_ranker.py | 77 +++--------- pyproject.toml | 1 + requirements.txt | 1 + scripts/codex-desktop-evolution | 28 ++++- scripts/codex-openspace | 9 ++ scripts/openspace.sh | 11 +- 12 files changed, 385 insertions(+), 90 deletions(-) create mode 100644 docs/current-routing-flow.md diff --git a/AGENTS.md b/AGENTS.md index 392653c..3854817 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,24 @@ # AGENTS +## Project Skill Bucket + +For this repository, the project-scoped OpenSpace skill bucket is: + +- `~/.codex/projects/openspace/skills` +- index: `~/.codex/projects/openspace/SKILL_INDEX.md` + +Routing preference for work inside this repo: + +1. project bucket `openspace` +2. shared local bucket `default` +3. common global skills + +Mirror OpenSpace's own pattern: +- first run `/Users/admin/.codex/tools/route_codex_skills_via_openspace.py` +- prefilter by skill header metadata first +- only open the most likely 1-2 `SKILL.md` files +- avoid scanning every project skill file unless the user explicitly asks + ## Codex Desktop Sidecar Evolution Use this workflow when the user is coding in Codex Desktop with their normal subscription login and wants OpenSpace to do post-task skill capture through the isolated `openspace_evolution` sidecar. diff --git a/docs/codex-desktop-sidecar-evolution.md b/docs/codex-desktop-sidecar-evolution.md index 5f617f2..382f785 100644 --- a/docs/codex-desktop-sidecar-evolution.md +++ b/docs/codex-desktop-sidecar-evolution.md @@ -28,6 +28,31 @@ Instead, the final implementation uses **process-level split routing**: API compatibility work was still necessary, but it is only one part of the solution. +## Embedding Split Routing + +The sidecar now also supports a separate skill-embedding route from the main LLM. + +Recommended setup: + +```bash +OPENSPACE_MODEL=gpt-5.4 +OPENSPACE_LLM_API_KEY=sk-xxx +OPENSPACE_LLM_API_BASE=http://127.0.0.1:8080/v1 + +OPENSPACE_SKILL_EMBEDDING_BACKEND=local +OPENSPACE_SKILL_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 +``` + +If you want a dedicated remote endpoint for skill embeddings instead of local +fastembed, set: + +```bash +OPENSPACE_SKILL_EMBEDDING_BACKEND=remote +OPENSPACE_SKILL_EMBEDDING_API_KEY=sk-embed-xxx +OPENSPACE_SKILL_EMBEDDING_API_BASE=https://example.com/v1 +OPENSPACE_SKILL_EMBEDDING_MODEL=openai/text-embedding-3-small +``` + ## What Was Implemented ### 1. OpenAI-compatible provider bridge for OpenSpace diff --git a/docs/current-routing-flow.md b/docs/current-routing-flow.md new file mode 100644 index 0000000..630c00e --- /dev/null +++ b/docs/current-routing-flow.md @@ -0,0 +1,81 @@ +# Current Routing Flow + +This document records the current OpenSpace routing setup for this local environment. + +## Effective Split Routing + +- Main LLM: + - model: `gpt-5.4` + - API base: `http://127.0.0.1:8080/v1` + - source: `OPENSPACE_LLM_*` +- Skill embeddings: + - backend: `local` + - model: `BAAI/bge-small-en-v1.5` + - source: `OPENSPACE_SKILL_EMBEDDING_*` + +This means: + +- normal OpenSpace generation and tool-calling still use the OpenAI-compatible provider path +- skill-router semantic re-rank does not depend on remote `/v1/embeddings` +- Codex Desktop main session remains isolated from the sidecar/provider env + +## Flow 1: OpenSpace CLI + +```mermaid +flowchart LR + A["User runs ./scripts/openspace.sh"] --> B["Load openspace/.env"] + B --> C["Set OPENSPACE_LLM_*"] + B --> D["Set OPENSPACE_SKILL_EMBEDDING_*"] + C --> E["LLM client"] + D --> F["SkillRanker"] + E --> G["sub2api / local OpenAI-compatible gateway
http://127.0.0.1:8080/v1"] + F --> H["fastembed local model
BAAI/bge-small-en-v1.5"] + G --> I["GroundingAgent execution"] + H --> J["BM25 + vector prefilter"] + J --> I +``` + +## Flow 2: Codex Desktop With OpenSpace Sidecar + +```mermaid +flowchart LR + A["User runs ./scripts/codex-desktop-evolution app"] --> B["Create isolated CODEX_HOME overlay"] + B --> C["Main Codex Desktop session"] + B --> D["openspace_evolution MCP sidecar"] + C --> E["Normal Codex subscription/API workflow"] + D --> F["OpenSpace evolution server"] + F --> G["OPENSPACE_LLM_* -> gpt-5.4 via http://127.0.0.1:8080/v1"] + F --> H["OPENSPACE_SKILL_EMBEDDING_* -> local fastembed"] + G --> I["Evolution / skill capture"] + H --> I +``` + +## Flow 3: Skill Routing Internals + +```mermaid +flowchart LR + A["Task text"] --> B["Early abstain check"] + B --> C["BM25 rough rank"] + C --> D["Local embedding re-rank"] + D --> E["Top candidate skills"] + E --> F["Optional LLM selection"] + F --> G["Injected / selected skills"] +``` + +## Key Config Inputs + +- `OPENSPACE_LLM_API_KEY` +- `OPENSPACE_LLM_API_BASE` +- `OPENSPACE_LLM_OPENAI_STREAM_COMPAT` +- `OPENSPACE_SKILL_EMBEDDING_BACKEND` +- `OPENSPACE_SKILL_EMBEDDING_MODEL` + +## Operational Notes + +- If the provider does not expose `/v1/embeddings`, the main LLM path still works. +- With the current setup, skill embeddings stay local, so router prefilter remains available. +- If needed later, skill embeddings can be moved to a separate remote endpoint by setting: + - `OPENSPACE_SKILL_EMBEDDING_BACKEND=remote` + - `OPENSPACE_SKILL_EMBEDDING_API_KEY` + - `OPENSPACE_SKILL_EMBEDDING_API_BASE` + - `OPENSPACE_SKILL_EMBEDDING_MODEL` diff --git a/openspace/.env.example b/openspace/.env.example index d8e9465..b9aba77 100644 --- a/openspace/.env.example +++ b/openspace/.env.example @@ -38,6 +38,18 @@ OPENROUTER_API_KEY= # OPENSPACE_LLM_API_KEY=sk-xxx # OPENSPACE_LLM_API_BASE=https://openrouter.ai/api/v1 +# --- Recommended split routing for OpenSpace itself --- +# Keep the main LLM on your OpenAI-compatible provider, +# but route skill embeddings separately. +# +# Example: LLM via sub2api / local gateway, skill embeddings via local fastembed +# +# OPENSPACE_MODEL=gpt-5.4 +# OPENSPACE_LLM_API_KEY=sk-xxx +# OPENSPACE_LLM_API_BASE=http://127.0.0.1:8080/v1 +# OPENSPACE_SKILL_EMBEDDING_BACKEND=local +# OPENSPACE_SKILL_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 + # ── OpenSpace Cloud (optional) ────────────────────────────── # Register at https://open-space.cloud to get your key. # Enables cloud skill search & upload; local features work without it. @@ -51,9 +63,30 @@ OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx # Optional backup key for rate limit fallback: # ANTHROPIC_API_KEY_BACKUP= -# ── 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). +# ── Skill Embedding (optional, router-only) ───────────────── +# Controls the skill-router semantic re-rank path independently +# from the main LLM provider. +# +# OPENSPACE_SKILL_EMBEDDING_BACKEND=auto +# - auto → prefer explicit remote embedding config, then legacy OpenAI-compatible env, then local fastembed +# - local → force local fastembed model +# - remote → force remote OpenAI-compatible /embeddings endpoint +# +# OPENSPACE_SKILL_EMBEDDING_BACKEND=local +# OPENSPACE_SKILL_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 +# +# Or use a dedicated remote embedding endpoint: +# OPENSPACE_SKILL_EMBEDDING_BACKEND=remote +# OPENSPACE_SKILL_EMBEDDING_API_KEY=sk-xxx +# OPENSPACE_SKILL_EMBEDDING_API_BASE=https://example.com/v1 +# OPENSPACE_SKILL_EMBEDDING_MODEL=openai/text-embedding-3-small + +# ── Tool / Generic Embedding (optional) ───────────────────── +# Used by tool search's semantic retrieval. Can also act as a fallback +# remote embedding endpoint for the skill router when the dedicated +# OPENSPACE_SKILL_EMBEDDING_* vars are not set. +# +# If not set, tool search uses a local embedding model (BAAI/bge-small-en-v1.5). # EMBEDDING_BASE_URL= # EMBEDDING_API_KEY= # EMBEDDING_MODEL=openai/text-embedding-3-small @@ -68,4 +101,4 @@ OPENSPACE_API_KEY=sk_xxxxxxxxxxxxxxxx # LOCAL_SERVER_URL=http://127.0.0.1:5000 # ---- Debug (Optional) ---- -# OPENSPACE_DEBUG=true \ No newline at end of file +# OPENSPACE_DEBUG=true diff --git a/openspace/cloud/embedding.py b/openspace/cloud/embedding.py index 6606936..3dd424b 100644 --- a/openspace/cloud/embedding.py +++ b/openspace/cloud/embedding.py @@ -1,4 +1,13 @@ -"""Embedding generation via OpenAI-compatible API.""" +"""Embedding generation for skill routing. + +Supports a dedicated skill-embedding path that can be routed +independently from the main LLM: + +- ``OPENSPACE_SKILL_EMBEDDING_BACKEND=local`` → local fastembed model +- ``OPENSPACE_SKILL_EMBEDDING_BACKEND=remote`` → dedicated OpenAI-compatible endpoint +- ``OPENSPACE_SKILL_EMBEDDING_BACKEND=auto`` → prefer dedicated/generic remote config, + then fall back to legacy OpenAI-compatible env vars, then local fastembed +""" from __future__ import annotations @@ -11,26 +20,54 @@ from typing import List, Optional, Tuple logger = logging.getLogger("openspace.cloud") -# Constants (duplicated here to avoid top-level import of skill_ranker) -SKILL_EMBEDDING_MODEL = "openai/text-embedding-3-small" +# Defaults +SKILL_REMOTE_EMBEDDING_MODEL = "openai/text-embedding-3-small" +SKILL_LOCAL_EMBEDDING_MODEL = "BAAI/bge-small-en-v1.5" SKILL_EMBEDDING_MAX_CHARS = 12_000 SKILL_EMBEDDING_DIMENSIONS = 1536 _OPENROUTER_BASE = "https://openrouter.ai/api/v1" _OPENAI_BASE = "https://api.openai.com/v1" +_VALID_BACKENDS = {"auto", "local", "remote"} +_LOCAL_EMBEDDER = None +_LOCAL_EMBEDDER_MODEL = None -def resolve_embedding_api() -> Tuple[Optional[str], str]: - """Resolve API key and base URL for embedding requests. +def resolve_skill_embedding_backend() -> str: + """Resolve skill-embedding backend mode.""" + value = os.environ.get("OPENSPACE_SKILL_EMBEDDING_BACKEND", "auto").strip().lower() + if value in _VALID_BACKENDS: + return value + return "auto" - Priority: - 1. ``OPENROUTER_API_KEY`` → OpenRouter base URL - 2. ``OPENAI_API_KEY`` + ``OPENAI_BASE_URL`` (default ``api.openai.com``) - 3. host-agent config (nanobot / openclaw) - Returns: - ``(api_key, base_url)`` — *api_key* may be ``None`` when no key is found. - """ +def resolve_skill_embedding_model(backend: Optional[str] = None) -> str: + """Resolve the model name for skill embeddings.""" + backend = backend or resolve_skill_embedding_backend() + explicit = os.environ.get("OPENSPACE_SKILL_EMBEDDING_MODEL", "").strip() + if explicit: + return explicit + if backend == "local": + return SKILL_LOCAL_EMBEDDING_MODEL + if backend == "auto": + remote_key, _ = _resolve_remote_embedding_api() + if not remote_key: + return SKILL_LOCAL_EMBEDDING_MODEL + return SKILL_REMOTE_EMBEDDING_MODEL + + +def _resolve_remote_embedding_api() -> Tuple[Optional[str], str]: + """Resolve remote embedding credentials/base URL for skill routing.""" + dedicated_key = os.environ.get("OPENSPACE_SKILL_EMBEDDING_API_KEY") + dedicated_base = os.environ.get("OPENSPACE_SKILL_EMBEDDING_API_BASE") + if dedicated_key and dedicated_base: + return dedicated_key, dedicated_base.rstrip("/") + + generic_key = os.environ.get("EMBEDDING_API_KEY") + generic_base = os.environ.get("EMBEDDING_BASE_URL") + if generic_key and generic_base: + return generic_key, generic_base.rstrip("/") + or_key = os.environ.get("OPENROUTER_API_KEY") if or_key: return or_key, _OPENROUTER_BASE @@ -42,6 +79,7 @@ def resolve_embedding_api() -> Tuple[Optional[str], str]: try: from openspace.host_detection import get_openai_api_key + host_key = get_openai_api_key() if host_key: base = os.environ.get("OPENAI_BASE_URL", _OPENAI_BASE).rstrip("/") @@ -52,6 +90,22 @@ def resolve_embedding_api() -> Tuple[Optional[str], str]: return None, _OPENAI_BASE +def resolve_embedding_api() -> Tuple[Optional[str], str]: + """Resolve API key and base URL for remote embedding requests. + + Priority: + 1. ``OPENSPACE_SKILL_EMBEDDING_API_*`` dedicated skill-router endpoint + 2. ``EMBEDDING_*`` generic embedding endpoint + 3. ``OPENROUTER_API_KEY`` → OpenRouter base URL + 4. ``OPENAI_API_KEY`` + ``OPENAI_BASE_URL`` (default ``api.openai.com``) + 5. host-agent config (nanobot / openclaw) + + Returns: + ``(api_key, base_url)`` — *api_key* may be ``None`` when no key is found. + """ + return _resolve_remote_embedding_api() + + def cosine_similarity(a: List[float], b: List[float]) -> float: """Compute cosine similarity between two vectors.""" if len(a) != len(b) or not a: @@ -81,33 +135,83 @@ def build_skill_embedding_text( return raw[:max_chars] +def _load_local_embedder(model_name: str): + """Load and cache the local embedding model.""" + global _LOCAL_EMBEDDER, _LOCAL_EMBEDDER_MODEL + + if _LOCAL_EMBEDDER is not None and _LOCAL_EMBEDDER_MODEL == model_name: + return _LOCAL_EMBEDDER + + try: + from fastembed import TextEmbedding + except ImportError: + logger.warning( + "Local skill embeddings requested but fastembed is not installed. " + "Install it with `pip install fastembed`." + ) + return None + + try: + logger.info("Loading local skill embedding model: %s", model_name) + _LOCAL_EMBEDDER = TextEmbedding(model_name=model_name) + _LOCAL_EMBEDDER_MODEL = model_name + return _LOCAL_EMBEDDER + except Exception as exc: + logger.warning("Failed to load local skill embedding model %s: %s", model_name, exc) + return None + + +def _generate_local_embedding(text: str, model_name: str) -> Optional[List[float]]: + embedder = _load_local_embedder(model_name) + if embedder is None: + return None + + try: + vector = next(iter(embedder.embed([text]))) + if hasattr(vector, "tolist"): + return vector.tolist() + return list(vector) + except Exception as exc: + logger.warning("Local skill embedding generation failed: %s", exc) + return None + + def generate_embedding(text: str, api_key: Optional[str] = None) -> Optional[List[float]]: - """Generate embedding using OpenAI-compatible API. + """Generate skill embedding using the configured local/remote backend. When *api_key* is ``None``, credentials are resolved automatically via - :func:`resolve_embedding_api` (``OPENROUTER_API_KEY`` → ``OPENAI_API_KEY`` - → host-agent config). + :func:`resolve_embedding_api`. - This is a **synchronous** call (uses urllib). In async contexts, - wrap with ``asyncio.to_thread()``. + Local mode uses ``fastembed``. + Remote mode uses an OpenAI-compatible ``/embeddings`` endpoint. Args: text: The text to embed. - api_key: Explicit API key. When provided, base URL is still resolved - from environment (``OPENROUTER_API_KEY`` presence determines - the endpoint). + api_key: Explicit API key for remote mode. Returns: Embedding vector, or None on failure. """ + backend = resolve_skill_embedding_backend() + model_name = resolve_skill_embedding_model(backend) + + if backend == "local": + return _generate_local_embedding(text, model_name) + resolved_key, base_url = resolve_embedding_api() if api_key is None: api_key = resolved_key + if not api_key: - return None + if backend == "remote": + logger.warning( + "Remote skill embeddings requested but no embedding API key/base was resolved." + ) + return None + return _generate_local_embedding(text, SKILL_LOCAL_EMBEDDING_MODEL) body = json.dumps({ - "model": SKILL_EMBEDDING_MODEL, + "model": model_name, "input": text, }).encode("utf-8") @@ -125,5 +229,7 @@ def generate_embedding(text: str, api_key: Optional[str] = None) -> Optional[Lis data = json.loads(resp.read().decode("utf-8")) return data.get("data", [{}])[0].get("embedding") except Exception as e: - logger.warning("Embedding generation failed: %s", e) + logger.warning("Remote skill embedding generation failed: %s", e) + if backend == "auto": + return _generate_local_embedding(text, SKILL_LOCAL_EMBEDDING_MODEL) return None diff --git a/openspace/config/README.md b/openspace/config/README.md index f2e0875..cd0222e 100644 --- a/openspace/config/README.md +++ b/openspace/config/README.md @@ -35,6 +35,13 @@ Set via `.env`, MCP config `env` block, or system environment. | `OPENSPACE_LLM_API_BASE` | LLM API base URL | — | | `OPENSPACE_LLM_EXTRA_HEADERS` | Extra LLM headers (JSON) | — | | `OPENSPACE_LLM_CONFIG` | Arbitrary litellm kwargs (JSON) | — | +| `OPENSPACE_SKILL_EMBEDDING_BACKEND` | Skill-router embedding backend: `auto`, `local`, or `remote` | `auto` | +| `OPENSPACE_SKILL_EMBEDDING_MODEL` | Skill-router embedding model | `BAAI/bge-small-en-v1.5` in local mode, `openai/text-embedding-3-small` in remote mode | +| `OPENSPACE_SKILL_EMBEDDING_API_KEY` | Dedicated remote embedding API key for skill routing | — | +| `OPENSPACE_SKILL_EMBEDDING_API_BASE` | Dedicated remote embedding API base for skill routing | — | +| `EMBEDDING_API_KEY` | Generic embedding API key (tool search, optional skill-router fallback) | — | +| `EMBEDDING_BASE_URL` | Generic embedding API base URL | — | +| `EMBEDDING_MODEL` | Generic embedding model for tool search | `BAAI/bge-small-en-v1.5` | | `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` | @@ -47,6 +54,29 @@ Set via `.env`, MCP config `env` block, or system environment. | `OPENSPACE_ENABLE_RECORDING` | Record execution traces | `true` | | `OPENSPACE_LOG_LEVEL` | Log level | `INFO` | +### Split-routing example + +Keep the main LLM on an OpenAI-compatible provider, but force the +skill-router embedding path to stay local: + +```bash +OPENSPACE_MODEL=gpt-5.4 +OPENSPACE_LLM_API_KEY=sk-xxx +OPENSPACE_LLM_API_BASE=http://127.0.0.1:8080/v1 + +OPENSPACE_SKILL_EMBEDDING_BACKEND=local +OPENSPACE_SKILL_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 +``` + +Or send skill embeddings to a separate endpoint: + +```bash +OPENSPACE_SKILL_EMBEDDING_BACKEND=remote +OPENSPACE_SKILL_EMBEDDING_API_KEY=sk-embed-xxx +OPENSPACE_SKILL_EMBEDDING_API_BASE=https://example.com/v1 +OPENSPACE_SKILL_EMBEDDING_MODEL=openai/text-embedding-3-small +``` + ## 3. MCP Servers (`config_mcp.json`) Register external MCP servers that OpenSpace connects to as a **client** (e.g. GitHub, Slack, databases): diff --git a/openspace/skill_engine/skill_ranker.py b/openspace/skill_engine/skill_ranker.py index 503eda5..8498f2b 100644 --- a/openspace/skill_engine/skill_ranker.py +++ b/openspace/skill_engine/skill_ranker.py @@ -7,7 +7,9 @@ Provides a two-stage retrieval pipeline for skill selection: Embedding strategy: - Text = ``name + description + SKILL.md body`` (consistent with MCP ``search_skills`` and the clawhub cloud platform) - - Model: ``qwen/qwen3-embedding-8b`` via OpenRouter API + - Backend is configurable via ``OPENSPACE_SKILL_EMBEDDING_BACKEND`` + and can use either a local fastembed model or a remote + OpenAI-compatible embedding endpoint - Embeddings are cached in-memory keyed by ``skill_id`` and optionally persisted to a pickle file for cross-session reuse @@ -32,8 +34,6 @@ from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) -# Embedding model — must match clawhub platform for vector-space compatibility -SKILL_EMBEDDING_MODEL = "openai/text-embedding-3-small" SKILL_EMBEDDING_MAX_CHARS = 12_000 # Pre-filter threshold: when local skills exceed this count, BM25 pre-filter @@ -44,7 +44,7 @@ PREFILTER_THRESHOLD = 10 BM25_CANDIDATES_MULTIPLIER = 3 # top_k * 3 # Cache version — increment when format changes -_CACHE_VERSION = 1 +_CACHE_VERSION = 2 @dataclass @@ -238,13 +238,6 @@ class SkillRanker: return ranked[:top_k] - @staticmethod - def _get_openai_api_key() -> Optional[str]: - """Resolve OpenAI-compatible API key for embedding requests.""" - from openspace.cloud.embedding import resolve_embedding_api - api_key, _ = resolve_embedding_api() - return api_key - @staticmethod def _build_embedding_text(candidate: SkillCandidate) -> str: """Build text for embedding, consistent with MCP search_skills.""" @@ -264,12 +257,8 @@ class SkillRanker: top_k: int, ) -> List[SkillCandidate]: """Rank candidates using embedding cosine similarity.""" - api_key = self._get_openai_api_key() - if not api_key: - return [] - # Generate query embedding - query_emb = self._generate_embedding(query, api_key=api_key) + query_emb = self._generate_embedding(query) if not query_emb: return [] @@ -281,7 +270,7 @@ class SkillRanker: c.embedding = cached else: text = self._build_embedding_text(c) - emb = self._generate_embedding(text, api_key=api_key) + emb = self._generate_embedding(text) if emb: c.embedding = emb self._embedding_cache[c.skill_id] = emb @@ -305,53 +294,20 @@ class SkillRanker: text: str, api_key: Optional[str] = None, ) -> Optional[List[float]]: - """Generate embedding via OpenAI-compatible API (text-embedding-3-small). + """Generate embedding via the configured skill-embedding backend. - Delegates credential / base-URL resolution to - :func:`openspace.cloud.embedding.resolve_embedding_api`. + Delegates backend/model resolution to :mod:`openspace.cloud.embedding`. """ - from openspace.cloud.embedding import resolve_embedding_api + from openspace.cloud.embedding import generate_embedding - resolved_key, base_url = resolve_embedding_api() - if not api_key: - api_key = resolved_key - if not api_key: - return None - - import urllib.request - - body = json.dumps({ - "model": SKILL_EMBEDDING_MODEL, - "input": text, - }).encode("utf-8") - - req = urllib.request.Request( - f"{base_url}/embeddings", - data=body, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - method="POST", - ) - import time - last_err = None - for attempt in range(3): - try: - with urllib.request.urlopen(req, timeout=15) as resp: - data = json.loads(resp.read().decode("utf-8")) - return data.get("data", [{}])[0].get("embedding") - except Exception as e: - last_err = e - if attempt < 2: - delay = 2 * (attempt + 1) - logger.debug("Embedding request failed (attempt %d/3), retrying in %ds: %s", attempt + 1, delay, e) - time.sleep(delay) - logger.warning("Skill embedding generation failed after 3 attempts: %s", last_err) - return None + return generate_embedding(text, api_key=api_key) def _cache_file(self) -> Path: - return self._cache_dir / f"skill_embeddings_v{_CACHE_VERSION}.pkl" + from openspace.cloud.embedding import resolve_skill_embedding_model + + model_name = resolve_skill_embedding_model() + safe_model_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", model_name) + return self._cache_dir / f"skill_embeddings_{safe_model_name}_v{_CACHE_VERSION}.pkl" def _load_cache(self) -> None: """Load embedding cache from disk.""" @@ -376,7 +332,7 @@ class SkillRanker: self._cache_dir.mkdir(parents=True, exist_ok=True) data = { "version": _CACHE_VERSION, - "model": SKILL_EMBEDDING_MODEL, + "model": self._cache_file().stem, "last_updated": datetime.now().isoformat(), "embeddings": self._embedding_cache, } @@ -412,4 +368,3 @@ def build_skill_embedding_text( if len(raw) <= max_chars: return raw return raw[:max_chars] - diff --git a/pyproject.toml b/pyproject.toml index a391cca..507d3cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "litellm>=1.70.0,<1.82.7", # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) "python-dotenv>=1.0.0", "openai>=1.0.0", + "fastembed>=0.8.0", "jsonschema>=4.25.0", "mcp>=1.0.0", "websockets>=15.0.0", diff --git a/requirements.txt b/requirements.txt index 8683105..abe1aa6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ litellm>=1.70.0,<1.82.7 # pinned to avoid PYSEC-2026-2 supply-chain compromise (1.82.7/1.82.8 were malicious) python-dotenv>=1.0.0 openai>=1.0.0 +fastembed>=0.8.0 jsonschema>=4.25.0 mcp>=1.0.0 websockets>=15.0.0 diff --git a/scripts/codex-desktop-evolution b/scripts/codex-desktop-evolution index 5a24b8a..6ff8dfe 100755 --- a/scripts/codex-desktop-evolution +++ b/scripts/codex-desktop-evolution @@ -53,6 +53,15 @@ OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-$(read_env_value OPENSPACE_LLM OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}" OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-$(read_env_value OPENSPACE_LLM_OPENAI_STREAM_COMPAT)}" OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}" +OPENSPACE_SKILL_EMBEDDING_BACKEND="${OPENSPACE_SKILL_EMBEDDING_BACKEND:-$(read_env_value OPENSPACE_SKILL_EMBEDDING_BACKEND)}" +OPENSPACE_SKILL_EMBEDDING_BACKEND="${OPENSPACE_SKILL_EMBEDDING_BACKEND:-local}" +OPENSPACE_SKILL_EMBEDDING_MODEL="${OPENSPACE_SKILL_EMBEDDING_MODEL:-$(read_env_value OPENSPACE_SKILL_EMBEDDING_MODEL)}" +OPENSPACE_SKILL_EMBEDDING_MODEL="${OPENSPACE_SKILL_EMBEDDING_MODEL:-BAAI/bge-small-en-v1.5}" +OPENSPACE_SKILL_EMBEDDING_API_KEY="${OPENSPACE_SKILL_EMBEDDING_API_KEY:-$(read_env_value OPENSPACE_SKILL_EMBEDDING_API_KEY)}" +OPENSPACE_SKILL_EMBEDDING_API_BASE="${OPENSPACE_SKILL_EMBEDDING_API_BASE:-$(read_env_value OPENSPACE_SKILL_EMBEDDING_API_BASE)}" +EMBEDDING_API_KEY="${EMBEDDING_API_KEY:-$(read_env_value EMBEDDING_API_KEY)}" +EMBEDDING_BASE_URL="${EMBEDDING_BASE_URL:-$(read_env_value EMBEDDING_BASE_URL)}" +EMBEDDING_MODEL="${EMBEDDING_MODEL:-$(read_env_value EMBEDDING_MODEL)}" sync_profile_dir() { local name="$1" @@ -78,7 +87,7 @@ bootstrap_profile_home() { cp "$PRIMARY_CODEX_HOME/auth.json" "$PROFILE_HOME/auth.json" - python3 - <<'PY' "$PRIMARY_CODEX_HOME/config.toml" "$PROFILE_HOME/config.toml" "$REPO_ROOT" "$REPO_PYTHON" "$PROJECT_SKILL_DIR" "$PROFILE_HOME/skills" "$OPENSPACE_MODEL" "$OPENSPACE_LLM_API_KEY" "$OPENSPACE_LLM_API_BASE" "$OPENSPACE_LLM_OPENAI_STREAM_COMPAT" + python3 - <<'PY' "$PRIMARY_CODEX_HOME/config.toml" "$PROFILE_HOME/config.toml" "$REPO_ROOT" "$REPO_PYTHON" "$PROJECT_SKILL_DIR" "$PROFILE_HOME/skills" "$OPENSPACE_MODEL" "$OPENSPACE_LLM_API_KEY" "$OPENSPACE_LLM_API_BASE" "$OPENSPACE_LLM_OPENAI_STREAM_COMPAT" "$OPENSPACE_SKILL_EMBEDDING_BACKEND" "$OPENSPACE_SKILL_EMBEDDING_MODEL" "$OPENSPACE_SKILL_EMBEDDING_API_KEY" "$OPENSPACE_SKILL_EMBEDDING_API_BASE" "$EMBEDDING_API_KEY" "$EMBEDDING_BASE_URL" "$EMBEDDING_MODEL" from pathlib import Path import os import sys @@ -93,6 +102,13 @@ model = sys.argv[7] api_key = sys.argv[8] api_base = sys.argv[9] stream_compat = sys.argv[10] +skill_embedding_backend = sys.argv[11] +skill_embedding_model = sys.argv[12] +skill_embedding_api_key = sys.argv[13] +skill_embedding_api_base = sys.argv[14] +embedding_api_key = sys.argv[15] +embedding_base_url = sys.argv[16] +embedding_model = sys.argv[17] def strip_tables(text: str, table_names: set[str]) -> str: kept = [] @@ -134,6 +150,13 @@ OPENSPACE_MODEL = "{model}" OPENSPACE_LLM_API_KEY = "{api_key}" OPENSPACE_LLM_API_BASE = "{api_base}" OPENSPACE_LLM_OPENAI_STREAM_COMPAT = "{stream_compat}" +OPENSPACE_SKILL_EMBEDDING_BACKEND = "{skill_embedding_backend}" +OPENSPACE_SKILL_EMBEDDING_MODEL = "{skill_embedding_model}" +OPENSPACE_SKILL_EMBEDDING_API_KEY = "{skill_embedding_api_key}" +OPENSPACE_SKILL_EMBEDDING_API_BASE = "{skill_embedding_api_base}" +EMBEDDING_API_KEY = "{embedding_api_key}" +EMBEDDING_BASE_URL = "{embedding_base_url}" +EMBEDDING_MODEL = "{embedding_model}" OPENSPACE_ENABLE_RECORDING = "false" OPENSPACE_BACKEND_SCOPE = "shell,system" ''' @@ -149,6 +172,9 @@ clear_openspace_env() { for var in ${!OPENSPACE_@}; do unset "$var" done + unset EMBEDDING_API_KEY + unset EMBEDDING_BASE_URL + unset EMBEDDING_MODEL } if [[ -z "$OPENSPACE_LLM_API_KEY" ]]; then diff --git a/scripts/codex-openspace b/scripts/codex-openspace index 0650383..bb459c2 100755 --- a/scripts/codex-openspace +++ b/scripts/codex-openspace @@ -24,6 +24,8 @@ fi OPENSPACE_MODEL="${OPENSPACE_MODEL:-gpt-5.4}" OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}" OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}" +OPENSPACE_SKILL_EMBEDDING_BACKEND="${OPENSPACE_SKILL_EMBEDDING_BACKEND:-local}" +OPENSPACE_SKILL_EMBEDDING_MODEL="${OPENSPACE_SKILL_EMBEDDING_MODEL:-BAAI/bge-small-en-v1.5}" sync_profile_dir() { local name="$1" @@ -102,6 +104,13 @@ OPENSPACE_MODEL = "$OPENSPACE_MODEL" OPENSPACE_LLM_API_KEY = "$OPENSPACE_LLM_API_KEY" OPENSPACE_LLM_API_BASE = "$OPENSPACE_LLM_API_BASE" OPENSPACE_LLM_OPENAI_STREAM_COMPAT = "$OPENSPACE_LLM_OPENAI_STREAM_COMPAT" +OPENSPACE_SKILL_EMBEDDING_BACKEND = "$OPENSPACE_SKILL_EMBEDDING_BACKEND" +OPENSPACE_SKILL_EMBEDDING_MODEL = "$OPENSPACE_SKILL_EMBEDDING_MODEL" +OPENSPACE_SKILL_EMBEDDING_API_KEY = "${OPENSPACE_SKILL_EMBEDDING_API_KEY:-}" +OPENSPACE_SKILL_EMBEDDING_API_BASE = "${OPENSPACE_SKILL_EMBEDDING_API_BASE:-}" +EMBEDDING_API_KEY = "${EMBEDDING_API_KEY:-}" +EMBEDDING_BASE_URL = "${EMBEDDING_BASE_URL:-}" +EMBEDDING_MODEL = "${EMBEDDING_MODEL:-}" [model_providers.codexapi] name = "codexapi" diff --git a/scripts/openspace.sh b/scripts/openspace.sh index eba4ae8..e497df5 100755 --- a/scripts/openspace.sh +++ b/scripts/openspace.sh @@ -5,6 +5,13 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" ALT_HOME="${CODEX_HOME:-$HOME/.codex-openspace}" AUTH_FILE="${OPENSPACE_AUTH_FILE:-$ALT_HOME/auth.json}" +ENV_FILE="${OPENSPACE_ENV_FILE:-$REPO_ROOT/openspace/.env}" + +if [[ -f "$ENV_FILE" ]]; then + set -a + source "$ENV_FILE" + set +a +fi api_key="${OPENSPACE_LLM_API_KEY:-}" if [[ -z "$api_key" ]]; then @@ -28,8 +35,10 @@ fi export OPENSPACE_MODEL="${OPENSPACE_MODEL:-gpt-5.4}" export OPENSPACE_LLM_API_KEY="$api_key" -export OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-https://codexapi.space/v1}" +export OPENSPACE_LLM_API_BASE="${OPENSPACE_LLM_API_BASE:-http://127.0.0.1:8080/v1}" export OPENSPACE_LLM_OPENAI_STREAM_COMPAT="${OPENSPACE_LLM_OPENAI_STREAM_COMPAT:-true}" +export OPENSPACE_SKILL_EMBEDDING_BACKEND="${OPENSPACE_SKILL_EMBEDDING_BACKEND:-local}" +export OPENSPACE_SKILL_EMBEDDING_MODEL="${OPENSPACE_SKILL_EMBEDDING_MODEL:-BAAI/bge-small-en-v1.5}" export OPENSPACE_HOST_SKILL_DIRS="${OPENSPACE_HOST_SKILL_DIRS:-$ALT_HOME/skills}" export OPENSPACE_WORKSPACE="${OPENSPACE_WORKSPACE:-$REPO_ROOT}"