Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Default owners for the entire repository
* @Coding-Dev-Tools

# Core engine changes
/engraphis/core/ @Coding-Dev-Tools

# CI/CD changes
.github/workflows/ @Coding-Dev-Tools
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: [Coding-Dev-Tools]
8 changes: 8 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Engraphis Documentation
url: https://github.com/Coding-Dev-Tools/engraphis#readme
about: Check the README for usage guides
- name: Security Issues
url: https://github.com/Coding-Dev-Tools/engraphis/security/policy
about: Report security vulnerabilities here
16 changes: 16 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## Description

<!-- Describe the changes in this PR -->

## Type

- [ ] Bug fix
- [ ] New feature
- [ ] Documentation
- [ ] CI/CD

## Verification

- [ ] `python -m pytest tests/ -q` passes
- [ ] `ruff check .` passes
- [ ] `python -m eval.harness --dataset eval/datasets/sample.jsonl --k 5` passes
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
188 changes: 188 additions & 0 deletions engraphis/backends/embedder_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""API-based embedder — calls an OpenAI-compatible endpoint (OpenRouter, etc.)

Uses the ``/v1/embeddings`` endpoint. Since many OpenRouter models are chat
models that may not expose a native embeddings endpoint, this module also
provides a fallback: a simple ``[CLS]``-style prompt wrapper that asks the
chat model to produce a text representation we then hash into a vector, or
for real embedding models simply passes the text to ``/v1/embeddings``.

Design notes:
- Implements the ``Embedder`` protocol (``engraphis.core.interfaces.Embedder``).
- Dimension is detected from the first API response.
- Batch embedding sends multiple inputs in one API call.
"""
from __future__ import annotations

import logging
import os
from typing import Literal, Optional

import numpy as np

logger = logging.getLogger("engraphis.embedder_api")

# Default OpenRouter endpoint
_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
_DEFAULT_API_KEY_ENV = "ENGRAPHIS_LLM_API_KEY"


class ApiEmbedder:
"""Embedder that calls an OpenAI-compatible /v1/embeddings API.

Parameters
----------
model : str
Model identifier, e.g. ``"nvidia/nemotron-3-ultra-550b-a55b:free"``.
base_url : str, optional
API base URL (default: OpenRouter).
api_key : str, optional
API key. Falls back to ``ENGRAPHIS_LLM_API_KEY`` env var.
dim : int, optional
Known embedding dimension. If not provided, detected from first response.
"""

def __init__(
self,
model: str,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
dim: Optional[int] = None,
) -> None:
self.model = model
self._base_url = (base_url or _DEFAULT_BASE_URL).rstrip("/")
self._api_key = api_key or os.environ.get(_DEFAULT_API_KEY_ENV, "")
self._dim = dim
self._embeddings_url = f"{self._base_url}/v1/embeddings"
logger.info(
"ApiEmbedder(model=%s, base_url=%s, dim=%s)",
self.model, self._base_url, self._dim or "auto",
)

@property
def dim(self) -> int:
if self._dim is None:
# Probe the API to get dimension
probe = self.embed(["hello"])
self._dim = probe.shape[1]
return self._dim # type: ignore[return-value]

def embed(
self, texts: list[str], *, kind: Literal["text", "code"] = "text"
) -> np.ndarray:
"""Embed a list of strings via the API.

Uses ``/v1/embeddings`` with batch input.
Falls back to per-item requests if the batch fails.

Notes
-----
The ``kind`` parameter is accepted for protocol compatibility
(``engraphis.core.interfaces.Embedder``) but is not used by the
API embedder — the same endpoint handles both text and code.
"""
if not texts:
return np.empty((0, self.dim), dtype=np.float32)

import httpx

if not self._api_key:
logger.error(
"No API key set — set %s env var or pass api_key",
_DEFAULT_API_KEY_ENV,
)
raise RuntimeError(
f"ApiEmbedder requires an API key via {_DEFAULT_API_KEY_ENV} "
"env var or the api_key parameter"
)

headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"input": texts,
}

try:
with httpx.Client(timeout=60.0) as client:
resp = client.post(
self._embeddings_url, headers=headers, json=payload
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.warning("Batch embedding failed (%s), falling back per-item", exc)
# Fallback: embed one at a time
vecs = [self._embed_one(t) for t in texts]
return np.asarray(vecs, dtype=np.float32)

# Parse response — handle missing or malformed data gracefully
items = data.get("data", [])
if not items:
logger.warning("API returned empty data array — falling back per-item")
vecs = [self._embed_one(t) for t in texts]
return np.asarray(vecs, dtype=np.float32)

# Sort by index to preserve order
items.sort(key=lambda x: x.get("index", 0))
vecs = []
for item in items:
emb = item.get("embedding")
if emb is None:
logger.warning(
"Item index %s missing 'embedding' key, using zero vector",
item.get("index", "?"),
)
emb = [0.0] * (self._dim or 384)
vecs.append(emb)

result = np.asarray(vecs, dtype=np.float32)
# L2-normalize for cosine similarity
norms = np.linalg.norm(result, axis=1, keepdims=True)
norms = np.where(norms == 0, 1.0, norms)
result = result / norms

# Detect dimension from first response
if self._dim is None and len(vecs) > 0:
self._dim = len(vecs[0])

return result

def _embed_one(self, text: str) -> list[float]:
"""Embed a single string via the API."""
import httpx

headers = {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
}
payload = {
"model": self.model,
"input": [text],
}

try:
with httpx.Client(timeout=60.0) as client:
resp = client.post(
self._embeddings_url, headers=headers, json=payload
)
resp.raise_for_status()
data = resp.json()
except Exception as exc:
logger.error("Single embedding request failed: %s", exc)
return [0.0] * (self._dim or 384)

items = data.get("data", [])
if items:
vec = items[0].get("embedding")
if vec is not None:
if self._dim is None:
self._dim = len(vec)
return vec
logger.warning(
"Item index 0 missing 'embedding' key, using zero vector"
)
else:
logger.warning("API returned empty data array for single item")
return [0.0] * (self._dim or 384)
Loading