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
+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),
}