diff --git a/app/config.py b/app/config.py index 5d8a960..98b8864 100644 --- a/app/config.py +++ b/app/config.py @@ -24,6 +24,10 @@ class Settings(BaseSettings): openai_model: str = "gpt-4o-mini" openai_sentiment_batch_size: int = 5 + # Sentiment Provider — DeepSeek / xAI (OpenAI-compatible; optional env fallback) + deepseek_api_key: str = "" + xai_api_key: str = "" + # Fundamentals Provider — Financial Modeling Prep fmp_api_key: str = "" diff --git a/app/providers/openai_compatible_sentiment.py b/app/providers/openai_compatible_sentiment.py new file mode 100644 index 0000000..c6356b9 --- /dev/null +++ b/app/providers/openai_compatible_sentiment.py @@ -0,0 +1,171 @@ +"""Sentiment provider for any OpenAI-compatible Chat Completions endpoint. + +Covers DeepSeek, OpenRouter, Together, Groq, Mistral, local Ollama, etc. — any +service exposing the OpenAI Chat Completions API at a custom base_url. + +NOTE: Unlike the OpenAI Responses provider and Gemini, this path has NO web +search grounding. Sentiment reflects the model's training knowledge, not live +news. Cheap, but not real-time. +""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from pathlib import Path + +import httpx +from openai import AsyncOpenAI + +from app.exceptions import ProviderError, RateLimitError +from app.providers.protocol import SentimentData + +logger = logging.getLogger(__name__) + +_CA_BUNDLE = os.environ.get("SSL_CERT_FILE", "") + +_SENTIMENT_PROMPT = """\ +Assess the CURRENT market sentiment for the stock ticker {ticker} based on your \ +knowledge of the company, its sector, and recent developments you are aware of. + +Respond ONLY with a JSON object in this exact format (no markdown, no extra text): +{{"classification": "", "confidence": <0-100>, "reasoning": ""}} + +Rules: +- classification must be exactly one of: bullish, bearish, neutral +- confidence must be an integer from 0 to 100 +- reasoning should be a brief one-sentence explanation +""" + +_SENTIMENT_PROMPT_SEARCH = """\ +Search the web and X for the LATEST news, analyst opinions, and market developments \ +about the stock ticker {ticker} from the past 24-48 hours. + +Based on your search findings, analyze the CURRENT market sentiment. + +Respond ONLY with a JSON object in this exact format (no markdown, no extra text): +{{"classification": "", "confidence": <0-100>, "reasoning": ""}} + +Rules: +- classification must be exactly one of: bullish, bearish, neutral +- confidence must be an integer from 0 to 100 +- reasoning should cite specific recent news or events you found +""" + +VALID_CLASSIFICATIONS = {"bullish", "bearish", "neutral"} + + +def _clean_json_text(raw: str) -> str: + clean = raw.strip() + if clean.startswith("```"): + clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:] + if clean.endswith("```"): + clean = clean[:-3] + return clean.strip() + + +class OpenAICompatibleSentimentProvider: + """Sentiment via the OpenAI Chat Completions API at a configurable base_url.""" + + def __init__( + self, + api_key: str, + model: str, + base_url: str, + source: str = "openai_compatible", + live_search: bool = False, + extra_body: dict | None = None, + ) -> None: + if not api_key: + raise ProviderError("API key is required") + if not base_url: + raise ProviderError("base_url is required for an OpenAI-compatible provider") + if not model: + raise ProviderError("model is required") + + http_kwargs: dict = {} + if _CA_BUNDLE and Path(_CA_BUNDLE).exists(): + http_kwargs["verify"] = _CA_BUNDLE + http_client = httpx.AsyncClient(**http_kwargs) + self._client = AsyncOpenAI(api_key=api_key, base_url=base_url, http_client=http_client) + self._model = model + self._source = source + self._live_search = live_search + self._extra_body = extra_body + + @staticmethod + def _extract_citations(response: object) -> list[dict[str, str]]: + """Best-effort extraction of xAI Live Search citations (list of URLs).""" + raw = getattr(response, "citations", None) + if not raw: + extra = getattr(response, "model_extra", None) or {} + raw = extra.get("citations") if isinstance(extra, dict) else None + citations: list[dict[str, str]] = [] + for item in raw or []: + if isinstance(item, str): + citations.append({"url": item, "title": ""}) + elif isinstance(item, dict) and item.get("url"): + citations.append({"url": str(item["url"]), "title": str(item.get("title", ""))}) + return citations + + async def fetch_sentiment(self, ticker: str) -> SentimentData: + prompt = _SENTIMENT_PROMPT_SEARCH if self._live_search else _SENTIMENT_PROMPT + kwargs: dict = {} + if self._extra_body: + kwargs["extra_body"] = self._extra_body + try: + response = await self._client.chat.completions.create( + model=self._model, + messages=[ + { + "role": "system", + "content": "You are a financial sentiment analyst. Always respond with valid JSON only, no markdown fences.", + }, + {"role": "user", "content": prompt.format(ticker=ticker)}, + ], + temperature=0.3, + **kwargs, + ) + raw_text = (response.choices[0].message.content or "").strip() + if not raw_text: + raise ProviderError(f"Empty response from {self._source} for {ticker}") + + parsed = json.loads(_clean_json_text(raw_text)) + + classification = str(parsed.get("classification", "")).lower() + if classification not in VALID_CLASSIFICATIONS: + raise ProviderError( + f"Invalid classification '{classification}' from {self._source} for {ticker}" + ) + + confidence = max(0, min(100, int(parsed.get("confidence", 50)))) + reasoning = str(parsed.get("reasoning", "")) + if reasoning: + logger.info( + "%s sentiment for %s: %s (confidence=%d) — %s", + self._source, ticker, classification, confidence, reasoning, + ) + + return SentimentData( + ticker=ticker, + classification=classification, + confidence=confidence, + source=self._source, + timestamp=datetime.now(timezone.utc), + reasoning=reasoning, + citations=self._extract_citations(response) if self._live_search else [], + ) + + except json.JSONDecodeError as exc: + logger.error("Failed to parse %s JSON for %s: %s", self._source, ticker, exc) + raise ProviderError(f"Invalid JSON from {self._source} for {ticker}") from exc + except ProviderError: + raise + except Exception as exc: + msg = str(exc).lower() + if "429" in msg or "rate" in msg or "quota" in msg or "insufficient" in msg: + raise RateLimitError(f"{self._source} rate limit hit for {ticker}") from exc + logger.error("%s provider error for %s: %s", self._source, ticker, exc) + raise ProviderError(f"{self._source} provider error for {ticker}: {exc}") from exc diff --git a/app/routers/admin.py b/app/routers/admin.py index 57d6d46..870a8a5 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -194,6 +194,7 @@ async def update_sentiment_settings( provider=body.provider, model=body.model, api_key=body.api_key, + base_url=body.base_url, ) return APIEnvelope(status="success", data=config) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index c77c995..6a5e65b 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -70,9 +70,10 @@ class ActivationConfigUpdate(BaseModel): class SentimentConfigUpdate(BaseModel): """Runtime sentiment LLM config. api_key is write-only; omit/empty to keep the stored key.""" - provider: Literal["openai", "gemini"] | None = None + provider: Literal["openai", "gemini", "deepseek", "xai", "openai_compatible"] | None = None model: str | None = Field(default=None, max_length=100) api_key: str | None = Field(default=None, max_length=400) + base_url: str | None = Field(default=None, max_length=300) class SentimentTestRequest(BaseModel): diff --git a/app/services/sentiment_provider_service.py b/app/services/sentiment_provider_service.py index 73676c1..3a3d9c5 100644 --- a/app/services/sentiment_provider_service.py +++ b/app/services/sentiment_provider_service.py @@ -22,23 +22,43 @@ from app.services.admin_service import update_setting logger = logging.getLogger(__name__) -VALID_PROVIDERS = {"openai", "gemini"} +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 grounds via Live Search (search_parameters); OpenAI/Gemini via their tools. +WEB_SEARCH_PROVIDERS = {"openai", "gemini", "xai"} + +# xAI Live Search: auto mode lets Grok search web + X when the query needs it. +_XAI_SEARCH_PARAMETERS = {"mode": "auto", "return_citations": True} + +# 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" 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]) + SystemSetting.key.in_([KEY_PROVIDER, KEY_MODEL, KEY_API_KEY, KEY_BASE_URL]) ) ) return {s.key: s.value for s in result.scalars().all()} @@ -49,6 +69,10 @@ def _env_key_for(provider: str) -> str: 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 "" @@ -60,36 +84,54 @@ def _env_model_for(provider: str) -> str: 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.""" +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 _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) + 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: - 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" + 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.""" - provider, model, api_key, source = await _resolve(db) + r = await _resolve(db) return { - "provider": provider, - "model": model, - "api_key_configured": bool(api_key), - "api_key_source": source, + "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), } @@ -98,9 +140,9 @@ async def update_sentiment_config( provider: str | None = None, model: str | None = None, api_key: str | None = None, + base_url: 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).""" + """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: @@ -114,6 +156,9 @@ async def update_sentiment_config( 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()) @@ -123,9 +168,10 @@ async def update_sentiment_config( 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. + Raises ProviderError if credentials/base_url are missing. """ - provider, model, api_key, _source = await _resolve(db) + 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}'") @@ -135,20 +181,31 @@ async def build_sentiment_provider(db: AsyncSession): if provider == "gemini": from app.providers.gemini_sentiment import GeminiSentimentProvider return GeminiSentimentProvider(api_key, model) + if provider in {"deepseek", "xai", "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 + if provider == "xai": + return OpenAICompatibleSentimentProvider( + api_key, model, base_url, source="xai", + live_search=True, + extra_body={"search_parameters": _XAI_SEARCH_PARAMETERS}, + ) + 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.""" - provider, model, _key, _source = await _resolve(db) + r = 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, + "provider": r["provider"], + "model": r["model"], "ticker": ticker.strip().upper(), "classification": data.classification, "confidence": data.confidence, @@ -158,7 +215,7 @@ async def test_sentiment_provider(db: AsyncSession, ticker: str = "AAPL") -> dic logger.warning("Sentiment provider test failed: %s", exc) return { "ok": False, - "provider": provider, - "model": model, + "provider": r["provider"], + "model": r["model"], "error": str(exc), } diff --git a/frontend/src/components/admin/SentimentProviderSettings.tsx b/frontend/src/components/admin/SentimentProviderSettings.tsx index 05a9eb8..4ae5d6c 100644 --- a/frontend/src/components/admin/SentimentProviderSettings.tsx +++ b/frontend/src/components/admin/SentimentProviderSettings.tsx @@ -4,7 +4,7 @@ import { useUpdateSentimentSettings, useTestSentimentProvider, } from '../../hooks/useAdmin'; -import { Select } from '../ui/Field'; +import { Dropdown, type DropdownOption } from '../ui/Dropdown'; import { SkeletonTable } from '../ui/Skeleton'; import type { SentimentTestResult } from '../../lib/types'; @@ -14,6 +14,18 @@ const SOURCE_LABEL: Record = { none: 'not configured', }; +const PROVIDER_LABELS: Record = { + openai: 'OpenAI — web search', + gemini: 'Google Gemini — web search', + deepseek: 'DeepSeek — cheap, no web search', + xai: 'xAI Grok — Live Search', + openai_compatible: 'OpenAI-compatible — custom URL', +}; + +function providerLabel(p: string): string { + return PROVIDER_LABELS[p] ?? p; +} + export function SentimentProviderSettings() { const { data, isLoading, isError, error } = useSentimentSettings(); const update = useUpdateSentimentSettings(); @@ -22,12 +34,14 @@ export function SentimentProviderSettings() { const [provider, setProvider] = useState('openai'); const [model, setModel] = useState(''); const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); const [testResult, setTestResult] = useState(null); useEffect(() => { if (data) { setProvider(data.provider); setModel(data.model); + setBaseUrl(data.base_url ?? ''); } }, [data]); @@ -35,10 +49,17 @@ export function SentimentProviderSettings() { if (isError) return

{(error as Error)?.message || 'Failed to load sentiment settings'}

; if (!data) return null; + const grounded = data.web_search_providers ?? ['openai', 'gemini']; + const needsBaseUrl = (data.custom_base_url_providers ?? ['openai_compatible']).includes(provider); + const isGrounded = grounded.includes(provider); + + const providerOptions: DropdownOption[] = data.valid_providers.map((p) => ({ + value: p, + label: providerLabel(p), + })); + const onProviderChange = (next: string) => { setProvider(next); - // Auto-fill the model with the new provider's default unless the user has a - // custom value that isn't the previous provider's default. const defaults = data.default_models; if (!model || Object.values(defaults).includes(model)) { setModel(defaults[next] ?? ''); @@ -50,6 +71,7 @@ export function SentimentProviderSettings() { update.mutate({ provider, model, + ...(needsBaseUrl ? { base_url: baseUrl } : {}), ...(apiKey ? { api_key: apiKey } : {}), }); setApiKey(''); @@ -74,11 +96,7 @@ export function SentimentProviderSettings() {
+ {needsBaseUrl && ( + + )} + + {!isGrounded && ( +
+ ⚠ No live web search. This provider scores sentiment from the model's training knowledge, + not current news — cheaper, but not real-time. OpenAI and Gemini are grounded in live search. +
+ )} +