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),
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
none: 'not configured',
|
||||
};
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
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<SentimentTestResult | null>(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 <p className="text-sm text-red-400">{(error as Error)?.message || 'Failed to load sentiment settings'}</p>;
|
||||
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() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Provider</span>
|
||||
<Select value={provider} onChange={(e) => onProviderChange(e.target.value)} className="w-full !py-2">
|
||||
{data.valid_providers.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</Select>
|
||||
<Dropdown value={provider} onChange={onProviderChange} options={providerOptions} ariaLabel="Sentiment provider" />
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1">
|
||||
@@ -87,12 +105,33 @@ export function SentimentProviderSettings() {
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={data.default_models[provider] ?? ''}
|
||||
placeholder={data.default_models[provider] || 'model id'}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{needsBaseUrl && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">Base URL</span>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://openrouter.ai/api/v1"
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
/>
|
||||
<span className="text-[11px] text-gray-600">OpenAI-compatible Chat Completions endpoint (OpenRouter, Together, Groq, local Ollama…).</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{!isGrounded && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-2.5 text-xs text-amber-300">
|
||||
⚠ 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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs text-gray-400">API Key</span>
|
||||
<input
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useUpdateTickerUniverseSetting,
|
||||
} from '../../hooks/useAdmin';
|
||||
import type { TickerUniverse } from '../../lib/types';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
|
||||
const UNIVERSE_OPTIONS: Array<{ value: TickerUniverse; label: string }> = [
|
||||
{ value: 'sp500', label: 'S&P 500' },
|
||||
@@ -50,18 +51,11 @@ export function TickerUniverseBootstrap() {
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label className="block space-y-1 md:col-span-2">
|
||||
<span className="text-xs text-gray-400">Default Universe</span>
|
||||
<select
|
||||
<Dropdown
|
||||
value={universe}
|
||||
onChange={(e) => setUniverse(e.target.value as TickerUniverse)}
|
||||
className="w-full input-glass px-3 py-2 text-sm"
|
||||
disabled={isLoading || updateDefault.isPending || bootstrap.isPending}
|
||||
>
|
||||
{UNIVERSE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setUniverse(v as TickerUniverse)}
|
||||
options={UNIVERSE_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-end gap-2 pb-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useUsers, useCreateUser, useUpdateAccess, useResetPassword } from '../../hooks/useAdmin';
|
||||
import { SkeletonTable } from '../ui/Skeleton';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import type { AdminUser } from '../../lib/types';
|
||||
|
||||
export function UserTable() {
|
||||
@@ -53,10 +54,15 @@ export function UserTable() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-gray-400">Role</label>
|
||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="input-glass px-3 py-2 text-sm">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<Dropdown
|
||||
value={newRole}
|
||||
onChange={setNewRole}
|
||||
className="w-32"
|
||||
options={[
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300 pb-1">
|
||||
<input type="checkbox" checked={newAccess} onChange={(e) => setNewAccess(e.target.checked)}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { useToast } from '../ui/Toast';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { Field, Input, Select } from '../ui/Field';
|
||||
import { Field, Input } from '../ui/Field';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import { triggerJob } from '../../api/admin';
|
||||
import type { TradeSetup } from '../../lib/types';
|
||||
import { RECOMMENDATION_ACTION_GLOSSARY, RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
||||
@@ -178,15 +179,17 @@ export function SetupsPanel() {
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Direction" htmlFor="direction">
|
||||
<Select
|
||||
<Dropdown
|
||||
id="direction"
|
||||
value={directionFilter}
|
||||
onChange={(e) => setDirectionFilter(e.target.value as DirectionFilter)}
|
||||
>
|
||||
<option value="both">Both</option>
|
||||
<option value="long">Long</option>
|
||||
<option value="short">Short</option>
|
||||
</Select>
|
||||
onChange={(v) => setDirectionFilter(v as DirectionFilter)}
|
||||
className="w-32"
|
||||
options={[
|
||||
{ value: 'both', label: 'Both' },
|
||||
{ value: 'long', label: 'Long' },
|
||||
{ value: 'short', label: 'Short' },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Min Confidence" htmlFor="min-confidence">
|
||||
<Input
|
||||
@@ -201,18 +204,20 @@ export function SetupsPanel() {
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Recommended Action" htmlFor="action">
|
||||
<Select
|
||||
<Dropdown
|
||||
id="action"
|
||||
value={actionFilter}
|
||||
onChange={(e) => setActionFilter(e.target.value as ActionFilter)}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="LONG_HIGH">{RECOMMENDATION_ACTION_LABELS.LONG_HIGH}</option>
|
||||
<option value="LONG_MODERATE">{RECOMMENDATION_ACTION_LABELS.LONG_MODERATE}</option>
|
||||
<option value="SHORT_HIGH">{RECOMMENDATION_ACTION_LABELS.SHORT_HIGH}</option>
|
||||
<option value="SHORT_MODERATE">{RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE}</option>
|
||||
<option value="NEUTRAL">{RECOMMENDATION_ACTION_LABELS.NEUTRAL}</option>
|
||||
</Select>
|
||||
onChange={(v) => setActionFilter(v as ActionFilter)}
|
||||
className="w-56"
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'LONG_HIGH', label: RECOMMENDATION_ACTION_LABELS.LONG_HIGH },
|
||||
{ value: 'LONG_MODERATE', label: RECOMMENDATION_ACTION_LABELS.LONG_MODERATE },
|
||||
{ value: 'SHORT_HIGH', label: RECOMMENDATION_ACTION_LABELS.SHORT_HIGH },
|
||||
{ value: 'SHORT_MODERATE', label: RECOMMENDATION_ACTION_LABELS.SHORT_MODERATE },
|
||||
{ value: 'NEUTRAL', label: RECOMMENDATION_ACTION_LABELS.NEUTRAL },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<div className="ml-auto">
|
||||
<Button onClick={() => scanMutation.mutate()} loading={scanMutation.isPending}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getIndicator, getEMACross } from '../../api/indicators';
|
||||
import { Select } from '../ui/Field';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import type { IndicatorResult, EMACrossResult } from '../../lib/types';
|
||||
|
||||
const INDICATOR_TYPES = ['ADX', 'EMA', 'RSI', 'ATR', 'volume_profile', 'pivot_points'] as const;
|
||||
@@ -86,16 +86,12 @@ export function IndicatorSelector({ symbol }: IndicatorSelectorProps) {
|
||||
<h3 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Indicators</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
<Dropdown
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType(e.target.value)}
|
||||
className="w-full !py-2.5"
|
||||
>
|
||||
<option value="">Select indicator…</option>
|
||||
{INDICATOR_TYPES.map((type) => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
))}
|
||||
</Select>
|
||||
onChange={setSelectedType}
|
||||
placeholder="Select indicator…"
|
||||
options={INDICATOR_TYPES.map((type) => ({ value: type, label: type }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedType && indicatorQuery.isLoading && (
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: DropdownOption[];
|
||||
id?: string;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully dark, custom dropdown — replaces native <select>, whose popup list
|
||||
* can't be reliably styled (white background on Windows). Button + absolutely
|
||||
* positioned menu, click-outside and Escape to close.
|
||||
*/
|
||||
export function Dropdown({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
id,
|
||||
className = '',
|
||||
ariaLabel,
|
||||
placeholder = 'Select…',
|
||||
}: DropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={`relative ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="input-glass flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm"
|
||||
>
|
||||
<span className={selected ? 'text-gray-200' : 'text-gray-500'}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 text-gray-500 transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M6 8l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute z-50 mt-1 max-h-64 w-full overflow-auto rounded-lg border border-white/[0.1] bg-[#151911] p-1 shadow-2xl shadow-black/50"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<li key={opt.value} role="option" aria-selected={isSelected}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors duration-100 ${
|
||||
isSelected
|
||||
? 'bg-blue-400/15 text-blue-200'
|
||||
: 'text-gray-300 hover:bg-white/[0.06] hover:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.7 5.3a1 1 0 010 1.4l-7.5 7.5a1 1 0 01-1.4 0L3.3 9.7a1 1 0 011.4-1.4l3.3 3.3 6.8-6.8a1 1 0 011.4 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from 'react';
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
@@ -21,11 +21,3 @@ export function Field({ label, htmlFor, children }: FieldProps) {
|
||||
export function Input({ className = '', ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={`input-glass px-3 py-1.5 text-sm ${className}`} {...rest} />;
|
||||
}
|
||||
|
||||
export function Select({ className = '', children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select className={`input-glass px-3 py-1.5 text-sm [&>option]:bg-[#151911] ${className}`} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,10 +165,14 @@ export interface ActivationConfig {
|
||||
export interface SentimentProviderConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
api_key_configured: boolean;
|
||||
api_key_source: 'database' | 'environment' | 'none';
|
||||
web_search: boolean;
|
||||
valid_providers: string[];
|
||||
default_models: Record<string, string>;
|
||||
web_search_providers: string[];
|
||||
custom_base_url_providers: string[];
|
||||
}
|
||||
|
||||
export interface SentimentTestResult {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RankingsTable } from '../components/rankings/RankingsTable';
|
||||
import { WeightsForm } from '../components/rankings/WeightsForm';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Disclosure } from '../components/ui/Disclosure';
|
||||
import { Select } from '../components/ui/Field';
|
||||
import { Dropdown } from '../components/ui/Dropdown';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { Tabs } from '../components/ui/Tabs';
|
||||
@@ -72,16 +72,17 @@ function WatchlistPanel() {
|
||||
<AddTickerForm />
|
||||
<label className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<span>Sort by</span>
|
||||
<Select
|
||||
<Dropdown
|
||||
value={sortMode}
|
||||
onChange={(event) => setSortMode(event.target.value as SortMode)}
|
||||
className="!py-1 !text-xs"
|
||||
>
|
||||
<option value="score_desc">Score (high → low)</option>
|
||||
<option value="score_asc">Score (low → high)</option>
|
||||
<option value="name_asc">Name (A → Z)</option>
|
||||
<option value="name_desc">Name (Z → A)</option>
|
||||
</Select>
|
||||
onChange={(v) => setSortMode(v as SortMode)}
|
||||
className="w-44"
|
||||
options={[
|
||||
{ value: 'score_desc', label: 'Score (high → low)' },
|
||||
{ value: 'score_asc', label: 'Score (low → high)' },
|
||||
{ value: 'name_asc', label: 'Name (A → Z)' },
|
||||
{ value: 'name_desc', label: 'Name (Z → A)' },
|
||||
]}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/useperformance.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
|
||||
@@ -33,7 +33,8 @@ class TestGetConfig:
|
||||
assert config["model"] == "gpt-4o-mini"
|
||||
assert config["api_key_configured"] is False
|
||||
assert config["api_key_source"] == "none"
|
||||
assert set(config["valid_providers"]) == {"openai", "gemini"}
|
||||
assert config["web_search"] is True
|
||||
assert set(config["valid_providers"]) == {"openai", "gemini", "deepseek", "xai", "openai_compatible"}
|
||||
|
||||
async def test_never_returns_raw_key(self, session: AsyncSession):
|
||||
await sps.update_sentiment_config(session, api_key="sk-secret-123")
|
||||
@@ -92,3 +93,38 @@ class TestBuildProvider:
|
||||
await sps.update_sentiment_config(session, provider="openai")
|
||||
provider = await sps.build_sentiment_provider(session)
|
||||
assert type(provider).__name__ == "OpenAISentimentProvider"
|
||||
|
||||
async def test_builds_deepseek_with_fixed_base_url(self, session: AsyncSession):
|
||||
await sps.update_sentiment_config(session, provider="deepseek", api_key="ds-x")
|
||||
provider = await sps.build_sentiment_provider(session)
|
||||
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||
config = await sps.get_sentiment_config(session)
|
||||
assert config["base_url"] == "https://api.deepseek.com"
|
||||
assert config["web_search"] is False
|
||||
|
||||
async def test_builds_xai_with_live_search(self, session: AsyncSession):
|
||||
await sps.update_sentiment_config(session, provider="xai", api_key="xai-x")
|
||||
provider = await sps.build_sentiment_provider(session)
|
||||
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||
# xAI is wired with Live Search enabled
|
||||
assert provider._live_search is True
|
||||
assert provider._extra_body == {"search_parameters": {"mode": "auto", "return_citations": True}}
|
||||
config = await sps.get_sentiment_config(session)
|
||||
assert config["base_url"] == "https://api.x.ai/v1"
|
||||
assert config["web_search"] is True
|
||||
|
||||
async def test_openai_compatible_requires_base_url(self, session: AsyncSession):
|
||||
await sps.update_sentiment_config(session, provider="openai_compatible", api_key="x")
|
||||
with pytest.raises(ProviderError):
|
||||
await sps.build_sentiment_provider(session)
|
||||
|
||||
async def test_openai_compatible_with_base_url(self, session: AsyncSession):
|
||||
await sps.update_sentiment_config(
|
||||
session,
|
||||
provider="openai_compatible",
|
||||
api_key="x",
|
||||
model="llama-3.1-70b",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
provider = await sps.build_sentiment_provider(session)
|
||||
assert type(provider).__name__ == "OpenAICompatibleSentimentProvider"
|
||||
|
||||
Reference in New Issue
Block a user