"""Runtime-configurable sentiment provider. Lets an admin switch the sentiment LLM (provider, model, API key) at runtime via SystemSetting, without a redeploy. Precedence for each value: DB setting > environment variable > hardcoded default. The API key is write-only: it is persisted but never returned through any read path. Reads report only whether a key is configured and its source. """ from __future__ import annotations import logging from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.exceptions import ProviderError, ValidationError from app.models.settings import SystemSetting from app.services.admin_service import update_setting logger = logging.getLogger(__name__) VALID_PROVIDERS = {"openai", "gemini"} PROVIDER_DEFAULT_MODELS: dict[str, str] = { "openai": "gpt-4o-mini", "gemini": "gemini-2.0-flash", } # SystemSetting keys KEY_PROVIDER = "sentiment_provider" KEY_MODEL = "sentiment_model" KEY_API_KEY = "sentiment_api_key" async def _get_settings_map(db: AsyncSession) -> dict[str, str]: result = await db.execute( select(SystemSetting).where( SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY]) ) ) return {s.key: s.value for s in result.scalars().all()} def _env_key_for(provider: str) -> str: if provider == "openai": return settings.openai_api_key or "" if provider == "gemini": return settings.gemini_api_key or "" return "" def _env_model_for(provider: str) -> str: if provider == "openai": return settings.openai_model or PROVIDER_DEFAULT_MODELS["openai"] if provider == "gemini": return settings.gemini_model or PROVIDER_DEFAULT_MODELS["gemini"] return PROVIDER_DEFAULT_MODELS.get(provider, "") async def _resolve(db: AsyncSession) -> tuple[str, str, str, str]: """Resolve (provider, model, api_key, api_key_source) from DB > env > default.""" stored = await _get_settings_map(db) provider = (stored.get(KEY_PROVIDER) or "").strip().lower() if provider not in VALID_PROVIDERS: # Default to whichever env key is present, else openai provider = "openai" if settings.openai_api_key or not settings.gemini_api_key else "gemini" model = (stored.get(KEY_MODEL) or "").strip() or _env_model_for(provider) db_key = (stored.get(KEY_API_KEY) or "").strip() if db_key: return provider, model, db_key, "database" env_key = _env_key_for(provider) if env_key: return provider, model, env_key, "environment" return provider, model, "", "none" async def get_sentiment_config(db: AsyncSession) -> dict: """Public config — never includes the raw API key.""" provider, model, api_key, source = await _resolve(db) return { "provider": provider, "model": model, "api_key_configured": bool(api_key), "api_key_source": source, "valid_providers": sorted(VALID_PROVIDERS), "default_models": PROVIDER_DEFAULT_MODELS, } async def update_sentiment_config( db: AsyncSession, provider: str | None = None, model: str | None = None, api_key: str | None = None, ) -> dict: """Persist provider/model/key. An empty/omitted api_key leaves the stored key untouched (so saving other fields does not wipe credentials).""" if provider is not None: provider = provider.strip().lower() if provider not in VALID_PROVIDERS: raise ValidationError( f"Unknown sentiment provider '{provider}'. Valid: {', '.join(sorted(VALID_PROVIDERS))}" ) await update_setting(db, KEY_PROVIDER, provider) if model is not None: model = model.strip() if model: await update_setting(db, KEY_MODEL, model) if api_key: # only overwrite when a non-empty key is supplied await update_setting(db, KEY_API_KEY, api_key.strip()) return await get_sentiment_config(db) async def build_sentiment_provider(db: AsyncSession): """Construct the active sentiment provider from current config. Raises ProviderError if no API key is available for the chosen provider. """ provider, model, api_key, _source = await _resolve(db) if not api_key: raise ProviderError(f"No API key configured for sentiment provider '{provider}'") if provider == "openai": from app.providers.openai_sentiment import OpenAISentimentProvider return OpenAISentimentProvider(api_key, model) if provider == "gemini": from app.providers.gemini_sentiment import GeminiSentimentProvider return GeminiSentimentProvider(api_key, model) raise ProviderError(f"Unsupported sentiment provider '{provider}'") async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dict: """Build the active provider and fetch one ticker as a live credentials check.""" provider, model, _key, _source = await _resolve(db) try: prov = await build_sentiment_provider(db) data = await prov.fetch_sentiment(ticker.strip().upper()) return { "ok": True, "provider": provider, "model": model, "ticker": ticker.strip().upper(), "classification": data.classification, "confidence": data.confidence, "reasoning": data.reasoning or None, } except Exception as exc: logger.warning("Sentiment provider test failed: %s", exc) return { "ok": False, "provider": provider, "model": model, "error": str(exc), }