From 47be9dc7157b5666ad28505534dba6ad4cf3f903 Mon Sep 17 00:00:00 2001 From: Sai-karthik Date: Mon, 29 Jun 2026 19:27:17 +0000 Subject: [PATCH] Support OpenRouter embeddings gateway --- app/config.py | 6 +++--- app/engine/embed.py | 40 ++++++++++++++++++++++++++++++++++------ app/engine/llm.py | 11 +++++++---- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/app/config.py b/app/config.py index 5fe70cb..c6666e5 100644 --- a/app/config.py +++ b/app/config.py @@ -31,14 +31,14 @@ class Settings(BaseSettings): # Inputs / engine APIFY_TOKEN: str | None = None OPENAI_API_KEY: str | None = None + OPENAI_API_BASE: str | None = None - # LLM gateway (opencode.ai/zen — OpenAI-compatible; reuse interview-service's DSPY key). - # One `openai` client (base_url override) serves the Opus curator + embeddings. + # LLM gateway (OpenAI-compatible, e.g. opencode/OpenRouter) for chat curation. DSPY_API_BASE: str | None = None DSPY_API_KEY: str | None = None CURATE_MODEL: str = "claude-opus-4-8" # the senior recruiter (Opus, latest) — Opus's call is final SUGGEST_MODEL: str = "claude-haiku-4-5" # the bubbling engine — fast + cheap (latency-sensitive UI) - EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI — gateway has no embeddings route) + EMBED_MODEL: str = "text-embedding-3-small" # the vibe-engineer (direct OpenAI or OpenAI-compatible embeddings API) ENGINE_LLM_ENABLED: bool = True # off / key absent → white-box sift + templated cards (safety net) SIFT_TOP_K: int = 18 # candidate pool the curator reads diff --git a/app/engine/embed.py b/app/engine/embed.py index c9a02e9..1331c9a 100644 --- a/app/engine/embed.py +++ b/app/engine/embed.py @@ -10,6 +10,37 @@ from app.config import get_settings from app.engine.llm import embed_client +def _embedding_vectors(s, texts: list[str]) -> list[list[float]] | None: + if s.OPENAI_API_BASE: + import httpx + + try: + resp = httpx.post( + s.OPENAI_API_BASE.rstrip("/") + "/embeddings", + headers={ + "Authorization": f"Bearer {s.OPENAI_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://sai-onchain.me", + "X-Title": "Sai Onchain Matchmaking", + }, + json={"model": s.EMBED_MODEL, "input": texts}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + return [item["embedding"] for item in data.get("data", [])] + except Exception: + return None + client = embed_client() + if not client: + return None + try: + resp = client.embeddings.create(model=s.EMBED_MODEL, input=texts) + return [d.embedding for d in resp.data] + except Exception: + return None + + def _job_text(job: dict) -> str: d = job.get("details") or {} parts = [ @@ -43,18 +74,15 @@ def _cos(a, b) -> float: def vibe_scores(prefs: dict, ctx: dict | None, jobs: list[dict]) -> dict[str, float] | None: """id → cosine sim (0..1). None if embeddings can't run (caller falls back to white-box only).""" - client = embed_client() - if not client or not jobs: + if not jobs: return None ptext = _profile_text(prefs, ctx) if not ptext.strip(): return None s = get_settings() texts = [ptext] + [_job_text(j) for j in jobs] - try: - resp = client.embeddings.create(model=s.EMBED_MODEL, input=texts) - except Exception: + vecs = _embedding_vectors(s, texts) + if not vecs or len(vecs) != len(texts): return None - vecs = [d.embedding for d in resp.data] pvec, jvecs = vecs[0], vecs[1:] return {job["id"]: max(0.0, min(1.0, _cos(pvec, jv))) for job, jv in zip(jobs, jvecs)} diff --git a/app/engine/llm.py b/app/engine/llm.py index b93eff3..9d4b15d 100644 --- a/app/engine/llm.py +++ b/app/engine/llm.py @@ -1,8 +1,8 @@ """LLM clients — ONE place to build them. Two backends, by capability: -- **Gateway** (opencode.ai/zen, OpenAI-compatible) → chat for the Opus curator. Has no embeddings route. -- **Direct OpenAI** (poc key) → embeddings (`text-embedding-3-small`). +- **Gateway** (OpenAI-compatible, e.g. opencode/OpenRouter) → chat for the curator. +- **Embeddings** → direct OpenAI or an OpenAI-compatible embeddings API via OPENAI_API_BASE. Both keys come from config/.env (git-ignored). Each builder returns `None` when unconfigured so the engine degrades gracefully (white-box sift + templated cards — the safety net). @@ -26,12 +26,15 @@ def gateway_client(): @lru_cache def embed_client(): - """Direct OpenAI client for embeddings (the gateway has no embeddings route). None if no key.""" + """OpenAI-compatible client for embeddings. None if no key.""" s = get_settings() if not s.OPENAI_API_KEY: return None from openai import OpenAI - return OpenAI(api_key=s.OPENAI_API_KEY, timeout=30, max_retries=2) + kwargs = {"api_key": s.OPENAI_API_KEY, "timeout": 30, "max_retries": 2} + if s.OPENAI_API_BASE: + kwargs["base_url"] = s.OPENAI_API_BASE + return OpenAI(**kwargs) def curate_enabled() -> bool: