Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Providers (admin-switchable, no redeploy): - DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together, Groq, local Ollama) via a generic Chat Completions adapter + base_url - xAI Grok with Live Search (search_parameters web+X, citations) — grounded tier alongside OpenAI and Gemini - DeepSeek / generic compatible endpoints are ungrounded (no web search); UI shows an amber warning and labels each provider's grounding - Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY UI: replace native <select> (unstyleable white popup on Windows) with a custom dark Dropdown component everywhere — sentiment provider, scanner filters, market sort, indicators, admin universe, user role. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation>"}}
|
||||
|
||||
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": "<bullish|bearish|neutral>", "confidence": <0-100>, "reasoning": "<brief explanation citing recent news>"}}
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user