"""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`). 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). """ from __future__ import annotations from functools import lru_cache from app.config import get_settings @lru_cache def gateway_client(): """OpenAI-compatible client for the opencode.ai/zen gateway — chat (Opus). None if unconfigured.""" s = get_settings() if not (s.DSPY_API_BASE and s.DSPY_API_KEY): return None from openai import OpenAI return OpenAI(base_url=s.DSPY_API_BASE, api_key=s.DSPY_API_KEY, timeout=60, max_retries=2) @lru_cache def embed_client(): """Direct OpenAI client for embeddings (the gateway has no embeddings route). 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) def curate_enabled() -> bool: """Is the Opus curator usable? (flag on + gateway key present).""" s = get_settings() return bool(s.ENGINE_LLM_ENABLED and s.DSPY_API_BASE and s.DSPY_API_KEY)