Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s

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:
2026-06-13 12:42:04 +02:00
parent d53ed972d1
commit 126c3b3c17
16 changed files with 521 additions and 98 deletions
+4
View File
@@ -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
+1
View File
@@ -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)
+2 -1
View File
@@ -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):
+81 -24
View File
@@ -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),
}