Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"uvicorn[standard]",
"httpx",
"tiktoken",
"requests",
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ httpx
fastapi
uvicorn[standard]
tiktoken
requests
6 changes: 6 additions & 0 deletions skillclaw/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ class SkillClawConfig:
skills_dir: str = "memory_data/skills"
skills_public_root: str = ""
retrieval_mode: str = "template"
# Embedding configuration: "local" for SentenceTransformer, "api" for OpenAI-compatible APIs
embedding_type: str = "local"
embedding_model_path: str = "Qwen/Qwen3-Embedding-0.6B"
# OpenAI-compatible embedding API configuration
embedding_api_url: str = "" # e.g., "https://api.openai.com/v1" or "https://api.jina.ai/v1"
embedding_api_model: str = "" # e.g., "text-embedding-3-small" or "jina-embeddings-v5-text-small"
embedding_api_key: str = ""
skill_top_k: int = 6
max_skills_prompt_chars: int = 30000

Expand Down
143 changes: 143 additions & 0 deletions skillclaw/embedding_api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""
Embedding API client supporting OpenAI-compatible APIs.

Supports any embedding service with OpenAI API format, including:
- OpenAI (https://api.openai.com/v1/embeddings)
- Jina (https://api.jina.ai/v1/embeddings)
- Azure OpenAI
- LocalAI
- Ollama (with OpenAI-compatible server)
"""

import logging
import numpy as np
import requests
from typing import List, Optional

logger = logging.getLogger(__name__)


class EmbeddingAPIClient:
"""Client for OpenAI-compatible embedding APIs."""

def __init__(
self,
api_url: str,
model: str,
api_key: Optional[str] = None,
timeout: int = 30,
):
"""Initialize embedding API client.

Args:
api_url: Base URL of the embedding API (e.g., "https://api.openai.com/v1")
model: Model name to use for embeddings
api_key: API key for authentication (optional for local services)
timeout: Request timeout in seconds
"""
self.api_url = api_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
self._session = None

@property
def session(self):
"""Lazy-load requests session."""
if self._session is None:
self._session = requests.Session()
return self._session

def encode(
self,
texts: List[str],
normalize_embeddings: bool = True,
show_progress_bar: bool = False,
convert_to_numpy: bool = True,
) -> np.ndarray:
"""Encode texts into embeddings using the API.

Args:
texts: List of text strings to encode
normalize_embeddings: Whether to normalize embeddings (L2)
show_progress_bar: Whether to show progress bar (ignored for API)
convert_to_numpy: Whether to return numpy array

Returns:
numpy array of shape (len(texts), embedding_dim)
"""
if show_progress_bar:
logger.warning(
"show_progress_bar parameter is not supported for embedding API client and will be ignored"
)

if not texts:
return np.zeros((0, 0), dtype=np.float32)

embeddings = self._call_api(texts)

if normalize_embeddings:
# L2 normalization
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
norms[norms == 0] = 1 # Avoid division by zero
embeddings = embeddings / norms

if convert_to_numpy:
return embeddings.astype(np.float32)
return embeddings

def _call_api(self, texts: List[str]) -> np.ndarray:
"""Call the embedding API and return embeddings.

Args:
texts: List of text strings to encode

Returns:
numpy array of embeddings
"""
headers = {
"Content-Type": "application/json",
}

if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"

payload = {
"model": self.model,
"input": texts,
}

try:
response = self.session.post(
f"{self.api_url}/embeddings",
json=payload,
headers=headers,
timeout=self.timeout,
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(f"Embedding API request failed: {e}")
raise

data = response.json()

# Extract embeddings from response
# OpenAI format: {"data": [{"embedding": [...], "index": 0}, ...]}
if "data" not in data:
raise ValueError(f"Unexpected API response format: {data}")

embeddings_list = sorted(data["data"], key=lambda x: x.get("index", 0))
embeddings = np.array(
[item["embedding"] for item in embeddings_list],
dtype=np.float32,
)

logger.debug(
f"Retrieved {len(embeddings)} embeddings with dimension {embeddings.shape[1]}"
)
return embeddings

def __del__(self):
"""Close session when client is destroyed."""
if self._session is not None:
self._session.close()
4 changes: 4 additions & 0 deletions skillclaw/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ async def _run(self, cfg):
public_skill_root=cfg.skills_public_root,
retrieval_mode=cfg.retrieval_mode,
embedding_model_path=cfg.embedding_model_path,
embedding_type=cfg.embedding_type,
embedding_api_url=cfg.embedding_api_url,
embedding_api_model=cfg.embedding_api_model,
embedding_api_key=cfg.embedding_api_key,
)
logger.info("[Launcher] SkillManager loaded: %s skills", skill_manager.get_skill_count())

Expand Down
48 changes: 39 additions & 9 deletions skillclaw/skill_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,10 @@ def __init__(
public_skill_root: str = "",
retrieval_mode: str = "template",
embedding_model_path: Optional[str] = None,
embedding_type: str = "local",
embedding_api_url: Optional[str] = None,
embedding_api_model: Optional[str] = None,
embedding_api_key: Optional[str] = None,
):
if retrieval_mode not in ("template", "embedding"):
raise ValueError(f"retrieval_mode must be 'template' or 'embedding', got '{retrieval_mode}'")
Expand All @@ -183,7 +187,11 @@ def __init__(
self._skills_dir = skills_dir
self._public_skill_root = public_skill_root.strip()
self.retrieval_mode = retrieval_mode
self.embedding_type = embedding_type
self.embedding_model_path = embedding_model_path or "Qwen/Qwen3-Embedding-0.6B"
self.embedding_api_url = embedding_api_url
self.embedding_api_model = embedding_api_model
self.embedding_api_key = embedding_api_key

self._embedding_model = None
self._skill_embeddings_cache: Optional[Dict] = None
Expand All @@ -199,10 +207,11 @@ def __init__(

counts = self._category_counts()
logger.info(
"[SkillManager] loaded %d skills from %s | mode=%s | categories=%s",
"[SkillManager] loaded %d skills from %s | mode=%s | embedding_type=%s | categories=%s",
len(self.skills.get("all_skills", [])),
skills_dir,
retrieval_mode,
embedding_type,
dict(counts),
)

Expand Down Expand Up @@ -372,16 +381,37 @@ def refresh_if_changed(self) -> bool:
# ------------------------------------------------------------------ #

def _get_embedding_model(self):
"""Get embedding model - either local SentenceTransformer or API client."""
if self._embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for embedding retrieval. "
"Install with: pip install sentence-transformers"
if self.embedding_type == "api":
# Use OpenAI-compatible API
if not self.embedding_api_url or not self.embedding_api_model:
raise ValueError(
"embedding_api_url and embedding_api_model must be set when embedding_type='api'"
)
from .embedding_api_client import EmbeddingAPIClient

logger.info(
"[SkillManager] using embedding API: %s (model: %s)",
self.embedding_api_url,
self.embedding_api_model,
)
self._embedding_model = EmbeddingAPIClient(
api_url=self.embedding_api_url,
model=self.embedding_api_model,
api_key=self.embedding_api_key,
)
logger.info("[SkillManager] loading embedding model: %s", self.embedding_model_path)
self._embedding_model = SentenceTransformer(self.embedding_model_path)
else:
# Use local SentenceTransformer model
try:
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for local embedding. "
"Install with: pip install sentence-transformers"
)
logger.info("[SkillManager] loading embedding model: %s", self.embedding_model_path)
self._embedding_model = SentenceTransformer(self.embedding_model_path)
return self._embedding_model

@staticmethod
Expand Down
Loading