# -*- coding: utf-8 -*- """ =================================== A股自选股智能分析系统 - 配置管理模块 =================================== 职责: 1. 使用单例模式管理全局配置 2. 从 .env 文件加载敏感配置 3. 提供类型安全的配置访问接口 """ import json import logging import os import re from functools import lru_cache from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple from urllib.parse import unquote, urlparse from dotenv import load_dotenv, dotenv_values from dataclasses import dataclass, field from src.core.config_manager import unescape_compose_sensitive_env_value from src.report_language import ( is_supported_report_language_value, normalize_report_language, ) from src.notification_routing import parse_notification_route_channels from src.notification_noise import ( NOTIFICATION_SEVERITIES, is_supported_notification_severity, parse_notification_quiet_hours, validate_notification_timezone, ) from src.notification_contracts import ( is_feishu_app_bot_configured, is_feishu_static_configured, ) from src.services.stock_list_parser import split_stock_list from src.llm.backend_registry import ( AUTO_AGENT_BACKEND_ID, GENERATION_ONLY_BACKEND_IDS, LOCAL_CLI_GENERATION_BACKEND_IDS, LITELLM_BACKEND_ID, OPENCODE_CLI_BACKEND_ID, SUPPORTED_AGENT_GENERATION_BACKENDS, SUPPORTED_AGENT_UI_BACKENDS, SUPPORTED_GENERATION_BACKENDS, ) from src.llm.local_cli_backend import ( DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY, DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY, DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES, DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS, MAX_GENERATION_BACKEND_MAX_CONCURRENCY, MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY, MAX_LOCAL_CLI_OUTPUT_BYTES, MAX_LOCAL_CLI_TIMEOUT_SECONDS, ) from src.llm import generation_params as llm_generation_params from src.llm.hermes import ( HERMES_DEFAULT_BASE_URL, HERMES_DEFAULT_MODEL, HERMES_DEFAULT_PROTOCOL, HermesConfigIssue, hermes_blocked_route_candidates, hermes_model_info, is_reserved_hermes_name, parse_hermes_channel, route_identity_candidates, route_deployment_origins, route_has_hermes, ) from src.scheduler import normalize_schedule_times from src.utils.market_review_region import normalize_market_review_region_lenient logger = logging.getLogger(__name__) @dataclass class ConfigIssue: """Structured configuration validation issue with a severity level. Attributes: severity: One of "error", "warning", or "info". message: Human-readable description of the issue. field: The environment variable / config field name most relevant to this issue (empty string when not applicable). """ severity: Literal["error", "warning", "info"] message: str field: str = "" code: str = "" def __str__(self) -> str: # noqa: D105 return self.message _MANAGED_LITELLM_KEY_PROVIDERS = {"gemini", "vertex_ai", "anthropic", "openai", "deepseek"} SUPPORTED_LLM_CHANNEL_PROTOCOLS = ("openai", "anthropic", "gemini", "vertex_ai", "deepseek", "ollama") SUPPORTED_LLM_CHANNEL_API_SURFACES = ("chat_completions", "responses") _FALLBACK_LITELLM_MODEL_PROVIDERS = _MANAGED_LITELLM_KEY_PROVIDERS | set(SUPPORTED_LLM_CHANNEL_PROTOCOLS) | { "minimax", "cohere", "huggingface", "bedrock", "sagemaker", "azure", "replicate", "together_ai", "palm", "text-completion-openai", "command-r", "groq", "cerebras", "fireworks_ai", "friendliai", "openrouter", "xai", } _FALSEY_ENV_VALUES = {"0", "false", "no", "off"} PROMPT_CACHE_DIAGNOSTICS_LEVELS = {"off", "basic", "debug"} SUPPORTED_AGENT_BACKENDS = {"auto", "litellm", "codex_app_server"} TICKFLOW_KLINE_ADJUST_VALUES = {"none", "forward", "backward", "forward_additive", "backward_additive"} # Fallback defaults used when ANSPIRE_API_KEYS is reused as legacy OpenAI-compatible source. # These are compatibility examples; actual availability should be validated by Anspire console/model entitlement. ANSPIRE_LLM_BASE_URL_DEFAULT = "https://open-gateway.anspire.cn/v6" ANSPIRE_LLM_MODEL_DEFAULT = "Doubao-Seed-2.0-lite" def _has_ntfy_topic_endpoint(value: Optional[str]) -> bool: """Return whether an ntfy URL points at a concrete topic endpoint.""" raw_url = (value or "").strip() if not raw_url: return False parsed = urlparse(raw_url) if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: return False return any(unquote(segment).strip() for segment in parsed.path.split("/") if segment) def _has_gotify_base_url(value: Optional[str]) -> bool: """Return whether a Gotify URL points at a server base URL, not /message.""" raw_url = (value or "").strip().rstrip("/") if not raw_url: return False parsed = urlparse(raw_url) if parsed.scheme.lower() not in {"http", "https"} or not parsed.netloc: return False if parsed.query or parsed.fragment: return False path_segments = [segment for segment in parsed.path.split("/") if segment] return not (path_segments and path_segments[-1].lower() == "message") def normalize_tickflow_kline_adjust(value: Optional[str]) -> str: """Normalize TickFlow daily K-line adjustment mode.""" normalized = (value or "none").strip().lower() if normalized in TICKFLOW_KLINE_ADJUST_VALUES: return normalized logger.warning( "Invalid TICKFLOW_KLINE_ADJUST=%r; falling back to none", value, ) return "none" def parse_prompt_cache_diagnostics_level(value: Optional[str]) -> str: """Parse prompt-cache diagnostics level with a conservative fallback.""" normalized = (value or "off").strip().lower() if normalized in PROMPT_CACHE_DIAGNOSTICS_LEVELS: return normalized logger.warning( "Invalid LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL=%r; falling back to off", value, ) return "off" AGENT_MAX_STEPS_DEFAULT = 10 FUNDAMENTAL_STAGE_TIMEOUT_SECONDS_DEFAULT = 8.0 NEWS_STRATEGY_WINDOWS: Dict[str, int] = { "ultra_short": 1, "short": 3, "medium": 7, "long": 30, } @dataclass(frozen=True) class AgentContextCompressionPreset: """Preset values for visible chat history compression.""" trigger_tokens: int protected_turns: int summary_target_tokens: int # P1 reserves this budget for future prompt-size controls; it is not # enforced by the current rolling-summary state table. history_budget_tokens: int AGENT_CONTEXT_COMPRESSION_DEFAULT_PROFILE = "balanced" AGENT_CONTEXT_COMPRESSION_PROFILES: Dict[str, AgentContextCompressionPreset] = { "cost": AgentContextCompressionPreset( trigger_tokens=6000, protected_turns=2, summary_target_tokens=900, history_budget_tokens=4000, ), "balanced": AgentContextCompressionPreset( trigger_tokens=12000, protected_turns=4, summary_target_tokens=1500, history_budget_tokens=8000, ), "long_context_raw_first": AgentContextCompressionPreset( trigger_tokens=24000, protected_turns=6, summary_target_tokens=2600, history_budget_tokens=14000, ), } def parse_env_bool(value: Optional[str], default: bool = False) -> bool: """Parse common truthy/falsey environment-style values.""" if value is None: return default normalized = value.strip().lower() if not normalized: return default return normalized not in _FALSEY_ENV_VALUES def parse_env_int( value: Optional[str], default: int, *, field_name: str, minimum: Optional[int] = None, maximum: Optional[int] = None, ) -> int: """Parse an integer env value with warning + fallback semantics.""" raw_value = value if raw_value is None or not str(raw_value).strip(): parsed = int(default) else: try: parsed = int(str(raw_value).strip()) except (TypeError, ValueError): logger.warning( "%s=%r is not a valid integer; falling back to %s", field_name, raw_value, default, ) parsed = int(default) if minimum is not None and parsed < minimum: logger.warning( "%s=%r is below minimum %s; clamping to %s", field_name, parsed, minimum, minimum, ) parsed = minimum if maximum is not None and parsed > maximum: logger.warning( "%s=%r is above maximum %s; clamping to %s", field_name, parsed, maximum, maximum, ) parsed = maximum return parsed def parse_env_float( value: Optional[str], default: float, *, field_name: str, minimum: Optional[float] = None, maximum: Optional[float] = None, ) -> float: """Parse a float env value with warning + fallback semantics.""" raw_value = value if raw_value is None or not str(raw_value).strip(): parsed = float(default) else: try: parsed = float(str(raw_value).strip()) except (TypeError, ValueError): logger.warning( "%s=%r is not a valid number; falling back to %s", field_name, raw_value, default, ) parsed = float(default) if minimum is not None and parsed < minimum: logger.warning( "%s=%r is below minimum %s; clamping to %s", field_name, parsed, minimum, minimum, ) parsed = minimum if maximum is not None and parsed > maximum: logger.warning( "%s=%r is above maximum %s; clamping to %s", field_name, parsed, maximum, maximum, ) parsed = maximum return parsed def normalize_news_strategy_profile(value: Optional[str]) -> str: """Normalize news strategy profile to known values.""" candidate = (value or "short").strip().lower() return candidate if candidate in NEWS_STRATEGY_WINDOWS else "short" def resolve_news_window_days(news_max_age_days: int, news_strategy_profile: Optional[str]) -> int: """Resolve effective news window days from profile and global max-age.""" profile = normalize_news_strategy_profile(news_strategy_profile) profile_days = NEWS_STRATEGY_WINDOWS.get(profile, NEWS_STRATEGY_WINDOWS["short"]) return max(1, min(max(1, int(news_max_age_days)), profile_days)) def normalize_agent_context_compression_profile(value: Optional[str]) -> str: """Normalize visible-chat context compression profile values.""" candidate = (value or AGENT_CONTEXT_COMPRESSION_DEFAULT_PROFILE).strip().lower() if candidate in AGENT_CONTEXT_COMPRESSION_PROFILES: return candidate logger.warning( "Invalid AGENT_CONTEXT_COMPRESSION_PROFILE=%r; falling back to %s", value, AGENT_CONTEXT_COMPRESSION_DEFAULT_PROFILE, ) return AGENT_CONTEXT_COMPRESSION_DEFAULT_PROFILE def get_agent_context_compression_preset(profile: Optional[str]) -> AgentContextCompressionPreset: """Return the preset for a normalized profile, falling back to balanced.""" normalized = normalize_agent_context_compression_profile(profile) return AGENT_CONTEXT_COMPRESSION_PROFILES[normalized] def parse_agent_context_compression_int( value: Optional[str], default: int, *, field_name: str, minimum: int, maximum: int, ) -> int: """Parse compression integers; empty/invalid/out-of-range values follow preset defaults.""" raw_value = value if raw_value is None or not str(raw_value).strip(): return int(default) try: parsed = int(str(raw_value).strip()) except (TypeError, ValueError): logger.warning( "%s=%r is not a valid integer; falling back to preset default %s", field_name, raw_value, default, ) return int(default) if parsed < minimum or parsed > maximum: logger.warning( "%s=%r is outside supported range [%s, %s]; falling back to preset default %s", field_name, parsed, minimum, maximum, default, ) return int(default) return parsed def canonicalize_llm_channel_protocol(value: Optional[str]) -> str: """Normalize a protocol label into a LiteLLM provider identifier.""" candidate = (value or "").strip().lower().replace("-", "_") aliases = { "openai_compatible": "openai", "openai_compat": "openai", "claude": "anthropic", "google": "gemini", "vertex": "vertex_ai", "vertexai": "vertex_ai", } return aliases.get(candidate, candidate) def canonicalize_llm_channel_api_surface(value: Optional[str]) -> str: """Normalize an LLM channel endpoint surface label.""" candidate = (value or "").strip().lower().replace("-", "_") aliases = { "chat": "chat_completions", "chat_completion": "chat_completions", "completions": "chat_completions", "response": "responses", "responses_api": "responses", } return aliases.get(candidate, candidate) def normalize_llm_channel_api_surface(value: Optional[str]) -> str: """Return a supported endpoint surface, defaulting to Chat Completions.""" normalized = canonicalize_llm_channel_api_surface(value) if normalized in SUPPORTED_LLM_CHANNEL_API_SURFACES: return normalized return "chat_completions" def is_supported_llm_channel_api_surface_value(value: Optional[str]) -> bool: """Return whether a raw API surface is blank or recognized.""" canonical = canonicalize_llm_channel_api_surface(value) return not canonical or canonical in SUPPORTED_LLM_CHANNEL_API_SURFACES @lru_cache(maxsize=1) def get_litellm_model_providers() -> frozenset[str]: """Return provider identifiers from the installed LiteLLM routing enum. LiteLLM adds direct providers independently of this repository. Loading its enum keeps channel validation aligned with the actual router instead of relying on a permanently incomplete local allow-list. The fallback is only for lightweight test stubs or a broken optional import; a production installation gets the complete provider set from its pinned LiteLLM. """ providers = set(_FALLBACK_LITELLM_MODEL_PROVIDERS) try: from litellm.types.utils import LlmProviders providers.update( str(provider.value).strip().lower() for provider in LlmProviders if str(getattr(provider, "value", "")).strip() ) except (ImportError, AttributeError, TypeError): logger.debug("LiteLLM provider metadata unavailable; using the compatibility fallback") return frozenset(providers) def get_explicit_llm_channel_model_provider(model: str) -> str: """Return the explicit LiteLLM provider prefix, if the model has one. A slash alone does not establish a provider: OpenAI-compatible gateways commonly expose provider-owned IDs such as ``Qwen/Qwen3`` or ``deepseek-ai/DeepSeek-V3``. Only prefixes understood as LiteLLM providers are treated as routing declarations. """ normalized_model = (model or "").strip() if "/" not in normalized_model: return "" raw_prefix = normalized_model.split("/", 1)[0].lower() canonical_prefix = canonicalize_llm_channel_protocol(raw_prefix) providers = get_litellm_model_providers() if raw_prefix in providers: return raw_prefix if canonical_prefix in providers: return canonical_prefix return "" def apply_litellm_api_surface(model: str, api_surface: Optional[str]) -> str: """Encode an explicit API surface in a LiteLLM wire model. LiteLLM's ``provider/responses/model`` convention keeps the public Router alias stable while letting ``completion()`` bridge messages, streaming, tools, responses, and usage through the provider's Responses endpoint. """ normalized_model = (model or "").strip() if not normalized_model or normalize_llm_channel_api_surface(api_surface) != "responses": return normalized_model provider = get_explicit_llm_channel_model_provider(normalized_model) if provider != "openai": raise ValueError( "Responses API surface requires a normalized openai/ route; " f"got {normalized_model!r}" ) provider, remainder = normalized_model.split("/", 1) if remainder.startswith("responses/"): return normalized_model return f"{provider}/responses/{remainder}" def resolve_llm_channel_protocol( protocol: Optional[str], *, base_url: Optional[str] = None, models: Optional[List[str]] = None, channel_name: Optional[str] = None, ) -> str: """Resolve the effective protocol for a channel.""" explicit = canonicalize_llm_channel_protocol(protocol) if explicit in SUPPORTED_LLM_CHANNEL_PROTOCOLS: return explicit for model in models or []: if "/" not in model: continue prefix = canonicalize_llm_channel_protocol(model.split("/", 1)[0]) if prefix in SUPPORTED_LLM_CHANNEL_PROTOCOLS: return prefix # Infer from channel name (e.g. "deepseek" -> deepseek, "gemini" -> gemini) if channel_name: name_protocol = canonicalize_llm_channel_protocol(channel_name) if name_protocol in SUPPORTED_LLM_CHANNEL_PROTOCOLS: return name_protocol if base_url: parsed = urlparse(base_url) if parsed.hostname in {"127.0.0.1", "localhost", "0.0.0.0"}: # Default to openai for local servers (vLLM, LM Studio, LocalAI, etc.). # Ollama users should set PROTOCOL=ollama explicitly or name the channel "ollama". return "openai" return "openai" return "" def channel_allows_empty_api_key(protocol: Optional[str], base_url: Optional[str]) -> bool: """Return True when a channel can run without an API key.""" resolved_protocol = resolve_llm_channel_protocol(protocol, base_url=base_url) if resolved_protocol == "ollama": return True parsed = urlparse(base_url or "") return parsed.hostname in {"127.0.0.1", "localhost", "0.0.0.0"} def normalize_llm_channel_model(model: str, protocol: Optional[str], base_url: Optional[str] = None) -> str: """Attach a provider prefix when the model omits it.""" normalized_model = model.strip() if not normalized_model: return normalized_model resolved_protocol = resolve_llm_channel_protocol(protocol, base_url=base_url, models=[normalized_model]) if "/" in normalized_model: # The model already has a slash, e.g. 'deepseek-ai/DeepSeek-V3'. # Check if the prefix is a known LiteLLM provider; if so, keep it. # Otherwise (e.g. HuggingFace-style IDs on SiliconFlow), prepend # the resolved protocol so LiteLLM routes via the correct handler. raw_prefix, remainder = normalized_model.split("/", 1) prefix = raw_prefix.lower() canonical_prefix = canonicalize_llm_channel_protocol(prefix) providers = get_litellm_model_providers() if prefix in providers: return normalized_model if canonical_prefix in providers: return f"{canonical_prefix}/{remainder}" # Not a real provider prefix — add one so LiteLLM routes correctly. if resolved_protocol: return f"{resolved_protocol}/{normalized_model}" return normalized_model if not resolved_protocol: return normalized_model return f"{resolved_protocol}/{normalized_model}" def find_incompatible_llm_channel_models( models: List[str], protocol: Optional[str], api_surface: Optional[str], base_url: Optional[str] = None, ) -> List[str]: """Return models whose actual LiteLLM route conflicts with the surface. Responses routing is implemented through LiteLLM's OpenAI bridge, so both the channel protocol and every normalized model route must resolve to the OpenAI provider. This is the shared invariant used by validation, runtime loading, diagnostics, and screening. """ if normalize_llm_channel_api_surface(api_surface) != "responses": return [] resolved_protocol = resolve_llm_channel_protocol( protocol, base_url=base_url, models=models, ) if resolved_protocol != "openai": return [model for model in models if (model or "").strip()] incompatible: List[str] = [] for model in models: normalized_model = normalize_llm_channel_model(model, resolved_protocol, base_url) if normalized_model and get_explicit_llm_channel_model_provider(normalized_model) != "openai": incompatible.append(model) return incompatible def find_llm_channel_surface_conflicts( channels: List[Dict[str, Any]], ) -> Dict[str, Tuple[str, ...]]: """Return public route aliases declared with more than one API surface.""" route_surfaces: Dict[str, set[str]] = {} for channel in channels: if not isinstance(channel, dict) or not channel.get("enabled", True): continue protocol = str(channel.get("protocol") or "") base_url = str(channel.get("base_url") or "") surface = normalize_llm_channel_api_surface(channel.get("api_surface")) for raw_model in channel.get("models") or []: model = normalize_llm_channel_model(str(raw_model), protocol, base_url) if model: route_surfaces.setdefault(model, set()).add(surface) return { model: tuple(sorted(surfaces)) for model, surfaces in route_surfaces.items() if len(surfaces) > 1 } def get_configured_llm_models(model_list: List[Dict[str, Any]]) -> List[str]: """Return non-legacy model names declared in Router model_list order. Uses the top-level ``model_name`` (the routing alias that users set in LITELLM_MODEL) rather than ``litellm_params.model`` (the wire-level model identifier). For channel-built entries both are identical, but YAML configs may define a friendly alias that differs from the underlying provider/model path. """ models: List[str] = [] seen: set = set() for entry in model_list or []: # Prefer top-level model_name (router routing key); fall back to # litellm_params.model for entries that omit it. name = str(entry.get("model_name") or "").strip() if not name: params = entry.get("litellm_params", {}) or {} name = str(params.get("model") or "").strip() if not name or name.startswith("__legacy_") or name in seen: continue seen.add(name) models.append(name) return models def resolve_litellm_wire_model( model: str, model_list: Optional[List[Dict[str, Any]]] = None, ) -> str: """Resolve a router alias to its underlying LiteLLM wire model.""" return llm_generation_params.resolve_litellm_wire_model(model, model_list) def resolve_litellm_thinking_enabled( model: str, model_list: Optional[List[Dict[str, Any]]] = None, request_overrides: Optional[Dict[str, Any]] = None, ) -> Optional[bool]: """Resolve whether the outgoing LiteLLM request explicitly enables thinking.""" return llm_generation_params.resolve_litellm_thinking_enabled( model, model_list=model_list, request_overrides=request_overrides, ) def get_fixed_litellm_temperature( model: str, model_list: Optional[List[Dict[str, Any]]] = None, request_overrides: Optional[Dict[str, Any]] = None, ) -> Optional[float]: """Return a provider-mandated temperature for known strict models.""" return llm_generation_params.get_fixed_litellm_temperature( model, model_list=model_list, request_overrides=request_overrides, ) def normalize_litellm_temperature( model: str, temperature: Optional[float], *, default: float = 0.7, model_list: Optional[List[Dict[str, Any]]] = None, request_overrides: Optional[Dict[str, Any]] = None, ) -> float: """Normalize temperature before sending a LiteLLM request.""" return llm_generation_params.normalize_litellm_temperature( model, temperature, default=default, model_list=model_list, request_overrides=request_overrides, ) def resolve_unified_llm_temperature(model: str) -> float: """Resolve the raw unified LLM temperature with backward-compatible fallbacks.""" llm_temperature_raw = os.getenv("LLM_TEMPERATURE") if llm_temperature_raw and llm_temperature_raw.strip(): try: return float(llm_temperature_raw) except (ValueError, TypeError): pass provider_temperature_env = { "gemini": "GEMINI_TEMPERATURE", "vertex_ai": "GEMINI_TEMPERATURE", "anthropic": "ANTHROPIC_TEMPERATURE", "openai": "OPENAI_TEMPERATURE", "deepseek": "OPENAI_TEMPERATURE", } preferred_env = provider_temperature_env.get(_get_litellm_provider(model)) if preferred_env: preferred_value = os.getenv(preferred_env) if preferred_value and preferred_value.strip(): try: return float(preferred_value) except (ValueError, TypeError): pass for env_name in ("GEMINI_TEMPERATURE", "ANTHROPIC_TEMPERATURE", "OPENAI_TEMPERATURE"): env_value = os.getenv(env_name) if env_value and env_value.strip(): try: return float(env_value) except (ValueError, TypeError): continue return 0.7 def _get_litellm_provider(model: str) -> str: """Extract the LiteLLM provider prefix from a model string.""" if not model: return "" if "/" in model: return model.split("/", 1)[0] return "openai" def _uses_direct_env_provider(model: str) -> bool: """Whether runtime handles the model via direct litellm env/provider resolution.""" provider = _get_litellm_provider(model) return bool(provider) and provider not in _MANAGED_LITELLM_KEY_PROVIDERS def _matches_route_set(model: str, routes: set[str]) -> bool: """Loose safety match for Hermes/provenance checks, not normal route availability.""" return bool(route_identity_candidates(model) & set(routes or set())) def _matches_exact_route(model: str, routes: set[str]) -> bool: """Match the Router's top-level model_name exactly for normal availability checks.""" normalized_model = str(model or "").strip() return bool(normalized_model) and normalized_model in set(routes or set()) def normalize_agent_litellm_model( model: str, configured_models: Optional[set[str]] = None, ) -> str: """Normalize AGENT_LITELLM_MODEL while preserving configured router aliases.""" normalized_model = (model or "").strip() if not normalized_model: return "" if "/" not in normalized_model: if configured_models and normalized_model in configured_models: return normalized_model return f"openai/{normalized_model}" return normalized_model def get_effective_agent_primary_model(config: "Config") -> str: """Return the effective Agent primary model with fallback inheritance.""" configured_router_models = set( get_configured_llm_models(getattr(config, "llm_model_list", []) or []) ) configured_agent_model = normalize_agent_litellm_model( getattr(config, "agent_litellm_model", ""), configured_models=configured_router_models, ) if configured_agent_model: return configured_agent_model return (getattr(config, "litellm_model", "") or "").strip() def get_effective_agent_models_to_try(config: "Config") -> List[str]: """Return Agent model try-order: primary + global fallbacks (deduped).""" configured_router_models = set( get_configured_llm_models(getattr(config, "llm_model_list", []) or []) ) raw_models = [get_effective_agent_primary_model(config)] + ( getattr(config, "litellm_fallback_models", []) or [] ) seen = set() ordered_models: List[str] = [] for model in raw_models: normalized_model = (model or "").strip() if not normalized_model: continue dedupe_key = normalize_agent_litellm_model( normalized_model, configured_models=configured_router_models, ) if dedupe_key in seen: continue seen.add(dedupe_key) ordered_models.append(normalized_model) return ordered_models def setup_env(override: bool = False): """ Initialize environment variables from .env file. Args: override: If True, overwrite existing environment variables with values from .env file. Set to True when reloading config after updates. Default is False to preserve behavior on initial load where system environment variables take precedence. """ Config._capture_bootstrap_runtime_env_overrides() # src/config.py -> src/ -> root env_file = os.getenv("ENV_FILE") if env_file: env_path = Path(env_file) else: env_path = Path(__file__).parent.parent / '.env' compose_sensitive_keys = ("CUSTOM_WEBHOOK_BODY_TEMPLATE",) preexisting_compose_sensitive_keys = { key for key in compose_sensitive_keys if key in os.environ } load_dotenv(dotenv_path=env_path, override=override) try: raw_env_values = dotenv_values(env_path, interpolate=False) except Exception as exc: # pragma: no cover - defensive branch logger.warning("Failed to read raw .env values from %s: %s", env_path, exc) return key = "CUSTOM_WEBHOOK_BODY_TEMPLATE" if key in raw_env_values and ( override or key not in preexisting_compose_sensitive_keys ): raw_value = raw_env_values.get(key) os.environ[key] = unescape_compose_sensitive_env_value( key, "" if raw_value is None else str(raw_value), ) @dataclass class Config: """ 系统配置类 - 单例模式 设计说明: - 使用 dataclass 简化配置属性定义 - 所有配置项从环境变量读取,支持默认值 - 类方法 get_instance() 实现单例访问 """ # === 自选股配置 === stock_list: List[str] = field(default_factory=list) # === 飞书云文档配置 === feishu_app_id: Optional[str] = None feishu_app_secret: Optional[str] = None feishu_folder_token: Optional[str] = None # 目标文件夹 Token # === 数据源 API Token === tushare_token: Optional[str] = None tickflow_api_key: Optional[str] = None tickflow_kline_adjust: str = "none" tickflow_priority: int = 2 tickflow_batch_daily_enabled: bool = True tickflow_batch_size: int = 100 finnhub_api_key: Optional[str] = None alphavantage_api_key: Optional[str] = None longbridge_app_key: Optional[str] = None longbridge_app_secret: Optional[str] = None longbridge_access_token: Optional[str] = None longbridge_oauth_client_id: Optional[str] = None stock_index_remote_update_enabled: bool = True # === Built-in stock screening === screening_enabled: bool = False # === AI 分析配置 === generation_backend: str = LITELLM_BACKEND_ID generation_fallback_backend: str = LITELLM_BACKEND_ID generation_backend_timeout_seconds: int = DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS generation_backend_max_output_bytes: int = DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES generation_backend_max_concurrency: int = DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY local_cli_backend_max_concurrency: int = DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY opencode_cli_model: str = "" # LiteLLM unified model config (provider/model format, e.g. gemini/gemini-3.1-pro-preview) litellm_model: str = "" # Primary model; must include provider prefix when set explicitly litellm_fallback_models: List[str] = field(default_factory=list) # Cross-model fallback list # Unified temperature for all LLM calls (LLM_TEMPERATURE); legacy per-provider temps are fallback only llm_temperature: float = 0.7 # Provider prompt-cache controls. These do not control provider implicit cache. llm_prompt_cache_telemetry_enabled: bool = True llm_prompt_cache_hints_enabled: bool = False llm_prompt_cache_diagnostics_level: str = "off" # --- Multi-channel LLM config (new) --- # LITELLM_CONFIG: path to a standard litellm_config.yaml file (most powerful) litellm_config_path: Optional[str] = None # Internal metadata: which config layer actually produced llm_model_list llm_models_source: str = "legacy_env" # LLM_CHANNELS: list of channel dicts, each with name/base_url/api_keys/models llm_channels: List[Dict[str, Any]] = field(default_factory=list) # Raw channel names requested through LLM_CHANNELS, including channels that # were skipped during parsing because required channel fields were missing. llm_channel_names: List[str] = field(default_factory=list) # Structured parse issues raised while turning LLM_CHANNELS into deployments. llm_channel_config_issues: List[Dict[str, str]] = field(default_factory=list) # True when invalid explicit channel config must prevent legacy key inference. llm_blocks_legacy_fallback: bool = False # Canonical Hermes route names that were requested but blocked by atomic parse issues. llm_blocked_hermes_routes: List[str] = field(default_factory=list) # Pre-built LiteLLM Router model_list (populated from channels, YAML, or legacy keys) llm_model_list: List[Dict[str, Any]] = field(default_factory=list) # Multi-key support: each list is parsed from *_API_KEYS (comma-separated) with single-key fallback gemini_api_keys: List[str] = field(default_factory=list) anthropic_api_keys: List[str] = field(default_factory=list) openai_api_keys: List[str] = field(default_factory=list) deepseek_api_keys: List[str] = field(default_factory=list) # Legacy single-key fields (kept for backward compatibility; gemini_api_keys[0] when set) gemini_api_key: Optional[str] = None gemini_model: str = "gemini-3.1-pro-preview" # 主模型 gemini_model_fallback: str = "gemini-3-flash-preview" # 备选模型 gemini_temperature: float = 0.7 # 温度参数(0.0-2.0,控制输出随机性,默认0.7) # Gemini API 请求配置(防止 429 限流) gemini_request_delay: float = 2.0 # 请求间隔(秒) gemini_max_retries: int = 5 # 最大重试次数 gemini_retry_delay: float = 5.0 # 重试基础延时(秒) # Anthropic Claude API(备选,当 Gemini 不可用时使用) anthropic_api_key: Optional[str] = None anthropic_model: str = "claude-sonnet-4-6" # Claude model name anthropic_temperature: float = 0.7 # Anthropic temperature (0.0-1.0, default 0.7) anthropic_max_tokens: int = 8192 # Max tokens for Anthropic responses # OpenAI 兼容 API(备选,当 Gemini/Anthropic 不可用时使用) openai_api_key: Optional[str] = None openai_base_url: Optional[str] = None # 如: https://api.openai.com/v1 openai_model: str = "gpt-5.5" # OpenAI 兼容模型名称 openai_vision_model: Optional[str] = None # Deprecated: use VISION_MODEL instead openai_temperature: float = 0.7 # OpenAI 温度参数(0.0-2.0,默认0.7) # === Vision 配置 === # VISION_MODEL: litellm model string used for image understanding calls. # Fallback chain: VISION_MODEL → OPENAI_VISION_MODEL → gemini/gemini-2.0-flash vision_model: str = "" # VISION_PROVIDER_PRIORITY: comma-separated provider order for Vision fallback. vision_provider_priority: str = "gemini,anthropic,openai" # === 搜索引擎配置(支持多 Key 负载均衡)=== anspire_api_keys: List[str] = field(default_factory=list) # Anspire Search API Keys bocha_api_keys: List[str] = field(default_factory=list) # Bocha API Keys minimax_api_keys: List[str] = field(default_factory=list) # MiniMax API Keys tavily_api_keys: List[str] = field(default_factory=list) # Tavily API Keys brave_api_keys: List[str] = field(default_factory=list) # Brave Search API Keys serpapi_keys: List[str] = field(default_factory=list) # SerpAPI Keys searxng_base_urls: List[str] = field(default_factory=list) # SearXNG instance URLs (self-hosted, no quota) searxng_public_instances_enabled: bool = False # Opt in to public discovery when base URLs are absent # === Social Sentiment (US stocks only, api.adanos.org) === social_sentiment_api_key: Optional[str] = None social_sentiment_api_url: str = "https://api.adanos.org" # === 新闻与分析筛选配置 === news_max_age_days: int = 3 # 新闻最大时效(天) news_strategy_profile: str = "short" # 新闻窗口策略档位:ultra_short/short/medium/long news_intel_retention_days: int = 30 # 本地资讯池保留天数 news_intel_fetch_timeout_sec: float = 8.0 # 单个资讯源拉取超时 news_intel_max_items_per_source: int = 50 # 单次每个资讯源最多采集条数 news_intel_auto_fetch_enabled: bool = False # 是否在分析前自动初始化并拉取本地资讯源 newsnow_base_url: str = "https://newsnow.busiyi.world" # NewsNow HTTP API base URL (数据源侧,不影响 LLM/provider base URL) bias_threshold: float = 5.0 # 乖离率阈值(%),超过此值提示不追高 # === Agent 模式配置 === agent_backend: str = "auto" agent_generation_backend: str = AUTO_AGENT_BACKEND_ID agent_litellm_model: str = "" # Optional Agent-only primary model; empty inherits LITELLM_MODEL agent_mode: bool = False _agent_mode_explicit: bool = False # True when AGENT_MODE was explicitly set in env agent_max_steps: int = AGENT_MAX_STEPS_DEFAULT agent_skills: List[str] = field(default_factory=list) agent_skill_dir: Optional[str] = None agent_nl_routing: bool = False # Enable natural language routing in bot dispatcher agent_arch: str = "single" # Agent architecture: 'single' (legacy) or 'multi' (orchestrator) agent_orchestrator_mode: str = "standard" # Orchestrator mode: quick/standard/full/specialist agent_orchestrator_timeout_s: int = 600 # Cooperative timeout budget for the whole multi-agent pipeline agent_technical_agent_timeout_s: float = 0 agent_intel_agent_timeout_s: float = 0 agent_risk_agent_timeout_s: float = 0 agent_decision_agent_timeout_s: float = 0 agent_portfolio_agent_timeout_s: float = 0 agent_skill_agent_timeout_s: float = 0 # Per-category default timeouts for agent tool calls (seconds). # 0 / unset means "no category default" -> falls back to the global # tool_call_timeout_seconds budget. agent_data_tool_timeout_s: float = 0.0 agent_search_tool_timeout_s: float = 0.0 agent_analysis_tool_timeout_s: float = 0.0 agent_action_tool_timeout_s: float = 0.0 agent_skill_concurrency: int = 3 agent_risk_override: bool = True # Allow risk agent to veto buy signals agent_deep_research_budget: int = 30000 # Max token budget for deep research agent_deep_research_timeout: int = 180 # Max seconds for /research command before returning timeout agent_memory_enabled: bool = False # Enable memory & calibration system agent_skill_autoweight: bool = True # Weight skills by attributable Outcome performance agent_skill_routing: str = "auto" # Skill routing: 'auto' (regime-based) or 'manual' agent_context_compression_enabled: bool = False # Compress visible chat history before Agent calls agent_context_compression_profile: str = AGENT_CONTEXT_COMPRESSION_DEFAULT_PROFILE agent_context_compression_trigger_tokens: int = 12000 agent_context_protected_turns: int = 4 agent_event_monitor_enabled: bool = False # Enable periodic event-driven alert checks in schedule mode agent_event_monitor_interval_minutes: int = 5 # Polling interval for event monitor background checks agent_event_alert_rules_json: str = "" # JSON array of serialized EventMonitor rules # === 通知配置(可同时配置多个,全部推送)=== # 企业微信 Webhook wechat_webhook_url: Optional[str] = None # 飞书 Webhook feishu_webhook_url: Optional[str] = None feishu_webhook_secret: Optional[str] = None # 自定义机器人签名密钥(可选) feishu_webhook_keyword: Optional[str] = None # 自定义机器人关键词(可选) dingtalk_webhook_url: Optional[str] = None dingtalk_secret: Optional[str] = None # 飞书应用机器人(App Bot)通知 feishu_chat_id: Optional[str] = None # 目标群会话 chat_id(群聊模式),或用户 open_id(P2P 模式) feishu_receive_id_type: str = "chat_id" # 接收者 ID 类型: "chat_id"(群聊) / "open_id"(私聊) feishu_domain: str = "feishu" # 飞书域名: "feishu"(feishu.cn) / "lark"(larksuite.com) # Telegram 配置(需要同时配置 Bot Token 和 Chat ID) telegram_bot_token: Optional[str] = None # Bot Token(@BotFather 获取) telegram_chat_id: Optional[str] = None # Chat ID telegram_message_thread_id: Optional[str] = None # Topic ID (Message Thread ID) for groups # 邮件配置(只需邮箱和授权码,SMTP 自动识别) email_sender: Optional[str] = None # 发件人邮箱 email_sender_name: str = "daily_stock_analysis股票分析助手" # 发件人显示名称 email_password: Optional[str] = None # 邮箱密码/授权码 email_receivers: List[str] = field(default_factory=list) # 收件人列表(留空则发给自己) # Stock-to-email group routing (Issue #268): STOCK_GROUP_N + EMAIL_GROUP_N # When configured, each group's report is sent to that group's emails only. stock_email_groups: List[Tuple[List[str], List[str]]] = field(default_factory=list) # Pushover 配置(手机/桌面推送通知) pushover_user_key: Optional[str] = None # 用户 Key(https://pushover.net 获取) pushover_api_token: Optional[str] = None # 应用 API Token # ntfy 配置(完整 topic endpoint,例如 https://ntfy.sh/my-topic) ntfy_url: Optional[str] = None ntfy_token: Optional[str] = None # Gotify 配置(server base URL;sender 会拼接 /message) gotify_url: Optional[str] = None gotify_token: Optional[str] = None # 自定义 Webhook(支持多个,逗号分隔) # 适用于:钉钉、Discord、Slack、自建服务等任意支持 POST JSON 的 Webhook custom_webhook_urls: List[str] = field(default_factory=list) custom_webhook_bearer_token: Optional[str] = None # Bearer Token(用于需要认证的 Webhook) custom_webhook_body_template: Optional[str] = None # 自定义 Webhook JSON body 模板 webhook_verify_ssl: bool = True # Webhook HTTPS 证书校验,false 可支持自签名(有 MITM 风险) # Discord 通知配置 discord_bot_token: Optional[str] = None # Discord Bot Token discord_main_channel_id: Optional[str] = None # Discord 主频道 ID discord_webhook_url: Optional[str] = None # Discord Webhook URL discord_interactions_public_key: Optional[str] = None # Discord Interaction 入站验签公钥 # Slack 通知配置 slack_webhook_url: Optional[str] = None # Slack Incoming Webhook URL slack_bot_token: Optional[str] = None # Slack Bot Token (xoxb-...) slack_channel_id: Optional[str] = None # Slack 频道 ID (Bot 模式必填) # AstrBot 通知配置 astrbot_token: Optional[str] = None astrbot_url: Optional[str] = None # 通知路由策略(Issue #1200 P3):留空表示该类型使用全部已配置渠道 notification_report_channels: List[str] = field(default_factory=list) notification_alert_channels: List[str] = field(default_factory=list) notification_system_error_channels: List[str] = field(default_factory=list) # 通知降噪机制(Issue #1200 P4):默认全部关闭,仅对静态通知渠道生效 notification_dedup_ttl_seconds: int = 0 notification_cooldown_seconds: int = 0 notification_quiet_hours: str = "" notification_timezone: str = "" notification_min_severity: str = "" notification_daily_digest_enabled: bool = False # 单股推送模式:每分析完一只股票立即推送,而不是汇总后推送 single_stock_notify: bool = False # 报告类型:simple(精简) 或 full(完整) report_type: str = "simple" report_language: str = "zh" # 仅分析结果摘要:true 时只推送汇总,不含个股详情(Issue #262) report_summary_only: bool = False report_show_llm_model: bool = True # Report Engine P0: Jinja2 renderer and integrity checks report_templates_dir: str = "templates" # Template directory (relative to project root) report_renderer_enabled: bool = False # Enable Jinja2 rendering (default off for zero regression) report_integrity_enabled: bool = True # Content integrity validation after LLM output report_integrity_retry: int = 1 # Retry count when mandatory fields missing (0 = placeholder only) report_history_compare_n: int = 0 # History comparison count (0 = disabled) # PushPlus 推送配置 pushplus_token: Optional[str] = None # PushPlus Token pushplus_topic: Optional[str] = None # PushPlus 群组编码(一对多推送) # Server酱3 推送配置 serverchan3_sendkey: Optional[str] = None # Server酱3 SendKey # 分析间隔时间(秒)- 用于避免API限流 analysis_delay: float = 0.0 # 个股分析与大盘分析之间的延迟 # Merge stock + market report into one notification (Issue #190) merge_email_notification: bool = False # 消息长度限制(字节)- 超长自动分批发送 feishu_max_bytes: int = 20000 # 飞书限制约 20KB,默认 20000 字节 feishu_send_as_file: bool = False # 飞书是否以文件形式发送报告(默认文字消息) wechat_max_bytes: int = 4000 # 企业微信限制 4096 字节,默认 4000 字节 discord_max_words: int = 2000 # Discord 限制 2000 字,默认 2000 字 wechat_msg_type: str = "markdown" # 企业微信消息类型,默认 markdown 类型 # Markdown 转图片(Issue #289):对不支持 Markdown 的渠道以图片发送 markdown_to_image_channels: List[str] = field(default_factory=list) # 逗号分隔:telegram,wechat,custom,email markdown_to_image_max_chars: int = 15000 # 超过此长度不转换,避免超大图片 md2img_engine: str = "wkhtmltoimage" # wkhtmltoimage | markdown-to-file | playwright share_image_xiaohongshu_url: Optional[str] = None share_image_xiaohongshu_handle: Optional[str] = None share_image_xiaohongshu_id: Optional[str] = None share_image_xiaohongshu_qr_path: Optional[str] = None # 实时行情预取(Issue #455):设为 false 可禁用,避免 efinance/akshare_em 全市场拉取 prefetch_realtime_quotes: bool = True # === 数据库配置 === database_path: str = "./data/stock_analysis.db" sqlite_wal_enabled: bool = True sqlite_busy_timeout_ms: int = 5000 sqlite_write_retry_max: int = 3 sqlite_write_retry_base_delay: float = 0.1 # 是否保存分析上下文快照(用于历史回溯) save_context_snapshot: bool = True # === 回测配置 === backtest_enabled: bool = True backtest_eval_window_days: int = 10 backtest_min_age_days: int = 14 backtest_engine_version: str = "v1" backtest_neutral_band_pct: float = 2.0 # === 日志配置 === log_dir: str = "./logs" # 日志文件目录 log_level: str = "INFO" # 日志级别 # === 系统配置 === max_workers: int = 3 # 低并发防封禁 debug: bool = False http_proxy: Optional[str] = None # HTTP 代理 (例如: http://127.0.0.1:10809) https_proxy: Optional[str] = None # HTTPS 代理 # === 定时任务配置 === schedule_enabled: bool = False # 是否启用定时任务 schedule_time: str = "18:00" # 每日推送时间(HH:MM 格式) schedule_times: List[str] = field(default_factory=lambda: ["18:00"]) schedule_run_immediately: bool = True # 启动时是否立即执行一次 run_immediately: bool = True # 启动时是否立即执行一次(非定时模式) market_review_enabled: bool = True # 是否启用大盘复盘 daily_market_context_enabled: bool = True # 是否将大盘环境摘要用于个股分析 Prompt 与保守护栏 # 大盘复盘市场区域:cn(A股)、hk(港股)、us(美股)、jp(日股)、kr(韩股)、both(全部市场) market_review_region: str = "cn" market_review_color_scheme: str = "green_up" # 交易日检查:默认启用,非交易日跳过执行;设为 false 或 --force-run 可强制执行(Issue #373) trading_day_check_enabled: bool = True # === 实时行情增强数据配置 === # 实时行情开关(关闭后使用历史收盘价进行分析) enable_realtime_quote: bool = True # 盘中实时技术面:启用时用实时价计算 MA/多头排列(Issue #234);关闭则用昨日收盘 enable_realtime_technical_indicators: bool = True # 筹码分布开关(该接口不稳定,云端部署建议关闭) enable_chip_distribution: bool = True # 东财接口补丁开关 enable_eastmoney_patch: bool = False # 实时行情数据源优先级(逗号分隔) # 推荐顺序:tencent > akshare_sina > efinance > akshare_em > tushare # - tencent: 腾讯财经,有量比/换手率/市盈率等,单股查询稳定(推荐) # - akshare_sina: 新浪财经,基本行情稳定,但无量比 # - efinance/akshare_em: 东财全量接口,数据最全但容易被封 # - tushare: Tushare Pro,需要2000积分,数据全面(付费用户可优先使用) realtime_source_priority: str = "tencent,akshare_sina,efinance,akshare_em" # 实时行情缓存时间(秒) realtime_cache_ttl: int = 600 # 熔断器冷却时间(秒) circuit_breaker_cooldown: int = 300 # === 基本面聚合开关与降级保护 === # 全局总开关;关闭时返回 not_supported 并保持主流程无变化 enable_fundamental_pipeline: bool = True # 基本面阶段总预算(秒) fundamental_stage_timeout_seconds: float = FUNDAMENTAL_STAGE_TIMEOUT_SECONDS_DEFAULT # 单能力源调用超时(秒) fundamental_fetch_timeout_seconds: float = 8.0 # 单能力失败重试次数(已包含首次) fundamental_retry_max: int = 1 # 基本面上下文短 TTL(秒) fundamental_cache_ttl_seconds: int = 120 # 基本面缓存最大条目数(避免长时间运行内存增长) fundamental_cache_max_entries: int = 256 # === Portfolio PR2: import/risk/fx settings === portfolio_risk_concentration_alert_pct: float = 35.0 portfolio_risk_drawdown_alert_pct: float = 15.0 portfolio_risk_stop_loss_alert_pct: float = 10.0 portfolio_risk_stop_loss_near_ratio: float = 0.8 portfolio_risk_lookback_days: int = 180 portfolio_fx_update_enabled: bool = True # Discord 机器人状态 discord_bot_status: str = "A股智能分析 | /help" # === 流控配置(防封禁关键参数)=== # Akshare 请求间隔范围(秒) akshare_sleep_min: float = 2.0 akshare_sleep_max: float = 5.0 # Tushare 每分钟最大请求数(免费配额) tushare_rate_limit_per_minute: int = 80 # 重试配置 max_retries: int = 3 retry_base_delay: float = 1.0 retry_max_delay: float = 30.0 # === WebUI 配置 === webui_enabled: bool = False webui_host: str = "127.0.0.1" webui_port: int = 8000 # === 机器人配置 === bot_enabled: bool = True # 是否启用机器人功能 bot_command_prefix: str = "/" # 命令前缀 bot_rate_limit_requests: int = 10 # 频率限制:窗口内最大请求数 bot_rate_limit_window: int = 60 # 频率限制:窗口时间(秒) bot_admin_users: List[str] = field(default_factory=list) # 管理员用户 ID 列表 # 飞书机器人(事件订阅)- 已有 feishu_app_id, feishu_app_secret feishu_verification_token: Optional[str] = None # 事件订阅验证 Token feishu_encrypt_key: Optional[str] = None # 消息加密密钥(可选) feishu_stream_enabled: bool = False # 是否启用 Stream 长连接模式(无需公网IP) # 钉钉机器人 dingtalk_app_key: Optional[str] = None # 应用 AppKey dingtalk_app_secret: Optional[str] = None # 应用 AppSecret dingtalk_stream_enabled: bool = False # 是否启用 Stream 模式(无需公网IP) # 企业微信机器人(回调模式) wecom_corpid: Optional[str] = None # 企业 ID wecom_token: Optional[str] = None # 回调 Token wecom_encoding_aes_key: Optional[str] = None # 消息加解密密钥 wecom_agent_id: Optional[str] = None # 应用 AgentId # Telegram 机器人 - 已有 telegram_bot_token, telegram_chat_id telegram_webhook_secret: Optional[str] = None # Webhook 密钥 # === 配置校验模式 === # CONFIG_VALIDATE_MODE=warn (default): log all issues but always continue startup # CONFIG_VALIDATE_MODE=strict: exit(1) when any "error" severity issue is found config_validate_mode: str = "warn" # --- Post-init validation --------------------------------------------------- _VALID_AGENT_ARCH = {"single", "multi"} _VALID_ORCHESTRATOR_MODES = {"quick", "standard", "full", "specialist"} _VALID_SKILL_ROUTING = {"auto", "manual"} _WEBUI_RUNTIME_ENV_FILE_PRIORITY_KEYS = frozenset( { "STOCK_LIST", "RUN_IMMEDIATELY", "SCHEDULE_ENABLED", "SCHEDULE_TIME", "SCHEDULE_TIMES", "SCHEDULE_RUN_IMMEDIATELY", } ) _BOOTSTRAP_RUNTIME_ENV_OVERRIDES_CAPTURED = False _BOOTSTRAP_RUNTIME_ENV_OVERRIDES = frozenset() _BOOTSTRAP_RUNTIME_ENV_PRESENT_KEYS = frozenset() def __post_init__(self) -> None: _log = logging.getLogger(__name__) if self.agent_arch not in self._VALID_AGENT_ARCH: _log.warning( "Invalid AGENT_ARCH=%r, falling back to 'single'. Valid: %s", self.agent_arch, self._VALID_AGENT_ARCH, ) object.__setattr__(self, "agent_arch", "single") if self.agent_orchestrator_mode in {"strategy", "skill"}: _log.info( "AGENT_ORCHESTRATOR_MODE=%s is deprecated; normalizing to 'specialist'", self.agent_orchestrator_mode, ) object.__setattr__(self, "agent_orchestrator_mode", "specialist") if self.agent_orchestrator_mode not in self._VALID_ORCHESTRATOR_MODES: _log.warning( "Invalid AGENT_ORCHESTRATOR_MODE=%r, falling back to 'standard'. Valid: %s", self.agent_orchestrator_mode, self._VALID_ORCHESTRATOR_MODES, ) object.__setattr__(self, "agent_orchestrator_mode", "standard") if self.agent_skill_routing not in self._VALID_SKILL_ROUTING: _log.warning( "Invalid AGENT_SKILL_ROUTING=%r, falling back to 'auto'. Valid: %s", self.agent_skill_routing, self._VALID_SKILL_ROUTING, ) object.__setattr__(self, "agent_skill_routing", "auto") normalized_profile = normalize_agent_context_compression_profile( self.agent_context_compression_profile ) if normalized_profile != self.agent_context_compression_profile: object.__setattr__(self, "agent_context_compression_profile", normalized_profile) # 单例实例存储 _instance: Optional['Config'] = None @classmethod def get_instance(cls) -> 'Config': """ 获取配置单例实例 单例模式确保: 1. 全局只有一个配置实例 2. 配置只从环境变量加载一次 3. 所有模块共享相同配置 """ if cls._instance is None: cls._instance = cls._load_from_env() return cls._instance @classmethod def _load_from_env(cls) -> 'Config': """ 从 .env 文件加载配置 加载优先级: 1. 大多数配置保持系统环境变量优先 2. WebUI 可写的运行期关键键优先复用持久化 `.env`,但保留启动时显式进程环境变量的 override 3. 代码中的默认值 """ cls._capture_bootstrap_runtime_env_overrides() preexisting_report_language = os.environ.get("REPORT_LANGUAGE") # 确保环境变量已加载 setup_env() # === 智能代理配置 (关键修复) === # 如果配置了代理,自动设置 NO_PROXY 以排除国内数据源,避免行情获取失败 http_proxy = os.getenv('HTTP_PROXY') or os.getenv('http_proxy') if http_proxy: # 国内金融数据源域名列表 domestic_domains = [ 'eastmoney.com', # 东方财富 (Efinance/Akshare) 'sina.com.cn', # 新浪财经 (Akshare) '163.com', # 网易财经 (Akshare) 'tushare.pro', # Tushare 'baostock.com', # Baostock 'sse.com.cn', # 上交所 'szse.cn', # 深交所 'csindex.com.cn', # 中证指数 'cninfo.com.cn', # 巨潮资讯 'localhost', '127.0.0.1' ] # 获取现有的 no_proxy current_no_proxy = os.getenv('NO_PROXY') or os.getenv('no_proxy') or '' existing_domains = current_no_proxy.split(',') if current_no_proxy else [] # 合并去重 final_domains = list(set(existing_domains + domestic_domains)) final_no_proxy = ','.join(filter(None, final_domains)) # 设置环境变量 (requests/urllib3/aiohttp 都会遵守此设置) os.environ['NO_PROXY'] = final_no_proxy os.environ['no_proxy'] = final_no_proxy # 确保 HTTP_PROXY 也被正确设置(以防仅在 .env 中定义但未导出) os.environ['HTTP_PROXY'] = http_proxy os.environ['http_proxy'] = http_proxy # HTTPS_PROXY 同理 https_proxy = os.getenv('HTTPS_PROXY') or os.getenv('https_proxy') if https_proxy: os.environ['HTTPS_PROXY'] = https_proxy os.environ['https_proxy'] = https_proxy # 解析自选股列表(逗号分隔,统一为大写 Issue #355) stock_list_str = cls._resolve_env_value( 'STOCK_LIST', default='', prefer_env_file=True, ) stock_list = [ (c or "").strip().upper() for c in split_stock_list(stock_list_str) if (c or "").strip() ] # === LiteLLM multi-key parsing === # GEMINI_API_KEYS (comma-separated) > GEMINI_API_KEY (single) _gemini_keys_raw = os.getenv('GEMINI_API_KEYS', '') gemini_api_keys = [k.strip() for k in _gemini_keys_raw.split(',') if k.strip()] _single_gemini = os.getenv('GEMINI_API_KEY', '').strip() if not gemini_api_keys and _single_gemini: gemini_api_keys = [_single_gemini] # ANTHROPIC_API_KEYS > ANTHROPIC_API_KEY _anthropic_keys_raw = os.getenv('ANTHROPIC_API_KEYS', '') anthropic_api_keys = [k.strip() for k in _anthropic_keys_raw.split(',') if k.strip()] _single_anthropic = os.getenv('ANTHROPIC_API_KEY', '').strip() if not anthropic_api_keys and _single_anthropic: anthropic_api_keys = [_single_anthropic] # OPENAI_API_KEYS > AIHUBMIX_KEY > OPENAI_API_KEY _aihubmix = os.getenv('AIHUBMIX_KEY', '').strip() _openai_keys_raw = os.getenv('OPENAI_API_KEYS', '') openai_api_keys = [k.strip() for k in _openai_keys_raw.split(',') if k.strip()] if not openai_api_keys: _single_openai = os.getenv('OPENAI_API_KEY', '').strip() _fallback_key = _aihubmix or _single_openai if _fallback_key: openai_api_keys = [_fallback_key] openai_base_url = os.getenv('OPENAI_BASE_URL') or ( 'https://aihubmix.com/v1' if _aihubmix else None ) # DEEPSEEK_API_KEYS > DEEPSEEK_API_KEY (independent from OpenAI-compatible layer) _deepseek_keys_raw = os.getenv('DEEPSEEK_API_KEYS', '') deepseek_api_keys = [k.strip() for k in _deepseek_keys_raw.split(',') if k.strip()] if not deepseek_api_keys: _single_deepseek = os.getenv('DEEPSEEK_API_KEY', '').strip() if _single_deepseek: deepseek_api_keys = [_single_deepseek] # Anspire Open shares the same key as Anspire Search and exposes an # OpenAI-compatible LLM gateway. When no other OpenAI-compatible key is # configured, use ANSPIRE_API_KEYS as the legacy openai-compatible # provider so "one key" setups work without LLM_CHANNELS. anspire_keys_str = os.getenv('ANSPIRE_API_KEYS', '') anspire_api_keys = [k.strip() for k in anspire_keys_str.split(',') if k.strip()] anspire_llm_enabled = parse_env_bool(os.getenv('ANSPIRE_LLM_ENABLED'), default=True) anspire_llm_base_url = ( os.getenv('ANSPIRE_LLM_BASE_URL') or ANSPIRE_LLM_BASE_URL_DEFAULT ).strip() _anspire_llm_model_env = os.getenv('ANSPIRE_LLM_MODEL', '').strip() anspire_channel_declared = False for _raw_channel in os.getenv('LLM_CHANNELS', '').split(','): if _raw_channel.strip().lower() != "anspire": continue anspire_channel_declared = True break using_anspire_llm_legacy = bool( anspire_llm_enabled and not anspire_channel_declared and anspire_api_keys and not openai_api_keys ) if using_anspire_llm_legacy: openai_api_keys = list(anspire_api_keys) openai_base_url = anspire_llm_base_url # LITELLM_MODEL / LITELLM_FALLBACK_MODELS explicit values are recorded # before YAML/channels are parsed, but legacy inference is delayed until # the higher-priority sources and Hermes blocking issues are known. litellm_model_explicit = os.getenv('LITELLM_MODEL', '').strip() litellm_model = litellm_model_explicit inferred_legacy_deepseek_model = False _openai_model_env = os.getenv('OPENAI_MODEL', '').strip() if using_anspire_llm_legacy: _openai_model_name = _anspire_llm_model_env or _openai_model_env or ANSPIRE_LLM_MODEL_DEFAULT else: _openai_model_name = _openai_model_env or 'gpt-5.5' # LITELLM_FALLBACK_MODELS: comma-separated list of fallback models _fallback_str = os.getenv('LITELLM_FALLBACK_MODELS', '') litellm_fallback_models_explicit = bool(_fallback_str.strip()) if _fallback_str.strip(): litellm_fallback_models = [m.strip() for m in _fallback_str.split(',') if m.strip()] else: litellm_fallback_models = [] # === LLM Channels + YAML config === litellm_config_path = os.getenv('LITELLM_CONFIG', '').strip() or None llm_models_source = "legacy_env" llm_channels: List[Dict[str, Any]] = [] llm_channel_names: List[str] = [] llm_channel_config_issues: List[Dict[str, str]] = [] llm_blocks_legacy_fallback = False llm_blocked_hermes_routes: List[str] = [] llm_model_list: List[Dict[str, Any]] = [] # Priority 1: LITELLM_CONFIG (standard LiteLLM YAML config file) if litellm_config_path: llm_model_list = cls._parse_litellm_yaml(litellm_config_path) if llm_model_list: llm_models_source = "litellm_config" # Priority 2: LLM_CHANNELS (env var based channel config) if not llm_model_list: _channels_str = os.getenv('LLM_CHANNELS', '').strip() if _channels_str: llm_channel_names = [ ch.strip().lower() for ch in _channels_str.split(',') if ch.strip() ] ( llm_channels, hermes_issues, llm_blocks_legacy_fallback, llm_blocked_hermes_routes, ) = cls._parse_llm_channels_with_issues(_channels_str) llm_channel_config_issues = [issue.as_dict() for issue in hermes_issues] llm_model_list = cls._channels_to_model_list(llm_channels) if llm_model_list: llm_models_source = "llm_channels" route_models = get_configured_llm_models(llm_model_list) if route_models: if not litellm_model: litellm_model = route_models[0] if not litellm_fallback_models and not litellm_fallback_models_explicit and litellm_model: _seen = {litellm_model} litellm_fallback_models = [ model for model in route_models if model not in _seen and not _seen.add(model) # type: ignore[func-returns-value] ] # Priority 3: Legacy env vars → auto-build model_list (backward compatible). # This is skipped when an explicit invalid Hermes channel blocks legacy fallback. if not llm_model_list and not llm_blocks_legacy_fallback: llm_model_list = cls._legacy_keys_to_model_list( gemini_api_keys, anthropic_api_keys, openai_api_keys, openai_base_url, deepseek_api_keys, ) if llm_model_list: llm_models_source = "legacy_env" if not litellm_model: _gemini_model_name = os.getenv('GEMINI_MODEL', 'gemini-3.1-pro-preview').strip() _anthropic_model_name = os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6').strip() if gemini_api_keys: litellm_model = f'gemini/{_gemini_model_name}' elif anthropic_api_keys: litellm_model = f'anthropic/{_anthropic_model_name}' elif deepseek_api_keys: litellm_model = 'deepseek/deepseek-chat' inferred_legacy_deepseek_model = True elif openai_api_keys: # For openai-compatible models, add prefix only if not already prefixed if '/' not in _openai_model_name: litellm_model = f'openai/{_openai_model_name}' else: litellm_model = _openai_model_name if not litellm_fallback_models and not litellm_fallback_models_explicit: # Backward compat: use gemini_model_fallback when primary is gemini _gemini_fallback = os.getenv('GEMINI_MODEL_FALLBACK', 'gemini-3-flash-preview').strip() if litellm_model.startswith('gemini/') and _gemini_fallback: _fb = f'gemini/{_gemini_fallback}' if '/' not in _gemini_fallback else _gemini_fallback litellm_fallback_models = [_fb] if ( inferred_legacy_deepseek_model and llm_models_source == "legacy_env" and litellm_model == 'deepseek/deepseek-chat' ): logger.warning( "Deprecation warning:\n" "deepseek-chat will be deprecated on 2026-07-24,\n" "please migrate to deepseek-v4-flash." ) generation_backend = ( os.getenv('GENERATION_BACKEND', LITELLM_BACKEND_ID).strip().lower() or LITELLM_BACKEND_ID ) _generation_fallback_raw = os.getenv('GENERATION_FALLBACK_BACKEND') if _generation_fallback_raw is None: generation_fallback_backend = LITELLM_BACKEND_ID else: generation_fallback_backend = _generation_fallback_raw.strip().lower() agent_generation_backend = ( os.getenv('AGENT_GENERATION_BACKEND', AUTO_AGENT_BACKEND_ID).strip().lower() or AUTO_AGENT_BACKEND_ID ) generation_backend_timeout_seconds = parse_env_int( os.getenv('GENERATION_BACKEND_TIMEOUT_SECONDS'), DEFAULT_LOCAL_CLI_TIMEOUT_SECONDS, field_name='GENERATION_BACKEND_TIMEOUT_SECONDS', minimum=1, maximum=MAX_LOCAL_CLI_TIMEOUT_SECONDS, ) generation_backend_max_output_bytes = parse_env_int( os.getenv('GENERATION_BACKEND_MAX_OUTPUT_BYTES'), DEFAULT_LOCAL_CLI_MAX_OUTPUT_BYTES, field_name='GENERATION_BACKEND_MAX_OUTPUT_BYTES', minimum=1, maximum=MAX_LOCAL_CLI_OUTPUT_BYTES, ) generation_backend_max_concurrency = parse_env_int( os.getenv('GENERATION_BACKEND_MAX_CONCURRENCY'), DEFAULT_GENERATION_BACKEND_MAX_CONCURRENCY, field_name='GENERATION_BACKEND_MAX_CONCURRENCY', minimum=1, maximum=MAX_GENERATION_BACKEND_MAX_CONCURRENCY, ) local_cli_backend_max_concurrency = parse_env_int( os.getenv('LOCAL_CLI_BACKEND_MAX_CONCURRENCY'), DEFAULT_LOCAL_CLI_BACKEND_MAX_CONCURRENCY, field_name='LOCAL_CLI_BACKEND_MAX_CONCURRENCY', minimum=1, maximum=MAX_LOCAL_CLI_BACKEND_MAX_CONCURRENCY, ) opencode_cli_model = (os.getenv('OPENCODE_CLI_MODEL', '') or '').strip() agent_litellm_model = normalize_agent_litellm_model( os.getenv('AGENT_LITELLM_MODEL', ''), configured_models=set(get_configured_llm_models(llm_model_list)), ) agent_context_compression_profile = normalize_agent_context_compression_profile( os.getenv('AGENT_CONTEXT_COMPRESSION_PROFILE') ) agent_context_compression_preset = get_agent_context_compression_preset( agent_context_compression_profile ) agent_context_compression_trigger_tokens = parse_agent_context_compression_int( os.getenv('AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS'), agent_context_compression_preset.trigger_tokens, field_name='AGENT_CONTEXT_COMPRESSION_TRIGGER_TOKENS', minimum=1000, maximum=200000, ) agent_context_protected_turns = parse_agent_context_compression_int( os.getenv('AGENT_CONTEXT_PROTECTED_TURNS'), agent_context_compression_preset.protected_turns, field_name='AGENT_CONTEXT_PROTECTED_TURNS', minimum=1, maximum=20, ) # 解析搜索引擎 API Keys(支持多个 key,逗号分隔) bocha_keys_str = os.getenv('BOCHA_API_KEYS', '') bocha_api_keys = [k.strip() for k in bocha_keys_str.split(',') if k.strip()] minimax_keys_str = os.getenv('MINIMAX_API_KEYS', '') minimax_api_keys = [k.strip() for k in minimax_keys_str.split(',') if k.strip()] tavily_keys_str = os.getenv('TAVILY_API_KEYS', '') tavily_api_keys = [k.strip() for k in tavily_keys_str.split(',') if k.strip()] serpapi_keys_str = os.getenv('SERPAPI_API_KEYS', '') serpapi_keys = [k.strip() for k in serpapi_keys_str.split(',') if k.strip()] brave_keys_str = os.getenv('BRAVE_API_KEYS', '') brave_api_keys = [k.strip() for k in brave_keys_str.split(',') if k.strip()] _raw_urls = [u.strip() for u in os.getenv('SEARXNG_BASE_URLS', '').split(',') if u.strip()] searxng_base_urls = [] invalid_searxng_urls = [] for u in _raw_urls: p = urlparse(u) if p.scheme in ('http', 'https') and p.netloc: searxng_base_urls.append(u) else: invalid_searxng_urls.append(u) if invalid_searxng_urls: logger.warning( "SEARXNG_BASE_URLS 中存在无效 URL,已忽略: %s", ", ".join(invalid_searxng_urls[:3]), ) searxng_public_instances_enabled = parse_env_bool( os.getenv('SEARXNG_PUBLIC_INSTANCES_ENABLED'), default=False, ) # 企微消息类型与最大字节数逻辑 wechat_msg_type = os.getenv('WECHAT_MSG_TYPE', 'markdown') wechat_msg_type_lower = wechat_msg_type.lower() wechat_max_bytes_env = os.getenv('WECHAT_MAX_BYTES') if wechat_max_bytes_env not in (None, ''): wechat_max_bytes = parse_env_int( wechat_max_bytes_env, 2048 if wechat_msg_type_lower == 'text' else 4000, field_name='WECHAT_MAX_BYTES', minimum=1, ) else: # 未显式配置时,根据消息类型选择默认字节数 wechat_max_bytes = 2048 if wechat_msg_type_lower == 'text' else 4000 # Preserve historical semantics for startup flags: only an explicit # literal "true" enables immediate execution; empty strings stay False. legacy_run_immediately_env = cls._resolve_env_value( 'RUN_IMMEDIATELY', prefer_env_file=True, ) legacy_run_immediately = ( legacy_run_immediately_env.lower() == 'true' if legacy_run_immediately_env is not None else True ) schedule_run_immediately_env = cls._resolve_env_value( 'SCHEDULE_RUN_IMMEDIATELY', prefer_env_file=True, ) # Keep backward compatibility for container/process overrides: # when RUN_IMMEDIATELY is explicitly provided by the runtime but the # schedule-specific alias is absent, schedule mode should inherit the # legacy process value instead of being pulled back to the persisted # `.env` copy of SCHEDULE_RUN_IMMEDIATELY. if ( not cls._had_bootstrap_runtime_env_key('SCHEDULE_RUN_IMMEDIATELY') and cls._has_bootstrap_runtime_env_override('RUN_IMMEDIATELY') ): schedule_run_immediately = legacy_run_immediately else: schedule_run_immediately = ( schedule_run_immediately_env.lower() == 'true' if schedule_run_immediately_env is not None else legacy_run_immediately ) schedule_time_value = cls._resolve_env_value( 'SCHEDULE_TIME', default='18:00', prefer_env_file=True, ) schedule_times_value = cls._resolve_env_value( 'SCHEDULE_TIMES', default='', prefer_env_file=True, ) report_language_raw = cls._resolve_report_language_env_value( preexisting_report_language ) report_show_llm_model_raw = os.getenv('REPORT_SHOW_LLM_MODEL') report_show_llm_model = parse_env_bool(report_show_llm_model_raw, default=True) if report_show_llm_model_raw is not None and not report_show_llm_model_raw.strip(): report_show_llm_model = False return cls( stock_list=stock_list, feishu_app_id=os.getenv('FEISHU_APP_ID'), feishu_app_secret=os.getenv('FEISHU_APP_SECRET'), feishu_folder_token=os.getenv('FEISHU_FOLDER_TOKEN'), tushare_token=os.getenv('TUSHARE_TOKEN'), tickflow_api_key=os.getenv('TICKFLOW_API_KEY'), tickflow_kline_adjust=normalize_tickflow_kline_adjust(os.getenv('TICKFLOW_KLINE_ADJUST')), tickflow_priority=parse_env_int(os.getenv('TICKFLOW_PRIORITY'), 2, field_name='TICKFLOW_PRIORITY', minimum=0), tickflow_batch_daily_enabled=parse_env_bool(os.getenv('TICKFLOW_BATCH_DAILY_ENABLED'), default=True), tickflow_batch_size=parse_env_int(os.getenv('TICKFLOW_BATCH_SIZE'), 100, field_name='TICKFLOW_BATCH_SIZE', minimum=1), finnhub_api_key=os.getenv('FINNHUB_API_KEY') or None, alphavantage_api_key=os.getenv('ALPHAVANTAGE_API_KEY') or None, longbridge_app_key=os.getenv('LONGBRIDGE_APP_KEY') or None, longbridge_app_secret=os.getenv('LONGBRIDGE_APP_SECRET') or None, longbridge_access_token=os.getenv('LONGBRIDGE_ACCESS_TOKEN') or None, longbridge_oauth_client_id=os.getenv('LONGBRIDGE_OAUTH_CLIENT_ID') or None, stock_index_remote_update_enabled=parse_env_bool( os.getenv('STOCK_INDEX_REMOTE_UPDATE_ENABLED'), default=True, ), generation_backend=generation_backend, generation_fallback_backend=generation_fallback_backend, generation_backend_timeout_seconds=generation_backend_timeout_seconds, generation_backend_max_output_bytes=generation_backend_max_output_bytes, generation_backend_max_concurrency=generation_backend_max_concurrency, local_cli_backend_max_concurrency=local_cli_backend_max_concurrency, opencode_cli_model=opencode_cli_model, litellm_model=litellm_model, litellm_fallback_models=litellm_fallback_models, llm_temperature=resolve_unified_llm_temperature(litellm_model), litellm_config_path=litellm_config_path, llm_models_source=llm_models_source, llm_channels=llm_channels, llm_channel_names=llm_channel_names, llm_channel_config_issues=llm_channel_config_issues, llm_blocks_legacy_fallback=llm_blocks_legacy_fallback, llm_blocked_hermes_routes=llm_blocked_hermes_routes, llm_model_list=llm_model_list, llm_prompt_cache_telemetry_enabled=parse_env_bool( os.getenv("LLM_PROMPT_CACHE_TELEMETRY_ENABLED"), default=True, ), llm_prompt_cache_hints_enabled=parse_env_bool( os.getenv("LLM_PROMPT_CACHE_HINTS_ENABLED"), default=False, ), llm_prompt_cache_diagnostics_level=parse_prompt_cache_diagnostics_level( os.getenv("LLM_PROMPT_CACHE_DIAGNOSTICS_LEVEL") ), gemini_api_keys=gemini_api_keys, anthropic_api_keys=anthropic_api_keys, openai_api_keys=openai_api_keys, deepseek_api_keys=deepseek_api_keys, gemini_api_key=os.getenv('GEMINI_API_KEY'), gemini_model=os.getenv('GEMINI_MODEL', 'gemini-3.1-pro-preview'), gemini_model_fallback=os.getenv('GEMINI_MODEL_FALLBACK', 'gemini-3-flash-preview'), gemini_temperature=parse_env_float(os.getenv('GEMINI_TEMPERATURE'), 0.7, field_name='GEMINI_TEMPERATURE'), gemini_request_delay=parse_env_float(os.getenv('GEMINI_REQUEST_DELAY'), 2.0, field_name='GEMINI_REQUEST_DELAY', minimum=0.0), gemini_max_retries=parse_env_int(os.getenv('GEMINI_MAX_RETRIES'), 5, field_name='GEMINI_MAX_RETRIES', minimum=0), gemini_retry_delay=parse_env_float(os.getenv('GEMINI_RETRY_DELAY'), 5.0, field_name='GEMINI_RETRY_DELAY', minimum=0.0), anthropic_api_key=os.getenv('ANTHROPIC_API_KEY'), anthropic_model=os.getenv('ANTHROPIC_MODEL', 'claude-sonnet-4-6'), anthropic_temperature=parse_env_float(os.getenv('ANTHROPIC_TEMPERATURE'), 0.7, field_name='ANTHROPIC_TEMPERATURE'), anthropic_max_tokens=parse_env_int(os.getenv('ANTHROPIC_MAX_TOKENS'), 8192, field_name='ANTHROPIC_MAX_TOKENS', minimum=1), # AIHubmix is the preferred OpenAI-compatible provider (one key, all models, no VPN required). # Within the OpenAI-compatible layer: AIHUBMIX_KEY takes priority over OPENAI_API_KEY. # Overall provider fallback order: Gemini > Anthropic > OpenAI-compatible (incl. AIHubmix). # base_url is auto-set to aihubmix.com/v1 when AIHUBMIX_KEY is used and no explicit # OPENAI_BASE_URL override is provided. # Model names match upstream (e.g. gemini-3.1-pro-preview, gpt-5.5, deepseek-v4-flash). openai_api_key=openai_api_keys[0] if openai_api_keys else None, openai_base_url=openai_base_url, openai_model=_openai_model_name, openai_vision_model=os.getenv('OPENAI_VISION_MODEL') or None, openai_temperature=parse_env_float(os.getenv('OPENAI_TEMPERATURE'), 0.7, field_name='OPENAI_TEMPERATURE'), # Vision model: VISION_MODEL > OPENAI_VISION_MODEL (alias) > default vision_model=( os.getenv('VISION_MODEL') or os.getenv('OPENAI_VISION_MODEL') or "" ), vision_provider_priority=os.getenv('VISION_PROVIDER_PRIORITY', 'gemini,anthropic,openai'), anspire_api_keys=anspire_api_keys, bocha_api_keys=bocha_api_keys, minimax_api_keys=minimax_api_keys, tavily_api_keys=tavily_api_keys, brave_api_keys=brave_api_keys, serpapi_keys=serpapi_keys, searxng_base_urls=searxng_base_urls, searxng_public_instances_enabled=searxng_public_instances_enabled, social_sentiment_api_key=os.getenv('SOCIAL_SENTIMENT_API_KEY') or None, social_sentiment_api_url=os.getenv('SOCIAL_SENTIMENT_API_URL', 'https://api.adanos.org').rstrip('/'), news_max_age_days=parse_env_int(os.getenv('NEWS_MAX_AGE_DAYS'), 3, field_name='NEWS_MAX_AGE_DAYS', minimum=1), news_strategy_profile=cls._parse_news_strategy_profile( os.getenv('NEWS_STRATEGY_PROFILE', 'short') ), news_intel_retention_days=parse_env_int( os.getenv('NEWS_INTEL_RETENTION_DAYS'), 30, field_name='NEWS_INTEL_RETENTION_DAYS', minimum=1, maximum=365, ), news_intel_fetch_timeout_sec=parse_env_float( os.getenv('NEWS_INTEL_FETCH_TIMEOUT_SEC'), 8.0, field_name='NEWS_INTEL_FETCH_TIMEOUT_SEC', minimum=1.0, maximum=30.0, ), news_intel_max_items_per_source=parse_env_int( os.getenv('NEWS_INTEL_MAX_ITEMS_PER_SOURCE'), 50, field_name='NEWS_INTEL_MAX_ITEMS_PER_SOURCE', minimum=1, maximum=200, ), news_intel_auto_fetch_enabled=parse_env_bool( os.getenv('NEWS_INTEL_AUTO_FETCH_ENABLED'), False, ), newsnow_base_url=((os.getenv('NEWSNOW_BASE_URL') or '').strip().rstrip('/') or 'https://newsnow.busiyi.world'), bias_threshold=parse_env_float(os.getenv('BIAS_THRESHOLD'), 5.0, field_name='BIAS_THRESHOLD', minimum=1.0), agent_backend=(os.getenv('AGENT_BACKEND', 'auto') or 'auto').strip().lower(), agent_generation_backend=agent_generation_backend, agent_litellm_model=agent_litellm_model, agent_mode=os.getenv('AGENT_MODE', 'false').lower() == 'true', _agent_mode_explicit=os.getenv('AGENT_MODE') is not None, agent_max_steps=parse_env_int( os.getenv('AGENT_MAX_STEPS'), AGENT_MAX_STEPS_DEFAULT, field_name='AGENT_MAX_STEPS', minimum=1, ), agent_skills=[s.strip() for s in os.getenv('AGENT_SKILLS', '').split(',') if s.strip()], agent_skill_dir=os.getenv('AGENT_SKILL_DIR') or os.getenv('AGENT_STRATEGY_DIR'), agent_nl_routing=os.getenv('AGENT_NL_ROUTING', 'false').lower() == 'true', agent_arch=os.getenv('AGENT_ARCH', 'single').lower(), agent_orchestrator_mode=os.getenv('AGENT_ORCHESTRATOR_MODE', 'standard').lower(), agent_orchestrator_timeout_s=parse_env_int( os.getenv('AGENT_ORCHESTRATOR_TIMEOUT_S'), 600, field_name='AGENT_ORCHESTRATOR_TIMEOUT_S', minimum=0, ), agent_technical_agent_timeout_s=parse_env_float( os.getenv('AGENT_TECHNICAL_AGENT_TIMEOUT_S'), 0, field_name='AGENT_TECHNICAL_AGENT_TIMEOUT_S', minimum=0, ), agent_intel_agent_timeout_s=parse_env_float( os.getenv('AGENT_INTEL_AGENT_TIMEOUT_S'), 0, field_name='AGENT_INTEL_AGENT_TIMEOUT_S', minimum=0, ), agent_risk_agent_timeout_s=parse_env_float( os.getenv('AGENT_RISK_AGENT_TIMEOUT_S'), 0, field_name='AGENT_RISK_AGENT_TIMEOUT_S', minimum=0, ), agent_decision_agent_timeout_s=parse_env_float( os.getenv('AGENT_DECISION_AGENT_TIMEOUT_S'), 0, field_name='AGENT_DECISION_AGENT_TIMEOUT_S', minimum=0, ), agent_portfolio_agent_timeout_s=parse_env_float( os.getenv('AGENT_PORTFOLIO_AGENT_TIMEOUT_S'), 0, field_name='AGENT_PORTFOLIO_AGENT_TIMEOUT_S', minimum=0, ), agent_skill_agent_timeout_s=parse_env_float( os.getenv('AGENT_SKILL_AGENT_TIMEOUT_S'), 0, field_name='AGENT_SKILL_AGENT_TIMEOUT_S', minimum=0, ), agent_data_tool_timeout_s=parse_env_float( os.getenv('AGENT_DATA_TOOL_TIMEOUT_S'), 0.0, field_name='AGENT_DATA_TOOL_TIMEOUT_S', minimum=0.0, ), agent_search_tool_timeout_s=parse_env_float( os.getenv('AGENT_SEARCH_TOOL_TIMEOUT_S'), 0.0, field_name='AGENT_SEARCH_TOOL_TIMEOUT_S', minimum=0.0, ), agent_analysis_tool_timeout_s=parse_env_float( os.getenv('AGENT_ANALYSIS_TOOL_TIMEOUT_S'), 0.0, field_name='AGENT_ANALYSIS_TOOL_TIMEOUT_S', minimum=0.0, ), agent_action_tool_timeout_s=parse_env_float( os.getenv('AGENT_ACTION_TOOL_TIMEOUT_S'), 0.0, field_name='AGENT_ACTION_TOOL_TIMEOUT_S', minimum=0.0, ), agent_skill_concurrency=parse_env_int( os.getenv('AGENT_SKILL_CONCURRENCY'), 3, field_name='AGENT_SKILL_CONCURRENCY', minimum=1, maximum=4, ), agent_risk_override=os.getenv('AGENT_RISK_OVERRIDE', 'true').lower() == 'true', agent_deep_research_budget=parse_env_int( os.getenv('AGENT_DEEP_RESEARCH_BUDGET'), 30000, field_name='AGENT_DEEP_RESEARCH_BUDGET', minimum=5000, ), agent_deep_research_timeout=parse_env_int( os.getenv('AGENT_DEEP_RESEARCH_TIMEOUT'), 180, field_name='AGENT_DEEP_RESEARCH_TIMEOUT', minimum=30, ), agent_memory_enabled=os.getenv('AGENT_MEMORY_ENABLED', 'false').lower() == 'true', agent_skill_autoweight=( os.getenv('AGENT_SKILL_AUTOWEIGHT') or os.getenv('AGENT_STRATEGY_AUTOWEIGHT', 'true') ).lower() == 'true', agent_skill_routing=( os.getenv('AGENT_SKILL_ROUTING') or os.getenv('AGENT_STRATEGY_ROUTING', 'auto') ).lower(), agent_context_compression_enabled=parse_env_bool( os.getenv('AGENT_CONTEXT_COMPRESSION_ENABLED'), default=False, ), agent_context_compression_profile=agent_context_compression_profile, agent_context_compression_trigger_tokens=agent_context_compression_trigger_tokens, agent_context_protected_turns=agent_context_protected_turns, agent_event_monitor_enabled=os.getenv('AGENT_EVENT_MONITOR_ENABLED', 'false').lower() == 'true', agent_event_monitor_interval_minutes=parse_env_int( os.getenv('AGENT_EVENT_MONITOR_INTERVAL_MINUTES'), 5, field_name='AGENT_EVENT_MONITOR_INTERVAL_MINUTES', minimum=1, ), agent_event_alert_rules_json=os.getenv('AGENT_EVENT_ALERT_RULES_JSON', ''), wechat_webhook_url=os.getenv('WECHAT_WEBHOOK_URL'), feishu_webhook_url=os.getenv('FEISHU_WEBHOOK_URL'), feishu_webhook_secret=os.getenv('FEISHU_WEBHOOK_SECRET'), feishu_webhook_keyword=os.getenv('FEISHU_WEBHOOK_KEYWORD'), dingtalk_webhook_url=os.getenv('DINGTALK_WEBHOOK_URL'), dingtalk_secret=os.getenv('DINGTALK_SECRET'), feishu_chat_id=os.getenv('FEISHU_CHAT_ID'), feishu_receive_id_type=os.getenv('FEISHU_RECEIVE_ID_TYPE', 'chat_id'), feishu_domain=os.getenv('FEISHU_DOMAIN', 'feishu'), telegram_bot_token=os.getenv('TELEGRAM_BOT_TOKEN'), telegram_chat_id=os.getenv('TELEGRAM_CHAT_ID'), telegram_message_thread_id=os.getenv('TELEGRAM_MESSAGE_THREAD_ID'), email_sender=os.getenv('EMAIL_SENDER'), email_sender_name=os.getenv('EMAIL_SENDER_NAME', 'daily_stock_analysis股票分析助手'), email_password=os.getenv('EMAIL_PASSWORD'), email_receivers=[r.strip() for r in os.getenv('EMAIL_RECEIVERS', '').split(',') if r.strip()], stock_email_groups=cls._parse_stock_email_groups(), pushover_user_key=os.getenv('PUSHOVER_USER_KEY'), pushover_api_token=os.getenv('PUSHOVER_API_TOKEN'), ntfy_url=os.getenv('NTFY_URL'), ntfy_token=os.getenv('NTFY_TOKEN'), gotify_url=os.getenv('GOTIFY_URL'), gotify_token=os.getenv('GOTIFY_TOKEN'), pushplus_token=os.getenv('PUSHPLUS_TOKEN'), pushplus_topic=os.getenv('PUSHPLUS_TOPIC'), serverchan3_sendkey=os.getenv('SERVERCHAN3_SENDKEY'), custom_webhook_urls=[u.strip() for u in os.getenv('CUSTOM_WEBHOOK_URLS', '').split(',') if u.strip()], custom_webhook_bearer_token=os.getenv('CUSTOM_WEBHOOK_BEARER_TOKEN'), custom_webhook_body_template=unescape_compose_sensitive_env_value( 'CUSTOM_WEBHOOK_BODY_TEMPLATE', os.getenv('CUSTOM_WEBHOOK_BODY_TEMPLATE') or '', ) or None, webhook_verify_ssl=os.getenv('WEBHOOK_VERIFY_SSL', 'true').lower() == 'true', discord_bot_token=os.getenv('DISCORD_BOT_TOKEN'), discord_main_channel_id=( os.getenv('DISCORD_MAIN_CHANNEL_ID') or os.getenv('DISCORD_CHANNEL_ID') ), discord_webhook_url=os.getenv('DISCORD_WEBHOOK_URL'), discord_interactions_public_key=os.getenv('DISCORD_INTERACTIONS_PUBLIC_KEY'), slack_webhook_url=os.getenv('SLACK_WEBHOOK_URL'), slack_bot_token=os.getenv('SLACK_BOT_TOKEN'), slack_channel_id=os.getenv('SLACK_CHANNEL_ID'), astrbot_url=os.getenv('ASTRBOT_URL'), astrbot_token=os.getenv('ASTRBOT_TOKEN'), notification_report_channels=parse_notification_route_channels( os.getenv('NOTIFICATION_REPORT_CHANNELS') ), notification_alert_channels=parse_notification_route_channels( os.getenv('NOTIFICATION_ALERT_CHANNELS') ), notification_system_error_channels=parse_notification_route_channels( os.getenv('NOTIFICATION_SYSTEM_ERROR_CHANNELS') ), notification_dedup_ttl_seconds=parse_env_int( os.getenv('NOTIFICATION_DEDUP_TTL_SECONDS'), 0, field_name='NOTIFICATION_DEDUP_TTL_SECONDS', minimum=0, ), notification_cooldown_seconds=parse_env_int( os.getenv('NOTIFICATION_COOLDOWN_SECONDS'), 0, field_name='NOTIFICATION_COOLDOWN_SECONDS', minimum=0, ), notification_quiet_hours=(os.getenv('NOTIFICATION_QUIET_HOURS') or '').strip(), notification_timezone=(os.getenv('NOTIFICATION_TIMEZONE') or '').strip(), notification_min_severity=(os.getenv('NOTIFICATION_MIN_SEVERITY') or '').strip().lower(), notification_daily_digest_enabled=parse_env_bool( os.getenv('NOTIFICATION_DAILY_DIGEST_ENABLED'), default=False, ), single_stock_notify=os.getenv('SINGLE_STOCK_NOTIFY', 'false').lower() == 'true', report_type=cls._parse_report_type(os.getenv('REPORT_TYPE', 'simple')), report_language=cls._parse_report_language(report_language_raw), report_summary_only=os.getenv('REPORT_SUMMARY_ONLY', 'false').lower() == 'true', report_show_llm_model=report_show_llm_model, report_templates_dir=os.getenv('REPORT_TEMPLATES_DIR', 'templates'), report_renderer_enabled=os.getenv('REPORT_RENDERER_ENABLED', 'false').lower() == 'true', report_integrity_enabled=os.getenv('REPORT_INTEGRITY_ENABLED', 'true').lower() == 'true', report_integrity_retry=parse_env_int(os.getenv('REPORT_INTEGRITY_RETRY'), 1, field_name='REPORT_INTEGRITY_RETRY', minimum=0), report_history_compare_n=parse_env_int(os.getenv('REPORT_HISTORY_COMPARE_N'), 0, field_name='REPORT_HISTORY_COMPARE_N', minimum=0), analysis_delay=parse_env_float(os.getenv('ANALYSIS_DELAY'), 0.0, field_name='ANALYSIS_DELAY', minimum=0.0), merge_email_notification=os.getenv('MERGE_EMAIL_NOTIFICATION', 'false').lower() == 'true', feishu_max_bytes=parse_env_int(os.getenv('FEISHU_MAX_BYTES'), 20000, field_name='FEISHU_MAX_BYTES', minimum=1), feishu_send_as_file=os.getenv('FEISHU_SEND_AS_FILE', '').lower() in ('true', '1', 'yes'), wechat_max_bytes=wechat_max_bytes, wechat_msg_type=wechat_msg_type_lower, discord_max_words=parse_env_int(os.getenv('DISCORD_MAX_WORDS'), 2000, field_name='DISCORD_MAX_WORDS', minimum=1), markdown_to_image_channels=[ c.strip().lower() for c in os.getenv('MARKDOWN_TO_IMAGE_CHANNELS', '').split(',') if c.strip() ], markdown_to_image_max_chars=parse_env_int( os.getenv('MARKDOWN_TO_IMAGE_MAX_CHARS'), 15000, field_name='MARKDOWN_TO_IMAGE_MAX_CHARS', minimum=1, ), md2img_engine=cls._parse_md2img_engine(os.getenv('MD2IMG_ENGINE', 'wkhtmltoimage')), share_image_xiaohongshu_url=(os.getenv('SHARE_IMAGE_XIAOHONGSHU_URL') or '').strip() or None, share_image_xiaohongshu_handle=(os.getenv('SHARE_IMAGE_XIAOHONGSHU_HANDLE') or '').strip() or None, share_image_xiaohongshu_id=(os.getenv('SHARE_IMAGE_XIAOHONGSHU_ID') or '').strip() or None, share_image_xiaohongshu_qr_path=(os.getenv('SHARE_IMAGE_XIAOHONGSHU_QR_PATH') or '').strip() or None, prefetch_realtime_quotes=os.getenv('PREFETCH_REALTIME_QUOTES', 'true').lower() == 'true', database_path=os.getenv('DATABASE_PATH', './data/stock_analysis.db'), sqlite_wal_enabled=os.getenv('SQLITE_WAL_ENABLED', 'true').lower() == 'true', sqlite_busy_timeout_ms=parse_env_int( os.getenv('SQLITE_BUSY_TIMEOUT_MS'), 5000, field_name='SQLITE_BUSY_TIMEOUT_MS', minimum=0, ), sqlite_write_retry_max=parse_env_int( os.getenv('SQLITE_WRITE_RETRY_MAX'), 3, field_name='SQLITE_WRITE_RETRY_MAX', minimum=0, ), sqlite_write_retry_base_delay=parse_env_float( os.getenv('SQLITE_WRITE_RETRY_BASE_DELAY'), 0.1, field_name='SQLITE_WRITE_RETRY_BASE_DELAY', minimum=0.0, ), save_context_snapshot=os.getenv('SAVE_CONTEXT_SNAPSHOT', 'true').lower() == 'true', backtest_enabled=os.getenv('BACKTEST_ENABLED', 'true').lower() == 'true', backtest_eval_window_days=parse_env_int(os.getenv('BACKTEST_EVAL_WINDOW_DAYS'), 10, field_name='BACKTEST_EVAL_WINDOW_DAYS', minimum=1), backtest_min_age_days=parse_env_int(os.getenv('BACKTEST_MIN_AGE_DAYS'), 14, field_name='BACKTEST_MIN_AGE_DAYS', minimum=1), backtest_engine_version=os.getenv('BACKTEST_ENGINE_VERSION', 'v1'), backtest_neutral_band_pct=parse_env_float( os.getenv('BACKTEST_NEUTRAL_BAND_PCT'), 2.0, field_name='BACKTEST_NEUTRAL_BAND_PCT', minimum=0.0, ), log_dir=os.getenv('LOG_DIR', './logs'), log_level=os.getenv('LOG_LEVEL', 'INFO'), max_workers=parse_env_int(os.getenv('MAX_WORKERS'), 3, field_name='MAX_WORKERS', minimum=1), debug=os.getenv('DEBUG', 'false').lower() == 'true', config_validate_mode=os.getenv('CONFIG_VALIDATE_MODE', 'warn').lower(), http_proxy=os.getenv('HTTP_PROXY'), https_proxy=os.getenv('HTTPS_PROXY'), schedule_enabled=cls._resolve_env_value( 'SCHEDULE_ENABLED', default='false', prefer_env_file=True, ).lower() == 'true', schedule_time=(schedule_time_value or '18:00').strip() or '18:00', schedule_times=normalize_schedule_times( schedule_times_value, fallback_time=(schedule_time_value or '18:00').strip() or '18:00', ), schedule_run_immediately=schedule_run_immediately, run_immediately=legacy_run_immediately, market_review_enabled=os.getenv('MARKET_REVIEW_ENABLED', 'true').lower() == 'true', daily_market_context_enabled=os.getenv('DAILY_MARKET_CONTEXT_ENABLED', 'true').lower() == 'true', market_review_region=cls._parse_market_review_region( os.getenv('MARKET_REVIEW_REGION', 'cn') ), market_review_color_scheme=cls._parse_market_review_color_scheme( os.getenv('MARKET_REVIEW_COLOR_SCHEME', 'green_up') ), trading_day_check_enabled=os.getenv('TRADING_DAY_CHECK_ENABLED', 'true').lower() != 'false', webui_enabled=os.getenv('WEBUI_ENABLED', 'false').lower() == 'true', webui_host=os.getenv('WEBUI_HOST', '127.0.0.1'), webui_port=parse_env_int(os.getenv('WEBUI_PORT'), 8000, field_name='WEBUI_PORT', minimum=1, maximum=65535), # 机器人配置 bot_enabled=os.getenv('BOT_ENABLED', 'true').lower() == 'true', bot_command_prefix=os.getenv('BOT_COMMAND_PREFIX', '/'), bot_rate_limit_requests=parse_env_int(os.getenv('BOT_RATE_LIMIT_REQUESTS'), 10, field_name='BOT_RATE_LIMIT_REQUESTS', minimum=1), bot_rate_limit_window=parse_env_int(os.getenv('BOT_RATE_LIMIT_WINDOW'), 60, field_name='BOT_RATE_LIMIT_WINDOW', minimum=1), bot_admin_users=[u.strip() for u in os.getenv('BOT_ADMIN_USERS', '').split(',') if u.strip()], # 飞书机器人 feishu_verification_token=os.getenv('FEISHU_VERIFICATION_TOKEN'), feishu_encrypt_key=os.getenv('FEISHU_ENCRYPT_KEY'), feishu_stream_enabled=os.getenv('FEISHU_STREAM_ENABLED', 'false').lower() == 'true', # 钉钉机器人 dingtalk_app_key=os.getenv('DINGTALK_APP_KEY'), dingtalk_app_secret=os.getenv('DINGTALK_APP_SECRET'), dingtalk_stream_enabled=os.getenv('DINGTALK_STREAM_ENABLED', 'false').lower() == 'true', # 企业微信机器人 wecom_corpid=os.getenv('WECOM_CORPID'), wecom_token=os.getenv('WECOM_TOKEN'), wecom_encoding_aes_key=os.getenv('WECOM_ENCODING_AES_KEY'), wecom_agent_id=os.getenv('WECOM_AGENT_ID'), # Telegram telegram_webhook_secret=os.getenv('TELEGRAM_WEBHOOK_SECRET'), # Discord 机器人扩展配置 discord_bot_status=os.getenv('DISCORD_BOT_STATUS', 'A股智能分析 | /help'), # 实时行情增强数据配置 enable_realtime_quote=os.getenv('ENABLE_REALTIME_QUOTE', 'true').lower() == 'true', enable_realtime_technical_indicators=os.getenv( 'ENABLE_REALTIME_TECHNICAL_INDICATORS', 'true' ).lower() == 'true', enable_chip_distribution=os.getenv('ENABLE_CHIP_DISTRIBUTION', 'true').lower() == 'true', # 东财接口补丁开关 enable_eastmoney_patch=os.getenv('ENABLE_EASTMONEY_PATCH', 'false').lower() == 'true', # 实时行情数据源优先级: # - tencent: 腾讯财经,有量比/换手率/PE/PB等,单股查询稳定(推荐) # - akshare_sina: 新浪财经,基本行情稳定,但无量比 # - efinance/akshare_em: 东财全量接口,数据最全但容易被封 # - tushare: Tushare Pro,需要2000积分,数据全面 realtime_source_priority=cls._resolve_realtime_source_priority(), realtime_cache_ttl=parse_env_int(os.getenv('REALTIME_CACHE_TTL'), 600, field_name='REALTIME_CACHE_TTL', minimum=0), circuit_breaker_cooldown=parse_env_int(os.getenv('CIRCUIT_BREAKER_COOLDOWN'), 300, field_name='CIRCUIT_BREAKER_COOLDOWN', minimum=0), enable_fundamental_pipeline=os.getenv('ENABLE_FUNDAMENTAL_PIPELINE', 'true').lower() == 'true', fundamental_stage_timeout_seconds=parse_env_float( os.getenv('FUNDAMENTAL_STAGE_TIMEOUT_SECONDS'), FUNDAMENTAL_STAGE_TIMEOUT_SECONDS_DEFAULT, field_name='FUNDAMENTAL_STAGE_TIMEOUT_SECONDS', minimum=0.0, ), fundamental_fetch_timeout_seconds=parse_env_float( os.getenv('FUNDAMENTAL_FETCH_TIMEOUT_SECONDS'), 8.0, field_name='FUNDAMENTAL_FETCH_TIMEOUT_SECONDS', minimum=0.0, ), fundamental_retry_max=parse_env_int(os.getenv('FUNDAMENTAL_RETRY_MAX'), 1, field_name='FUNDAMENTAL_RETRY_MAX', minimum=0), fundamental_cache_ttl_seconds=parse_env_int( os.getenv('FUNDAMENTAL_CACHE_TTL_SECONDS'), 120, field_name='FUNDAMENTAL_CACHE_TTL_SECONDS', minimum=0, ), fundamental_cache_max_entries=parse_env_int( os.getenv('FUNDAMENTAL_CACHE_MAX_ENTRIES'), 256, field_name='FUNDAMENTAL_CACHE_MAX_ENTRIES', minimum=1, ), portfolio_risk_concentration_alert_pct=parse_env_float( os.getenv('PORTFOLIO_RISK_CONCENTRATION_ALERT_PCT'), 35.0, field_name='PORTFOLIO_RISK_CONCENTRATION_ALERT_PCT', minimum=0.0, ), portfolio_risk_drawdown_alert_pct=parse_env_float( os.getenv('PORTFOLIO_RISK_DRAWDOWN_ALERT_PCT'), 15.0, field_name='PORTFOLIO_RISK_DRAWDOWN_ALERT_PCT', minimum=0.0, ), portfolio_risk_stop_loss_alert_pct=parse_env_float( os.getenv('PORTFOLIO_RISK_STOP_LOSS_ALERT_PCT'), 10.0, field_name='PORTFOLIO_RISK_STOP_LOSS_ALERT_PCT', minimum=0.0, ), portfolio_risk_stop_loss_near_ratio=parse_env_float( os.getenv('PORTFOLIO_RISK_STOP_LOSS_NEAR_RATIO'), 0.8, field_name='PORTFOLIO_RISK_STOP_LOSS_NEAR_RATIO', minimum=0.0, ), portfolio_risk_lookback_days=parse_env_int( os.getenv('PORTFOLIO_RISK_LOOKBACK_DAYS'), 180, field_name='PORTFOLIO_RISK_LOOKBACK_DAYS', minimum=1, ), portfolio_fx_update_enabled=os.getenv('PORTFOLIO_FX_UPDATE_ENABLED', 'true').lower() == 'true', screening_enabled=parse_env_bool(os.getenv('SCREENING_ENABLED'), default=False), ) @classmethod def _parse_litellm_yaml(cls, config_path: str) -> List[Dict[str, Any]]: """Parse a standard LiteLLM config YAML file into Router model_list. Supports the ``os.environ/VAR_NAME`` syntax for secret references. Returns an empty list on any error (logged, never raises). """ import logging _logger = logging.getLogger(__name__) try: import yaml except ImportError: _logger.warning("PyYAML not installed; LITELLM_CONFIG ignored. Install with: pip install pyyaml") return [] path = Path(config_path) if not path.is_absolute(): path = Path(__file__).parent.parent / path if not path.exists(): _logger.warning(f"LITELLM_CONFIG file not found: {path}") return [] try: with open(path, encoding='utf-8') as f: yaml_config = yaml.safe_load(f) or {} except Exception as e: _logger.warning(f"Failed to parse LITELLM_CONFIG: {e}") return [] model_list = yaml_config.get('model_list', []) if not isinstance(model_list, list): _logger.warning("LITELLM_CONFIG: model_list must be a list") return [] # Resolve os.environ/ references in string params for entry in model_list: params = entry.get('litellm_params', {}) for key in list(params.keys()): val = params.get(key) if isinstance(val, str) and val.startswith('os.environ/'): env_name = val.split('/', 1)[1] params[key] = os.getenv(env_name, '') _logger.info(f"LITELLM_CONFIG: loaded {len(model_list)} model deployment(s) from {path}") return model_list @classmethod def _parse_llm_channels(cls, channels_str: str) -> List[Dict[str, Any]]: """Backward-compatible channel parser returning only valid channels.""" channels, _issues, _blocks, _blocked_routes = cls._parse_llm_channels_with_issues(channels_str) return channels @classmethod def _parse_llm_channels_with_issues( cls, channels_str: str, ) -> Tuple[List[Dict[str, Any]], List[HermesConfigIssue], bool, List[str]]: """Parse LLM_CHANNELS env var and per-channel env vars. Format: LLM_CHANNELS=aihubmix,deepseek,gemini LLM_AIHUBMIX_PROTOCOL=openai LLM_AIHUBMIX_API_SURFACE=chat_completions LLM_AIHUBMIX_BASE_URL=https://aihubmix.com/v1 LLM_AIHUBMIX_API_KEY=sk-xxx (or LLM_AIHUBMIX_API_KEYS=k1,k2) LLM_AIHUBMIX_MODELS=gpt-5.5,claude-sonnet-4-6 LLM_AIHUBMIX_ENABLED=true """ import logging _logger = logging.getLogger(__name__) channels: List[Dict[str, Any]] = [] issues: List[HermesConfigIssue] = [] blocks_legacy_fallback = False blocked_hermes_routes: List[str] = [] def record_blocked_hermes_routes(raw_models: List[str]) -> None: nonlocal blocks_legacy_fallback blocks_legacy_fallback = True for raw_model in raw_models or [HERMES_DEFAULT_MODEL]: for route_name in hermes_blocked_route_candidates(raw_model): if route_name not in blocked_hermes_routes: blocked_hermes_routes.append(route_name) for raw_name in channels_str.split(','): ch_name = raw_name.strip() if not ch_name: continue ch_lower = ch_name.lower() ch_upper = ch_name.upper() base_url = os.getenv(f'LLM_{ch_upper}_BASE_URL', '').strip() or None if ch_lower == "anspire" and not base_url: base_url = ( os.getenv('ANSPIRE_LLM_BASE_URL') or ANSPIRE_LLM_BASE_URL_DEFAULT ).strip() or None protocol_raw = os.getenv(f'LLM_{ch_upper}_PROTOCOL', '').strip() if ch_lower == "anspire" and not protocol_raw: protocol_raw = "openai" api_surface_raw = os.getenv(f'LLM_{ch_upper}_API_SURFACE', '').strip() enabled_raw = os.getenv(f'LLM_{ch_upper}_ENABLED') if ch_lower == "anspire" and (enabled_raw is None or not enabled_raw.strip()): enabled_raw = os.getenv('ANSPIRE_LLM_ENABLED') enabled = parse_env_bool(enabled_raw, default=True) # API keys: LLM_{NAME}_API_KEYS (multi) > LLM_{NAME}_API_KEY (single) api_keys_raw = os.getenv(f'LLM_{ch_upper}_API_KEYS', '') api_keys = [k.strip() for k in api_keys_raw.split(',') if k.strip()] single_key = os.getenv(f'LLM_{ch_upper}_API_KEY', '').strip() if not api_keys: if single_key: api_keys = [single_key] if not api_keys and ch_lower == "anspire": anspire_keys_raw = os.getenv('ANSPIRE_API_KEYS', '') api_keys = [k.strip() for k in anspire_keys_raw.split(',') if k.strip()] # Models models_raw = os.getenv(f'LLM_{ch_upper}_MODELS', '') raw_models = [m.strip() for m in models_raw.split(',') if m.strip()] if not raw_models and ch_lower == "anspire": anspire_model = ( os.getenv('ANSPIRE_LLM_MODEL') or ANSPIRE_LLM_MODEL_DEFAULT ).strip() if anspire_model: raw_models = [anspire_model] # Disabled channels are inert. In particular, stale values such as # LLM_HERMES_API_SURFACE=responses must not block valid legacy # deployments after Hermes has been explicitly disabled. if not enabled: _logger.info("LLM channel '%s': disabled, skipped", ch_name) continue if not is_supported_llm_channel_api_surface_value(api_surface_raw): issues.append(HermesConfigIssue( f"LLM_{ch_upper}_API_SURFACE", "invalid_api_surface", ( f"Unsupported LLM API surface '{api_surface_raw}'. " f"Supported: {', '.join(SUPPORTED_LLM_CHANNEL_API_SURFACES)}" ), )) if is_reserved_hermes_name(ch_name): record_blocked_hermes_routes(raw_models) _logger.warning( "LLM_%s_API_SURFACE=%s is unsupported; channel skipped", ch_upper, api_surface_raw, ) continue api_surface = normalize_llm_channel_api_surface(api_surface_raw) if is_reserved_hermes_name(ch_name): if api_surface == "responses": issues.append(HermesConfigIssue( f"LLM_{ch_upper}_API_SURFACE", "hermes_responses_unsupported", "The reserved Hermes channel does not support the Responses API surface", )) record_blocked_hermes_routes(raw_models) _logger.warning( "LLM_%s_API_SURFACE=responses is unsupported for reserved Hermes channel; channel skipped", ch_upper, ) continue if not raw_models: raw_models = [HERMES_DEFAULT_MODEL] result = parse_hermes_channel( enabled=enabled, protocol=protocol_raw or HERMES_DEFAULT_PROTOCOL, base_url=base_url or HERMES_DEFAULT_BASE_URL, api_key=single_key, api_keys_raw=api_keys_raw, extra_headers_raw=os.getenv(f'LLM_{ch_upper}_EXTRA_HEADERS', ''), models=raw_models, ) issues.extend(result.issues) blocks_legacy_fallback = blocks_legacy_fallback or result.blocks_legacy_fallback for route_name in result.blocked_route_names: if route_name not in blocked_hermes_routes: blocked_hermes_routes.append(route_name) if result.channel is None: if not enabled: _logger.info("LLM channel '%s': disabled, skipped", ch_name) else: _logger.warning("LLM channel '%s': invalid reserved Hermes channel, skipped", ch_name) continue channels.append(result.channel) _logger.info("LLM channel '%s': Hermes preset with %d model(s)", ch_name, len(result.channel["models"])) continue protocol = resolve_llm_channel_protocol(protocol_raw, base_url=base_url, models=raw_models, channel_name=ch_name) if api_surface == "responses" and protocol != "openai": issues.append(HermesConfigIssue( f"LLM_{ch_upper}_API_SURFACE", "responses_requires_openai_protocol", "Responses API surface currently requires the openai protocol", )) _logger.warning( "LLM_%s_API_SURFACE=responses requires protocol=openai; channel skipped", ch_upper, ) continue incompatible_models = find_incompatible_llm_channel_models( raw_models, protocol, api_surface, base_url, ) if incompatible_models: issues.append(HermesConfigIssue( f"LLM_{ch_upper}_MODELS", "responses_requires_openai_model_provider", ( "Responses API surface requires every model to use the OpenAI " f"provider route; incompatible: {', '.join(incompatible_models[:3])}" ), )) _logger.warning( "LLM_%s_API_SURFACE=responses has non-OpenAI model routes (%s); channel skipped", ch_upper, ", ".join(incompatible_models[:3]), ) continue models = [normalize_llm_channel_model(m, protocol, base_url) for m in raw_models] # Extra headers (JSON string, optional) extra_headers_raw = os.getenv(f'LLM_{ch_upper}_EXTRA_HEADERS', '').strip() extra_headers = None if extra_headers_raw: try: extra_headers = json.loads(extra_headers_raw) except json.JSONDecodeError: _logger.warning(f"LLM_{ch_upper}_EXTRA_HEADERS: invalid JSON, ignored") if protocol_raw and canonicalize_llm_channel_protocol(protocol_raw) not in SUPPORTED_LLM_CHANNEL_PROTOCOLS: _logger.warning( "LLM_%s_PROTOCOL=%s is unsupported; auto-detected protocol=%s", ch_upper, protocol_raw, protocol or "unknown", ) if not api_keys and channel_allows_empty_api_key(protocol, base_url): api_keys = [""] if not api_keys: _logger.warning(f"LLM channel '{ch_name}': no API key configured, skipped") continue if not models: _logger.warning(f"LLM channel '{ch_name}': no models configured, skipped") continue channels.append({ 'name': ch_name.lower(), 'protocol': protocol, 'api_surface': api_surface, 'enabled': enabled, 'base_url': base_url, 'api_keys': api_keys, 'models': models, 'extra_headers': extra_headers, }) _logger.info(f"LLM channel '{ch_name}': {len(models)} model(s), {len(api_keys)} key(s)") surface_conflicts = find_llm_channel_surface_conflicts(channels) if surface_conflicts: conflicting_models = set(surface_conflicts) for model, surfaces in surface_conflicts.items(): issues.append(HermesConfigIssue( "LLM_CHANNELS", "mixed_api_surfaces_for_route", ( f"LLM route alias '{model}' is declared with multiple API surfaces: " f"{', '.join(surfaces)}" ), )) _logger.warning( "LLM route alias '%s' mixes API surfaces (%s); conflicting channels skipped", model, ", ".join(surfaces), ) channels = [ channel for channel in channels if not { normalize_llm_channel_model( str(model), str(channel.get("protocol") or ""), str(channel.get("base_url") or ""), ) for model in channel.get("models") or [] }.intersection(conflicting_models) ] return channels, issues, blocks_legacy_fallback, blocked_hermes_routes @classmethod def _channels_to_model_list(cls, channels: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Convert parsed LLM channels to LiteLLM Router model_list format. Mapping follows: - LiteLLM providers: https://docs.litellm.ai/docs/providers - LiteLLM model_list 语义: https://docs.litellm.ai/docs/proxy/configs#the-model_list-key """ surface_conflicts = find_llm_channel_surface_conflicts(channels) if surface_conflicts: raise ValueError( "LLM route aliases cannot mix API surfaces: " + ", ".join(sorted(surface_conflicts)) ) model_list: List[Dict[str, Any]] = [] for ch in channels: hermes_refs = { str(ref.get("route_model") or ""): ref for ref in (ch.get("model_refs") or []) if isinstance(ref, dict) } for model_name in ch['models']: for api_key in ch['api_keys']: model_ref = hermes_refs.get(str(model_name)) wire_model = str((model_ref or {}).get("wire_model") or model_name) api_surface = normalize_llm_channel_api_surface(ch.get("api_surface")) wire_model = apply_litellm_api_surface(wire_model, api_surface) litellm_params: Dict[str, Any] = { 'model': wire_model, } if api_key: litellm_params['api_key'] = api_key if ch['base_url']: litellm_params['api_base'] = ch['base_url'] # Auto-inject aihubmix sponsored header headers = dict(ch.get('extra_headers') or {}) if ch['base_url'] and 'aihubmix.com' in ch['base_url']: headers.setdefault('APP-Code', 'GPIJ3886') if headers: litellm_params['extra_headers'] = headers entry: Dict[str, Any] = { 'model_name': model_name, 'litellm_params': litellm_params, } if ch.get("is_hermes") or is_reserved_hermes_name(str(ch.get("name") or "")): entry["model_info"] = hermes_model_info( str((model_ref or {}).get("display_model") or "") ) elif api_surface == "responses": entry["model_info"] = {"dsa_api_surface": "responses"} model_list.append(entry) return model_list @classmethod def _legacy_keys_to_model_list( cls, gemini_keys: List[str], anthropic_keys: List[str], openai_keys: List[str], openai_base_url: Optional[str], deepseek_keys: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Build Router model_list from legacy per-provider keys (backward compat). Returns a model_list where each provider's keys are expanded into deployments, keyed by placeholder model_name tokens. The analyzer resolves actual model_names at call time from LITELLM_MODEL / LITELLM_FALLBACK_MODELS. Compatibility note: - LiteLLM OpenAI-compatible 约定: https://docs.litellm.ai/docs/providers/openai_compatible - OpenAI 请求与鉴权约定: https://platform.openai.com/docs/api-reference/making-requests / https://platform.openai.com/docs/api-reference/authentication """ model_list: List[Dict[str, Any]] = [] # Gemini keys for k in gemini_keys: if k and len(k) >= 8: model_list.append({ 'model_name': '__legacy_gemini__', 'litellm_params': {'model': '__legacy_gemini__', 'api_key': k}, }) # Anthropic keys for k in anthropic_keys: if k and len(k) >= 8: model_list.append({ 'model_name': '__legacy_anthropic__', 'litellm_params': {'model': '__legacy_anthropic__', 'api_key': k}, }) # OpenAI-compatible keys for k in openai_keys: if k and len(k) >= 8: params: Dict[str, Any] = {'model': '__legacy_openai__', 'api_key': k} if openai_base_url: params['api_base'] = openai_base_url if openai_base_url and 'aihubmix.com' in openai_base_url: params['extra_headers'] = {'APP-Code': 'GPIJ3886'} model_list.append({ 'model_name': '__legacy_openai__', 'litellm_params': params, }) # DeepSeek keys (native litellm provider — auto-resolves api_base) for k in (deepseek_keys or []): if k and len(k) >= 8: model_list.append({ 'model_name': '__legacy_deepseek__', 'litellm_params': { 'model': '__legacy_deepseek__', 'api_key': k, }, }) return model_list @classmethod def _parse_stock_email_groups(cls) -> List[Tuple[List[str], List[str]]]: """ Parse STOCK_GROUP_N and EMAIL_GROUP_N from environment. Returns [(stocks, emails), ...] ordered by group index. Stock codes are canonicalized via normalize_stock_code so that runtime routing matches the same equivalence used in validation. """ from data_provider.base import normalize_stock_code groups: dict = {} stock_re = re.compile(r'^STOCK_GROUP_(\d+)$', re.IGNORECASE) email_re = re.compile(r'^EMAIL_GROUP_(\d+)$', re.IGNORECASE) for key in os.environ: m = stock_re.match(key) if m: idx = int(m.group(1)) val = os.environ[key].strip() groups.setdefault(idx, {})['stocks'] = [ normalize_stock_code(c.strip()) for c in val.split(',') if c.strip() ] m = email_re.match(key) if m: idx = int(m.group(1)) val = os.environ[key].strip() groups.setdefault(idx, {})['emails'] = [e.strip() for e in val.split(',') if e.strip()] result = [] for idx in sorted(groups.keys()): g = groups[idx] if 'stocks' in g and 'emails' in g and g['stocks'] and g['emails']: result.append((g['stocks'], g['emails'])) return result @classmethod def _parse_report_type(cls, value: str) -> str: """Parse REPORT_TYPE, fallback to simple for invalid values (supports brief).""" v = (value or 'simple').strip().lower() if v in ('simple', 'full', 'brief'): return v import logging logging.getLogger(__name__).warning( f"REPORT_TYPE '{value}' invalid, fallback to 'simple' (valid: simple/full/brief)" ) return 'simple' @classmethod def _get_env_file_value(cls, key: str) -> Optional[str]: """Read one config key directly from the active `.env` file.""" env_file = os.getenv("ENV_FILE") env_path = Path(env_file) if env_file else (Path(__file__).parent.parent / ".env") if not env_path.exists(): return None try: env_values = dotenv_values(env_path) except Exception as exc: # pragma: no cover - defensive branch logging.getLogger(__name__).warning( "Failed to read %s while resolving %s: %s", env_path, key, exc, ) return None value = env_values.get(key) if value is None: return None return unescape_compose_sensitive_env_value(key, str(value)) @classmethod def _resolve_env_value( cls, key: str, *, default: Optional[str] = None, prefer_env_file: bool = False, ) -> Optional[str]: """Resolve one env value, optionally preferring the persisted `.env` copy.""" env_value = os.getenv(key) file_value = cls._get_env_file_value(key) should_prefer_file = prefer_env_file or key in cls._WEBUI_RUNTIME_ENV_FILE_PRIORITY_KEYS if should_prefer_file and file_value is not None: if env_value is not None and cls._has_bootstrap_runtime_env_override(key): return env_value return file_value if env_value is not None: return env_value if file_value is not None: return file_value return default @classmethod def _capture_bootstrap_runtime_env_overrides(cls) -> None: """Remember process-provided runtime env overrides before dotenv mutates os.environ. Called by ``setup_env()`` **before** ``load_dotenv()``, so ``os.environ`` only contains genuine process-level values (Docker ``environment:``, Dockerfile ``ENV``, shell exports, etc.). A key is treated as an explicit override when it is present in ``os.environ`` and either: * absent from the persisted ``.env`` file, **or** * present with a **different** value. When both values are identical, the distinction is irrelevant and we do **not** flag the key, so that a later ``.env`` update by WebUI can take effect on config reload without requiring a container restart. """ if cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES_CAPTURED: return explicit_overrides = set() present_keys = set() for key in cls._WEBUI_RUNTIME_ENV_FILE_PRIORITY_KEYS: env_value = os.environ.get(key) if env_value is None: continue present_keys.add(key) file_value = cls._get_env_file_value(key) if file_value is None or env_value != file_value: explicit_overrides.add(key) cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES = frozenset(explicit_overrides) cls._BOOTSTRAP_RUNTIME_ENV_PRESENT_KEYS = frozenset(present_keys) cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES_CAPTURED = True @classmethod def _has_bootstrap_runtime_env_override(cls, key: str) -> bool: cls._capture_bootstrap_runtime_env_overrides() return key in cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES @classmethod def _had_bootstrap_runtime_env_key(cls, key: str) -> bool: cls._capture_bootstrap_runtime_env_overrides() return key in cls._BOOTSTRAP_RUNTIME_ENV_PRESENT_KEYS @classmethod def _resolve_report_language_env_value( cls, preexisting_env_value: Optional[str], ) -> str: """Resolve REPORT_LANGUAGE while preserving real process env overrides.""" file_value = cls._get_env_file_value("REPORT_LANGUAGE") env_value = os.getenv("REPORT_LANGUAGE") if preexisting_env_value is not None: env_text = preexisting_env_value.strip() file_text = (file_value or "").strip() if file_text and env_text and env_text.lower() != file_text.lower(): env_file = os.getenv("ENV_FILE") or str(Path(__file__).parent.parent / ".env") logging.getLogger(__name__).warning( "REPORT_LANGUAGE environment value '%s' overrides %s ('%s')", preexisting_env_value, env_file, file_value, ) return preexisting_env_value if file_value is not None: return file_value return env_value or "zh" @classmethod def _parse_report_language(cls, value: Optional[str]) -> str: """Parse REPORT_LANGUAGE, fallback to zh for invalid values.""" normalized = normalize_report_language(value, default="zh") raw = (value or "").strip() if raw and not is_supported_report_language_value(raw): logging.getLogger(__name__).warning( "REPORT_LANGUAGE '%s' invalid, fallback to 'zh' (valid: zh/en/ko)", value, ) return normalized @classmethod def _parse_news_strategy_profile(cls, value: Optional[str]) -> str: """Parse NEWS_STRATEGY_PROFILE, fallback to short for invalid values.""" normalized = normalize_news_strategy_profile(value) raw = (value or "short").strip().lower() if raw != normalized: logging.getLogger(__name__).warning( "NEWS_STRATEGY_PROFILE '%s' invalid, fallback to 'short' " "(valid: ultra_short/short/medium/long)", value, ) return normalized def get_effective_news_window_days(self) -> int: """Return effective news window days after profile + max-age merge.""" return resolve_news_window_days( news_max_age_days=self.news_max_age_days, news_strategy_profile=self.news_strategy_profile, ) @classmethod def _parse_market_review_region(cls, value: str) -> str: """解析大盘复盘市场区域,非法值记录警告后回退为 cn""" normalized = normalize_market_review_region_lenient(value) if normalized is not None: return normalized logging.getLogger(__name__).warning( f"MARKET_REVIEW_REGION 配置值 '{value}' 无效,已回退为默认值 'cn'(合法值:cn / hk / us / jp / kr / both;支持逗号分隔有效值)" ) return 'cn' @classmethod def _parse_market_review_color_scheme(cls, value: str) -> str: """Parse market-review index change color scheme.""" import logging v = (value or 'green_up').strip().lower().replace('-', '_') if v in ('green_up', 'red_up'): return v logging.getLogger(__name__).warning( "MARKET_REVIEW_COLOR_SCHEME 配置值 '%s' 无效,已回退为默认值 'green_up'(合法值:green_up / red_up)", value, ) return 'green_up' @classmethod def _parse_md2img_engine(cls, value: str) -> str: """Parse MD2IMG_ENGINE, fallback to wkhtmltoimage for invalid values (Issue #455).""" v = (value or 'wkhtmltoimage').strip().lower() if v in ('wkhtmltoimage', 'markdown-to-file', 'playwright'): return v if v: import logging logging.getLogger(__name__).warning( f"MD2IMG_ENGINE '{value}' invalid, fallback to 'wkhtmltoimage' " "(valid: wkhtmltoimage | markdown-to-file | playwright)" ) return 'wkhtmltoimage' @classmethod def _resolve_realtime_source_priority(cls) -> str: """ Resolve realtime source priority with automatic tushare injection. When TUSHARE_TOKEN is configured but REALTIME_SOURCE_PRIORITY is not explicitly set, automatically prepend 'tushare' to the default priority so that the paid data source is utilized for realtime quotes as well. """ explicit = os.getenv('REALTIME_SOURCE_PRIORITY') default_priority = 'tencent,akshare_sina,efinance,akshare_em' if explicit: # User explicitly set priority, respect it return explicit tushare_token = os.getenv('TUSHARE_TOKEN', '').strip() if tushare_token: # Token configured but no explicit priority override # Prepend tushare so the paid source is tried first import logging logger = logging.getLogger(__name__) resolved = f'tushare,{default_priority}' logger.info( f"TUSHARE_TOKEN detected, auto-injecting tushare into realtime priority: {resolved}" ) return resolved return default_priority @classmethod def reset_instance(cls) -> None: """重置单例(主要用于测试)""" cls._instance = None cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES_CAPTURED = False cls._BOOTSTRAP_RUNTIME_ENV_OVERRIDES = frozenset() cls._BOOTSTRAP_RUNTIME_ENV_PRESENT_KEYS = frozenset() def has_searxng_enabled(self) -> bool: """Whether SearXNG fallback is enabled via self-hosted or public mode.""" return bool(self.searxng_base_urls) or bool(self.searxng_public_instances_enabled) def has_search_capability_enabled(self) -> bool: """Whether any search provider is configured or SearXNG fallback is enabled.""" return bool( self.anspire_api_keys or self.bocha_api_keys or self.minimax_api_keys or self.tavily_api_keys or self.brave_api_keys or self.serpapi_keys or self.has_searxng_enabled() ) def is_agent_available(self) -> bool: """Check whether agent capabilities are usable. Decision table: +-----------------------+----------------------------+-----------------+ | AGENT_MODE env | Agent-safe route available | Result | +-----------------------+----------------------------+-----------------+ | ``false`` (explicit) | any | False | | ``true`` | yes | True | | ``true`` | no | False | | not set (default) | yes | True | | not set (default) | no | False | +-----------------------+----------------------------+-----------------+ ``AGENT_MODE=true`` expresses user intent, but Phase 3 Hermes safety still requires a non-Hermes Agent route. Hermes-only deployments cannot satisfy Agent tool roundtrip support; mixed routes are usable only via their non-Hermes deployments. ``AGENT_MODE=false`` remains an explicit kill-switch. Explicit local CLI Agent backends are unavailable because they are text generation backends, not Agent tool-calling runtimes. """ if (self.agent_generation_backend or AUTO_AGENT_BACKEND_ID).strip().lower() in GENERATION_ONLY_BACKEND_IDS: return False # Phase 3 no longer lets AGENT_MODE=true bypass tool-route safety. if self._agent_mode_explicit: if not self.agent_mode: return False primary_model = get_effective_agent_primary_model(self) origins = route_deployment_origins(self.llm_model_list, primary_model) return not origins.is_hermes_only # Auto-detect: Agent inherits global model when AGENT_LITELLM_MODEL is empty. primary_model = get_effective_agent_primary_model(self) if not primary_model: return False origins = route_deployment_origins(self.llm_model_list, primary_model) return not origins.is_hermes_only def refresh_stock_list(self) -> None: """ 热读取 STOCK_LIST 环境变量并更新配置中的自选股列表 支持两种配置方式: 1. .env 文件(本地开发、定时任务模式) - 修改后下次执行自动生效 2. 系统环境变量(GitHub Actions、Docker) - 启动时固定,运行中不变 """ # 优先从 .env 文件读取最新配置,这样即使在容器环境中修改了 .env 文件, # 也能获取到最新的股票列表配置 env_file = os.getenv("ENV_FILE") env_path = Path(env_file) if env_file else (Path(__file__).parent.parent / '.env') stock_list_str = '' if env_path.exists(): # 直接从 .env 文件读取最新的配置 env_values = dotenv_values(env_path) stock_list_str = (env_values.get('STOCK_LIST') or '').strip() # 如果 .env 文件不存在或未配置,才尝试从系统环境变量读取 if not stock_list_str: stock_list_str = os.getenv('STOCK_LIST', '') stock_list = [ (c or "").strip().upper() for c in split_stock_list(stock_list_str) if (c or "").strip() ] self.stock_list = stock_list def validate_structured(self) -> List[ConfigIssue]: """Return structured validation issues with severity levels. Covers all three LLM configuration tiers introduced by PR #494: - LITELLM_CONFIG (YAML) - LLM_CHANNELS (env) - Legacy per-provider keys Returns: List of ConfigIssue objects, each carrying a severity ("error" | "warning" | "info"), a human-readable message, and the primary environment variable / field name it relates to. """ issues: List[ConfigIssue] = [] # --- Stock list --- if not self.stock_list: issues.append(ConfigIssue( severity="error", message="未配置 STOCK_LIST。请设置至少一个股票代码,例如:600519,hk00700,AAPL。", field="STOCK_LIST", )) elif self.stock_email_groups: from data_provider.base import normalize_stock_code configured_stock_set = { normalize_stock_code(code) for code in self.stock_list if (code or "").strip() } missing_group_stocks_dict: Dict[str, None] = {} for stocks, _emails in self.stock_email_groups: for stock in stocks: raw = (stock or "").strip() if not raw: continue normalized_stock = normalize_stock_code(stock) if normalized_stock in configured_stock_set: continue if normalized_stock in missing_group_stocks_dict: continue missing_group_stocks_dict[normalized_stock] = None missing_group_stocks = list(missing_group_stocks_dict.keys()) if missing_group_stocks: issues.append(ConfigIssue( severity="warning", message=( "检测到 STOCK_GROUP_N 中存在未包含在 STOCK_LIST 内的股票:" f"{', '.join(missing_group_stocks[:6])}。" "STOCK_GROUP_N 仅用于邮件路由,不会扩大分析范围;" "请先将这些股票加入 STOCK_LIST。" ), field="STOCK_GROUP_N", )) # --- Data sources (informational only) --- if not self.tushare_token: issues.append(ConfigIssue( severity="info", message="未配置 Tushare Token,将使用其他数据源", field="TUSHARE_TOKEN", )) # --- Generation backend selection --- generation_backend = (self.generation_backend or LITELLM_BACKEND_ID).strip().lower() generation_fallback_backend = str(self.generation_fallback_backend or "").strip().lower() agent_generation_backend = ( self.agent_generation_backend or AUTO_AGENT_BACKEND_ID ).strip().lower() agent_backend = (self.agent_backend or "auto").strip().lower() if generation_backend not in SUPPORTED_GENERATION_BACKENDS: issues.append(ConfigIssue( severity="error", message=( "GENERATION_BACKEND 当前支持 " f"{'、'.join(sorted(SUPPORTED_GENERATION_BACKENDS))}。" f"已配置的值为:{generation_backend}。" ), field="GENERATION_BACKEND", )) if generation_fallback_backend and generation_fallback_backend == generation_backend: generation_fallback_backend = "" if generation_fallback_backend and generation_fallback_backend != LITELLM_BACKEND_ID: issues.append(ConfigIssue( severity="error", message=( "GENERATION_FALLBACK_BACKEND 当前支持 litellm、与 primary 相同的 no-op 值,或空字符串。" f"已配置的值为:{generation_fallback_backend}。" ), field="GENERATION_FALLBACK_BACKEND", )) if agent_generation_backend not in SUPPORTED_AGENT_GENERATION_BACKENDS: agent_ui_backends = "、".join(sorted(SUPPORTED_AGENT_UI_BACKENDS)) local_toolless_backends = "、".join(sorted(GENERATION_ONLY_BACKEND_IDS)) issues.append(ConfigIssue( severity="error", message=( f"AGENT_GENERATION_BACKEND 当前支持 {agent_ui_backends};" f"local CLI backend({local_toolless_backends})仅作为显式 unsupported diagnostic 保留," "不支持 Agent 工具调用。" f"已配置的值为:{agent_generation_backend}。" ), field="AGENT_GENERATION_BACKEND", )) if agent_backend not in SUPPORTED_AGENT_BACKENDS: issues.append(ConfigIssue( severity="error", message=( "AGENT_BACKEND 当前支持 auto、litellm、codex_app_server。" f"已配置的值为:{agent_backend}。" ), field="AGENT_BACKEND", code="capability_unsupported", )) if agent_backend == "codex_app_server" and self.agent_arch != "single": issues.append(ConfigIssue( severity="error", message="Codex 本地 Agent 当前只支持单 Agent 问股,请将 AGENT_ARCH 设为 single。", field="AGENT_ARCH", code="unsupported_agent_arch", )) litellm_model_lower = (self.litellm_model or "").strip().lower() local_model_prefix = next( ( backend_id for backend_id in GENERATION_ONLY_BACKEND_IDS if litellm_model_lower.startswith(f"{backend_id}/") ), "", ) if local_model_prefix: issues.append(ConfigIssue( severity="error", message=( f"{local_model_prefix} 是 GENERATION_BACKEND,不是 LiteLLM provider。" f"请不要使用 LITELLM_MODEL={local_model_prefix}/...。" ), field="LITELLM_MODEL", )) if generation_backend == OPENCODE_CLI_BACKEND_ID: opencode_model = (self.opencode_cli_model or "").strip() unsafe_model = bool(opencode_model) and ( any(ch.isspace() for ch in opencode_model) or any( marker in opencode_model for marker in ("|", ">", "<", ";", "`", "&&", "||", "$") ) ) if unsafe_model: issues.append(ConfigIssue( severity="error", message=( "OPENCODE_CLI_MODEL 是可选的 OpenCode 模型覆盖值。" "配置时会作为单个 --model 参数传给 OpenCode,不能包含空白或 shell 元字符;" "不配置时 DSA 将使用 OpenCode 自身默认模型。" ), field="OPENCODE_CLI_MODEL", )) # --- LLM availability --- for raw_issue in self.llm_channel_config_issues or []: issues.append(ConfigIssue( severity=raw_issue.get("severity", "error"), # type: ignore[arg-type] message=raw_issue.get("message", "LLM channel configuration is invalid"), field=raw_issue.get("field", "LLM_CHANNELS"), code=raw_issue.get("code", "invalid_channel_config"), )) # llm_model_list is populated for YAML / channels / managed legacy keys. # Other LiteLLM-native providers (for example cohere/*) run through the # direct litellm env path and therefore do not populate llm_model_list. has_direct_env_model = bool(self.litellm_model) and _uses_direct_env_provider(self.litellm_model) local_generation_backend = generation_backend in LOCAL_CLI_GENERATION_BACKEND_IDS if not local_generation_backend and not self.llm_model_list and not has_direct_env_model: if self.litellm_config_path: issues.append(ConfigIssue( severity="error", message=( "已配置 LITELLM_CONFIG,但未解析出可用模型。" "请检查 YAML 中的 model_list、litellm_params 和环境变量引用。" ), field="LITELLM_CONFIG", )) elif self.llm_channel_names: issues.append(ConfigIssue( severity="error", message=( "已配置 LLM_CHANNELS,但未解析出可用模型渠道。" "请检查对应 LLM__API_KEY(S)、" "LLM__MODELS、LLM__PROTOCOL 或 Base URL。" ), field="LLM_CHANNELS", )) else: issues.append(ConfigIssue( severity="error", message=( "未配置任何可用的 AI 模型接入。请至少配置 ANSPIRE_API_KEYS、" "AIHUBMIX_KEY、GEMINI_API_KEY、ANTHROPIC_API_KEY、" "OPENAI_API_KEY 或 DEEPSEEK_API_KEY 中的一个,或配置 " "LITELLM_CONFIG / LLM_CHANNELS 可用模型渠道。" ), field="LITELLM_CONFIG", )) elif not local_generation_backend and not self.litellm_model: issues.append(ConfigIssue( severity="info", message=( "尚未明确指定主模型,系统将自动从可用 API Key 推断。" "建议尽早配置主模型(格式如 gemini/gemini-3.1-pro-preview)" ), field="LITELLM_MODEL", )) available_router_models = get_configured_llm_models(self.llm_model_list) available_router_model_set = set(available_router_models) def _has_runtime_source_for_model(model: str) -> bool: if not model or _uses_direct_env_provider(model): return True provider = _get_litellm_provider(model) if provider in {"gemini", "vertex_ai"}: return any(k and len(k) >= 8 for k in (self.gemini_api_keys or [])) if provider == "anthropic": return any(k and len(k) >= 8 for k in (self.anthropic_api_keys or [])) if provider == "deepseek": return any(k and len(k) >= 8 for k in (self.deepseek_api_keys or [])) if provider == "openai": return any(k and len(k) >= 8 for k in (self.openai_api_keys or [])) return False configured_agent_primary_model = bool((self.agent_litellm_model or "").strip()) effective_agent_primary_model = get_effective_agent_primary_model(self) if available_router_model_set: if self.litellm_model: origins = route_deployment_origins(self.llm_model_list, self.litellm_model) if origins.is_mixed: issues.append(ConfigIssue( severity="error", message=( "Hermes/non-Hermes mixed generation routes are not supported in Phase 3. " "请选择纯 Hermes 或纯非 Hermes 主模型。" ), field="LITELLM_MODEL", code="mixed_hermes_route_unsupported", )) if ( self.litellm_model and not _uses_direct_env_provider(self.litellm_model) and not _matches_exact_route(self.litellm_model, available_router_model_set) ): issues.append(ConfigIssue( severity="error", message=( "已配置的主模型未出现在当前渠道或高级模型路由配置中。" f" 当前可用模型:{', '.join(available_router_models[:6])}" ), field="LITELLM_MODEL", )) if configured_agent_primary_model and effective_agent_primary_model: origins = route_deployment_origins(self.llm_model_list, effective_agent_primary_model) if origins.is_hermes_only: issues.append(ConfigIssue( severity="error", message=( "Hermes-only route 不能作为 Agent 主模型。" "请选择包含非 Hermes deployment 的 Agent-safe route。" ), field="AGENT_LITELLM_MODEL", code="explicit_agent_model_no_safe_deployment", )) if ( configured_agent_primary_model and effective_agent_primary_model and not _uses_direct_env_provider(effective_agent_primary_model) and not _matches_exact_route(effective_agent_primary_model, available_router_model_set) ): issues.append(ConfigIssue( severity="error", message=( "已配置的 Agent 主模型未出现在当前渠道或高级模型路由配置中。" f" 当前可用模型:{', '.join(available_router_models[:6])}" ), field="AGENT_LITELLM_MODEL", )) mixed_fallbacks = [ model for model in (self.litellm_fallback_models or []) if route_deployment_origins(self.llm_model_list, model).is_mixed ] if mixed_fallbacks: issues.append(ConfigIssue( severity="error", message=( "Hermes/non-Hermes mixed generation routes are not supported as fallback models in Phase 3: " f"{', '.join(mixed_fallbacks[:3])}" ), field="LITELLM_FALLBACK_MODELS", code="mixed_hermes_route_unsupported", )) invalid_fallbacks = [ model for model in (self.litellm_fallback_models or []) if model and not _matches_exact_route(model, available_router_model_set) and not _uses_direct_env_provider(model) ] if invalid_fallbacks: issues.append(ConfigIssue( severity="warning", message=( "备选模型中包含未在当前渠道或高级模型路由配置中声明的模型:" f"{', '.join(invalid_fallbacks[:3])}" ), field="LITELLM_FALLBACK_MODELS", )) if ( self.vision_model and not _uses_direct_env_provider(self.vision_model) and not _matches_exact_route(self.vision_model, available_router_model_set) ): issues.append(ConfigIssue( severity="warning", message=( "VISION_MODEL 未出现在当前渠道声明中。" f" 当前可用模型:{', '.join(available_router_models[:6])}" ), field="VISION_MODEL", )) if self.vision_model and route_has_hermes(self.llm_model_list, self.vision_model): issues.append(ConfigIssue( severity="error", message=( "Hermes Phase 3 未验证 Vision 能力,VISION_MODEL 不能选择包含 Hermes deployment 的 route。" ), field="VISION_MODEL", code="hermes_vision_unsupported", )) elif ( configured_agent_primary_model and effective_agent_primary_model and not _has_runtime_source_for_model(effective_agent_primary_model) ): issues.append(ConfigIssue( severity="error", message=( "已配置 Agent 主模型,但未找到可用的运行时来源" "(启用渠道或匹配的 API Key)。" ), field="AGENT_LITELLM_MODEL", )) # --- Search engine (informational only) --- if not self.has_search_capability_enabled(): issues.append(ConfigIssue( severity="info", message="未配置搜索引擎能力 (Bocha/MiniMax/Tavily/Brave/SerpAPI/SearXNG),新闻搜索功能将不可用", field="BOCHA_API_KEYS", )) # --- Notification channels --- has_notification = bool( self.wechat_webhook_url or self.dingtalk_webhook_url or self.feishu_webhook_url or ( (self.feishu_app_id or "") and (self.feishu_app_secret or "") and (self.feishu_chat_id or "") ) or (self.telegram_bot_token and self.telegram_chat_id) or (self.email_sender and self.email_password) or (self.pushover_user_key and self.pushover_api_token) or _has_ntfy_topic_endpoint(self.ntfy_url) or ( self.gotify_url and (self.gotify_token or "").strip() and _has_gotify_base_url(self.gotify_url) ) or self.pushplus_token or self.serverchan3_sendkey or self.custom_webhook_urls or self.astrbot_url or (self.discord_bot_token and self.discord_main_channel_id) or self.discord_webhook_url or self.slack_webhook_url or (self.slack_bot_token and self.slack_channel_id) ) if not has_notification: issues.append(ConfigIssue( severity="warning", message="未配置通知渠道,将不发送推送通知", field="WECHAT_WEBHOOK_URL", )) has_telegram_token = bool((self.telegram_bot_token or "").strip()) has_telegram_chat_id = bool((self.telegram_chat_id or "").strip()) if has_telegram_token != has_telegram_chat_id: issues.append(ConfigIssue( severity="error", message="Telegram 通知配置不完整:TELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID 必须同时配置。", field="TELEGRAM_CHAT_ID" if has_telegram_token else "TELEGRAM_BOT_TOKEN", )) has_email_sender = bool((self.email_sender or "").strip()) has_email_password = bool((self.email_password or "").strip()) if has_email_sender != has_email_password: issues.append(ConfigIssue( severity="error", message="邮件通知配置不完整:EMAIL_SENDER 和 EMAIL_PASSWORD 必须同时配置。", field="EMAIL_PASSWORD" if has_email_sender else "EMAIL_SENDER", )) def _warn_if_webhook_url_invalid(field: str, value: Optional[str]) -> None: raw_url = (value or "").strip() if not raw_url: return parsed = urlparse(raw_url) if parsed.scheme.lower() in {"http", "https"} and parsed.netloc: return issues.append(ConfigIssue( severity="warning", message=f"{field} 看起来不是有效 URL,请确认是否以 http:// 或 https:// 开头。", field=field, )) for field, value in ( ("WECHAT_WEBHOOK_URL", self.wechat_webhook_url), ("FEISHU_WEBHOOK_URL", self.feishu_webhook_url), ("DINGTALK_WEBHOOK_URL", self.dingtalk_webhook_url), ("DISCORD_WEBHOOK_URL", self.discord_webhook_url), ("SLACK_WEBHOOK_URL", self.slack_webhook_url), ("ASTRBOT_URL", self.astrbot_url), ): _warn_if_webhook_url_invalid(field, value) for custom_url in self.custom_webhook_urls: _warn_if_webhook_url_invalid("CUSTOM_WEBHOOK_URLS", custom_url) if self.ntfy_url and not _has_ntfy_topic_endpoint(self.ntfy_url): issues.append(ConfigIssue( severity="error", message="NTFY_URL 必须包含 topic path,例如 https://ntfy.sh/my-topic", field="NTFY_URL", )) if self.gotify_url and not _has_gotify_base_url(self.gotify_url): issues.append(ConfigIssue( severity="error", message="GOTIFY_URL 必须是 Gotify server base URL,不包含 /message,例如 https://gotify.example", field="GOTIFY_URL", )) if ( self.gotify_url and _has_gotify_base_url(self.gotify_url) and not (self.gotify_token or "").strip() ): issues.append(ConfigIssue( severity="warning", message="已配置 GOTIFY_URL,但缺少 GOTIFY_TOKEN,Gotify 渠道不会启用", field="GOTIFY_TOKEN", )) if self.notification_quiet_hours: try: parse_notification_quiet_hours(self.notification_quiet_hours) except ValueError as exc: issues.append(ConfigIssue( severity="error", message=f"通知静默时段配置无效:{exc}", field="NOTIFICATION_QUIET_HOURS", )) if self.notification_timezone: try: validate_notification_timezone(self.notification_timezone) except ValueError as exc: issues.append(ConfigIssue( severity="error", message=f"通知时区配置无效:{exc}", field="NOTIFICATION_TIMEZONE", )) if self.notification_min_severity and not is_supported_notification_severity(self.notification_min_severity): issues.append(ConfigIssue( severity="error", message=( "通知最低级别配置无效,允许值:" f"{', '.join(NOTIFICATION_SEVERITIES)}" ), field="NOTIFICATION_MIN_SEVERITY", )) if self.notification_daily_digest_enabled: issues.append(ConfigIssue( severity="warning", message=( "NOTIFICATION_DAILY_DIGEST_ENABLED 当前为预留配置;" "P4 不会发送每日摘要或持久化摘要内容。" ), field="NOTIFICATION_DAILY_DIGEST_ENABLED", )) has_feishu_app_id = bool((self.feishu_app_id or "").strip()) has_feishu_app_secret = bool((self.feishu_app_secret or "").strip()) has_feishu_app_credentials_complete = has_feishu_app_id and has_feishu_app_secret has_feishu_app_credentials = has_feishu_app_id or has_feishu_app_secret has_feishu_doc_token = bool((self.feishu_folder_token or "").strip()) has_feishu_full_cloud_doc_credentials = ( has_feishu_app_credentials_complete and has_feishu_doc_token ) has_feishu_stream_route = bool(self.feishu_stream_enabled and has_feishu_app_credentials_complete) has_feishu_app_notification_route = is_feishu_app_bot_configured(self) if ( has_feishu_app_credentials and not has_feishu_full_cloud_doc_credentials and not is_feishu_static_configured(self) and not has_feishu_stream_route and not has_feishu_app_notification_route ): suggestions = [] if has_feishu_app_credentials_complete: suggestions.append("配置 FEISHU_CHAT_ID 开启 App Bot 主动推送") suggestions.append("开启 FEISHU_STREAM_ENABLED 使用应用机器人事件订阅") else: suggestions.append("补齐 FEISHU_APP_ID / FEISHU_APP_SECRET 后配置 FEISHU_CHAT_ID 开启 App Bot 主动推送") suggestions.append("配置 FEISHU_WEBHOOK_URL 使用自定义机器人 Webhook 推送") issues.append(ConfigIssue( severity="warning", message="仅配置 FEISHU_APP_ID / FEISHU_APP_SECRET 不会开启飞书静态通知。" + " 请选择以下方式之一:" + ";".join(suggestions) + "。", field="FEISHU_CHAT_ID", )) # --- Deprecated field migration hints --- if os.getenv("OPENAI_VISION_MODEL"): issues.append(ConfigIssue( severity="info", message=( "OPENAI_VISION_MODEL 已废弃,请改用 VISION_MODEL。" "当前值已自动迁移,建议更新配置文件以消除此提示。" ), field="OPENAI_VISION_MODEL", )) # --- Vision key availability --- # Only warn when user explicitly set VISION_MODEL (or OPENAI_VISION_MODEL alias). # Skipped when vision_model is empty (Vision not intentionally configured). if self.vision_model: # Maps provider prefix → the corresponding key list tracked by Config. # vertex_ai shares gemini keys; other LiteLLM-native providers are not # in this map (their keys come from env vars, which we cannot inspect here). _VISION_KEY_MAP = { "gemini": self.gemini_api_keys, "vertex_ai": self.gemini_api_keys, "anthropic": self.anthropic_api_keys, "openai": self.openai_api_keys, "deepseek": self.deepseek_api_keys, } # Derive the primary model's provider prefix so that its key is also # checked even when the provider is absent from VISION_PROVIDER_PRIORITY. _primary_prefix = ( self.vision_model.split("/")[0] if "/" in self.vision_model else "openai" ) _priority_providers = [ p.strip().lower() for p in self.vision_provider_priority.split(",") if p.strip() ] # Union: fallback providers + primary model's own provider _all_providers = {_primary_prefix} | set(_priority_providers) # Align with get_api_keys_for_model: keys must be non-empty and len >= 8 _has_any_key = any( any(k and len(k) >= 8 for k in (_VISION_KEY_MAP.get(p) or [])) for p in _all_providers if p in _VISION_KEY_MAP ) if not _has_any_key: _checked = sorted(_all_providers & _VISION_KEY_MAP.keys()) issues.append(ConfigIssue( severity="warning", message=( "VISION_MODEL 已配置,但未找到可用的 Vision API Key " f"(已检查:{', '.join(_checked)})。" "图片股票代码提取功能将不可用,请配置对应的 API Key。" ), field="VISION_MODEL", )) return issues def validate(self) -> List[str]: """Return validation messages as plain strings (backward-compatible). Internally delegates to validate_structured(). Callers that only need the human-readable strings can continue to use this method unchanged. Returns: List of message strings, one per ConfigIssue. """ return [issue.message for issue in self.validate_structured()] def get_db_url(self) -> str: """ 获取 SQLAlchemy 数据库连接 URL 自动创建数据库目录(如果不存在) """ db_path = Path(self.database_path) db_path.parent.mkdir(parents=True, exist_ok=True) return f"sqlite:///{db_path.absolute()}" # === 便捷的配置访问函数 === def get_config() -> Config: """获取全局配置实例的快捷方式""" return Config.get_instance() # ============================================================ # Shared LLM helpers (used by both analyzer and agent/llm_adapter) # ============================================================ def get_api_keys_for_model(model: str, config: Config) -> List[str]: """Return explicitly managed API keys for a litellm model (legacy path only). When llm_model_list is populated (channels / YAML), the Router handles key selection, so this function is not needed. Kept for backward compat when no Router is built and a direct litellm.completion() call is needed. """ provider = _get_litellm_provider(model) if provider in {"gemini", "vertex_ai"}: return [k for k in config.gemini_api_keys if k and len(k) >= 8] if provider == "anthropic": return [k for k in config.anthropic_api_keys if k and len(k) >= 8] if provider == "deepseek": return [k for k in config.deepseek_api_keys if k and len(k) >= 8] if provider == "openai": return [k for k in config.openai_api_keys if k and len(k) >= 8] # Other LiteLLM-native providers – API key resolved from env vars return [] def extra_litellm_params(model: str, config: Config) -> Dict[str, Any]: """Build extra litellm params for a model (legacy path only). When llm_model_list is populated, the Router already carries api_base and headers per-deployment, so this is not called. """ params: Dict[str, Any] = {} # deepseek/ provider: litellm auto-resolves api_base, no manual override needed if model.startswith("deepseek/"): return params if model.startswith("openai/") or "/" not in model: if config.openai_base_url: params["api_base"] = config.openai_base_url if config.openai_base_url and "aihubmix.com" in config.openai_base_url: params["extra_headers"] = {"APP-Code": "GPIJ3886"} return params if __name__ == "__main__": # 测试配置加载 config = get_config() print("=== 配置加载测试 ===") print(f"自选股列表: {config.stock_list}") print(f"数据库路径: {config.database_path}") print(f"最大并发数: {config.max_workers}") print(f"调试模式: {config.debug}") # 验证配置 warnings = config.validate() if warnings: print("\n配置验证结果:") for w in warnings: print(f" - {w}")