"""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.ext.asyncio import AsyncSession from app.config import settings from app.exceptions import ProviderError, ValidationError from app.services import settings_store from app.services.admin_service import update_setting logger = logging.getLogger(__name__) VALID_PROVIDERS = {"openai", "gemini", "deepseek", "xai", "openai_compatible"} PROVIDER_DEFAULT_MODELS: dict[str, str] = { "openai": "gpt-4o-mini", "gemini": "gemini-2.0-flash", "deepseek": "deepseek-chat", "xai": "grok-3", "openai_compatible": "", } # Fixed base URLs for named OpenAI-compatible providers. PROVIDER_BASE_URLS: dict[str, str] = { "deepseek": "https://api.deepseek.com", "xai": "https://api.x.ai/v1", } # Providers grounded in live web search. The rest score from model knowledge. # xAI and OpenAI ground via the Responses API web-search tool; Gemini via its # own search grounding. WEB_SEARCH_PROVIDERS = {"openai", "gemini", "xai"} # Providers needing a user-supplied base URL (generic compatible endpoints). CUSTOM_BASE_URL_PROVIDERS = {"openai_compatible"} # SystemSetting keys KEY_PROVIDER = "sentiment_provider" KEY_MODEL = "sentiment_model" KEY_API_KEY = "sentiment_api_key" KEY_BASE_URL = "sentiment_base_url" 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 "" if provider == "deepseek": return getattr(settings, "deepseek_api_key", "") or "" if provider == "xai": return getattr(settings, "xai_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, "") def _base_url_for(provider: str, stored_base_url: str) -> str: if provider in PROVIDER_BASE_URLS: return PROVIDER_BASE_URLS[provider] return stored_base_url async def _resolve(db: AsyncSession) -> dict: """Resolve effective config from DB > env > default.""" stored = await settings_store.get_map(db, [KEY_PROVIDER, KEY_MODEL, KEY_API_KEY, KEY_BASE_URL]) provider = (stored.get(KEY_PROVIDER) or "").strip().lower() if provider not in VALID_PROVIDERS: 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) base_url = _base_url_for(provider, (stored.get(KEY_BASE_URL) or "").strip()) db_key = (stored.get(KEY_API_KEY) or "").strip() if db_key: api_key, source = db_key, "database" elif _env_key_for(provider): api_key, source = _env_key_for(provider), "environment" else: api_key, source = "", "none" return { "provider": provider, "model": model, "base_url": base_url, "api_key": api_key, "api_key_source": source, } async def get_sentiment_config(db: AsyncSession) -> dict: """Public config — never includes the raw API key.""" r = await _resolve(db) return { "provider": r["provider"], "model": r["model"], "base_url": r["base_url"], "api_key_configured": bool(r["api_key"]), "api_key_source": r["api_key_source"], "web_search": r["provider"] in WEB_SEARCH_PROVIDERS, "valid_providers": sorted(VALID_PROVIDERS), "default_models": PROVIDER_DEFAULT_MODELS, "web_search_providers": sorted(WEB_SEARCH_PROVIDERS), "custom_base_url_providers": sorted(CUSTOM_BASE_URL_PROVIDERS), } async def update_sentiment_config( db: AsyncSession, provider: str | None = None, model: str | None = None, api_key: str | None = None, base_url: str | None = None, ) -> dict: """Persist config. An empty/omitted api_key leaves the stored key untouched.""" 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 base_url is not None: await update_setting(db, KEY_BASE_URL, base_url.strip()) 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 credentials/base_url are missing. """ r = await _resolve(db) provider, model, base_url, api_key = r["provider"], r["model"], r["base_url"], r["api_key"] 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) if provider == "xai": # xAI grounds via the Responses API web_search tool (the former Live # Search / search_parameters API is deprecated). from app.providers.openai_sentiment import OpenAISentimentProvider return OpenAISentimentProvider( api_key, model, base_url=base_url, tool_type="web_search", source="xai", ) if provider in {"deepseek", "openai_compatible"}: if not base_url: raise ProviderError(f"No base_url configured for sentiment provider '{provider}'") from app.providers.openai_compatible_sentiment import OpenAICompatibleSentimentProvider return OpenAICompatibleSentimentProvider(api_key, model, base_url, source=provider) 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.""" r = await _resolve(db) try: prov = await build_sentiment_provider(db) data = await prov.fetch_sentiment(ticker.strip().upper()) return { "ok": True, "provider": r["provider"], "model": r["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": r["provider"], "model": r["model"], "error": str(exc), }