From 066213a917ea87981358edad268ac550c3e7f31e Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Thu, 2 Jul 2026 19:20:30 -0400 Subject: [PATCH 1/2] improve: add GitHub community files (dependabot, FUNDING, CODEOWNERS, PR template, issue config) --- .github/CODEOWNERS | 8 ++ .github/FUNDING.yml | 1 + .github/ISSUE_TEMPLATE/config.yml | 8 ++ .github/PULL_REQUEST_TEMPLATE.md | 16 ++++ .github/dependabot.yml | 14 +++ engraphis/backends/embedder_api.py | 145 +++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 engraphis/backends/embedder_api.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..95aa6f8 --- /dev/null +++ b/.github/CODEOWNERS @@ -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 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4c1c2fc --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [Coding-Dev-Tools] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b0766af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..871fbcf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## Description + + + +## 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 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3637cbb --- /dev/null +++ b/.github/dependabot.yml @@ -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 diff --git a/engraphis/backends/embedder_api.py b/engraphis/backends/embedder_api.py new file mode 100644 index 0000000..ee860d6 --- /dev/null +++ b/engraphis/backends/embedder_api.py @@ -0,0 +1,145 @@ +"""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. + """ + import httpx + + 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 + items = data.get("data", []) + # Sort by index to preserve order + items.sort(key=lambda x: x.get("index", 0)) + vecs = [item["embedding"] for item in items] + + 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], + } + + 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() + + items = data.get("data", []) + if items: + vec = items[0].get("embedding", []) + if self._dim is None: + self._dim = len(vec) + return vec + return [0.0] * (self._dim or 384) From 109600d4daddf8044411c522c84e34fe5639f347 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Thu, 2 Jul 2026 19:32:21 -0400 Subject: [PATCH 2/2] fix: handle empty texts, missing API key, and malformed API responses in embedder_api.py by reviewer-B --- engraphis/backends/embedder_api.py | 67 ++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/engraphis/backends/embedder_api.py b/engraphis/backends/embedder_api.py index ee860d6..3ed8ce6 100644 --- a/engraphis/backends/embedder_api.py +++ b/engraphis/backends/embedder_api.py @@ -73,9 +73,28 @@ def embed( 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", @@ -98,11 +117,25 @@ def embed( vecs = [self._embed_one(t) for t in texts] return np.asarray(vecs, dtype=np.float32) - # Parse response + # 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 = [item["embedding"] for item in items] + 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 @@ -129,17 +162,27 @@ def _embed_one(self, text: str) -> list[float]: "input": [text], } - 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() + 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 self._dim is None: - self._dim = len(vec) - return vec + 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)