Add configurable data providers and localize frontend UI

This commit is contained in:
2026-03-15 00:55:12 +08:00
parent 12de93aa30
commit d233a3f55d
38 changed files with 1936 additions and 1038 deletions

View File

@@ -1,29 +1,77 @@
# -*- coding: utf-8 -*-
"""
Centralized Data Source Configuration
"""Centralized data source configuration and fallback ordering."""
Auto-detects and manages data source based on available API keys.
Priority: FINNHUB_API_KEY > FINANCIAL_DATASETS_API_KEY
"""
import os
from dataclasses import dataclass
from typing import Literal, Optional
DataSource = Literal["finnhub", "financial_datasets"]
DataSource = Literal["finnhub", "financial_datasets", "yfinance", "local_csv"]
_KNOWN_SOURCES: tuple[DataSource, ...] = (
"finnhub",
"financial_datasets",
"yfinance",
"local_csv",
)
@dataclass
class DataSourceConfig:
"""Immutable data source configuration"""
"""Resolved data source configuration."""
source: DataSource
api_key: str
sources: list[DataSource]
# Module-level cache for the resolved configuration
_config_cache: Optional[DataSourceConfig] = None
def _parse_enabled_sources() -> list[DataSource]:
"""Parse optional enabled source allowlist from the environment."""
raw_value = os.getenv("ENABLED_DATA_SOURCES", "").strip().lower()
if not raw_value:
return []
enabled: list[DataSource] = []
for item in raw_value.split(","):
candidate = item.strip()
if not candidate or candidate not in _KNOWN_SOURCES:
continue
if candidate not in enabled:
enabled.append(candidate)
return enabled
def _ordered_sources() -> list[DataSource]:
"""Resolve source preference and available fallbacks."""
preferred = os.getenv("FIN_DATA_SOURCE", "").strip().lower()
finnhub_key = os.getenv("FINNHUB_API_KEY", "").strip()
fd_key = os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip()
enabled_sources = _parse_enabled_sources()
wants_yfinance = preferred == "yfinance" or "yfinance" in enabled_sources
available: list[DataSource] = []
if finnhub_key:
available.append("finnhub")
if fd_key:
available.append("financial_datasets")
if wants_yfinance:
available.append("yfinance")
available.append("local_csv")
if enabled_sources:
filtered = [source for source in enabled_sources if source in available]
if filtered:
available = filtered
if preferred in available:
ordered = [preferred]
ordered.extend(source for source in available if source != preferred)
return ordered
return available
def _resolve_config() -> DataSourceConfig:
"""
Resolve data source configuration based on available API keys.
@@ -33,21 +81,22 @@ def _resolve_config() -> DataSourceConfig:
2. FINANCIAL_DATASETS_API_KEY (if set)
3. Raises error if neither is available
"""
# Check for Finnhub API key first (higher priority)
finnhub_key = os.getenv("FINNHUB_API_KEY")
if finnhub_key:
return DataSourceConfig(source="finnhub", api_key=finnhub_key)
# Fallback to Financial Datasets API
fd_key = os.getenv("FINANCIAL_DATASETS_API_KEY")
if fd_key:
return DataSourceConfig(source="financial_datasets", api_key=fd_key)
# No API key available
raise ValueError(
"No API key found. Please set either FINNHUB_API_KEY or "
"FINANCIAL_DATASETS_API_KEY in your .env file.",
)
sources = _ordered_sources()
if "finnhub" in sources:
return DataSourceConfig(
source="finnhub",
api_key=os.getenv("FINNHUB_API_KEY", "").strip(),
sources=sources,
)
if "financial_datasets" in sources:
return DataSourceConfig(
source="financial_datasets",
api_key=os.getenv("FINANCIAL_DATASETS_API_KEY", "").strip(),
sources=sources,
)
if "yfinance" in sources:
return DataSourceConfig(source="yfinance", api_key="", sources=sources)
return DataSourceConfig(source="local_csv", api_key="", sources=sources)
def get_config() -> DataSourceConfig:
@@ -71,6 +120,11 @@ def get_data_source() -> DataSource:
return get_config().source
def get_data_sources() -> list[DataSource]:
"""Get preferred source ordering including fallbacks."""
return get_config().sources
def get_api_key() -> str:
"""Get the API key for the configured data source."""
return get_config().api_key

View File

@@ -1,22 +1,55 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Simple environment config helpers
"""
"""Environment config helpers with light validation and normalization."""
import os
from dataclasses import dataclass
from typing import Optional
FALSEY_ENV_VALUES = {"0", "false", "no", "off"}
PROVIDER_ALIASES = {
"openai_compatible": "OPENAI",
"openai_compat": "OPENAI",
"claude": "ANTHROPIC",
"google": "GEMINI",
"vertex": "GEMINI",
"vertexai": "GEMINI",
}
@dataclass(frozen=True)
class AgentModelConfig:
"""Resolved model config for one agent."""
model_name: str
provider: str
def _get_env_raw(key: str) -> Optional[str]:
value = os.getenv(key)
if value is None:
return None
value = value.strip()
return value or None
def get_env_str(key: str, default: str = "") -> str:
"""Get trimmed string from env."""
value = _get_env_raw(key)
return value if value is not None else default
def get_env_list(key: str, default: list = None) -> list:
"""Get comma-separated list from env"""
value = os.getenv(key, "")
"""Get comma-separated list from env."""
value = _get_env_raw(key)
if not value:
return default or []
return [item.strip() for item in value.split(",") if item.strip()]
def get_env_float(key: str, default: float = 0.0) -> float:
"""Get float from env"""
value = os.getenv(key)
"""Get float from env."""
value = _get_env_raw(key)
if value is None:
return default
try:
@@ -26,11 +59,45 @@ def get_env_float(key: str, default: float = 0.0) -> float:
def get_env_int(key: str, default: int = 0) -> int:
"""Get int from env"""
value = os.getenv(key)
"""Get int from env."""
value = _get_env_raw(key)
if value is None:
return default
try:
return int(value)
except ValueError:
return default
def get_env_bool(key: str, default: bool = False) -> bool:
"""Parse common truthy/falsey env values."""
value = _get_env_raw(key)
if value is None:
return default
return value.lower() not in FALSEY_ENV_VALUES
def canonicalize_model_provider(provider: Optional[str]) -> str:
"""Normalize provider labels to stable uppercase names."""
if not provider:
return "OPENAI"
normalized = provider.strip().lower().replace("-", "_")
normalized = PROVIDER_ALIASES.get(normalized, normalized)
return normalized.upper()
def get_agent_model_config(agent_id: str) -> AgentModelConfig:
"""Resolve model config with agent-specific override and global fallback."""
agent_key = agent_id.upper().replace("-", "_")
model_name = get_env_str(f"AGENT_{agent_key}_MODEL_NAME")
provider = get_env_str(f"AGENT_{agent_key}_MODEL_PROVIDER")
if not model_name:
model_name = get_env_str("MODEL_NAME", "gpt-4o")
if not provider:
provider = get_env_str("MODEL_PROVIDER", "OPENAI")
return AgentModelConfig(
model_name=model_name,
provider=canonicalize_model_provider(provider),
)