"""LLM clients — ONE place to build them. Two backends, by capability: - **Gateway** → chat for the curator, using DSPY_* when configured or direct OpenAI otherwise. - **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). """ from __future__ import annotations from functools import lru_cache from app.config import get_settings @lru_cache def gateway_client(): """Chat client for curation/suggestions. Prefer DSPY_* override, otherwise use OPENAI_API_KEY.""" s = get_settings() api_key = s.DSPY_API_KEY or s.OPENAI_API_KEY if not api_key: return None from openai import OpenAI kwargs = {"api_key": api_key, "timeout": 60, "max_retries": 2} if s.DSPY_API_BASE: kwargs["base_url"] = s.DSPY_API_BASE return OpenAI(**kwargs) @lru_cache def embed_client(): """OpenAI-compatible client for embeddings. None if no key.""" s = get_settings() if not s.OPENAI_API_KEY: return None from openai import OpenAI 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: """Is the curator usable? (flag on + any chat key present).""" s = get_settings() return bool(s.ENGINE_LLM_ENABLED and (s.DSPY_API_KEY or s.OPENAI_API_KEY))