diff --git a/.env.example b/.env.example index ff37fbbad..b6e0ba33f 100644 --- a/.env.example +++ b/.env.example @@ -60,39 +60,3 @@ OPENAI_API_KEY= OPENAI_COMPATIBLE_API_KEY= OPENAI_COMPATIBLE_BASE_URL= - -# ============================================ -# Research Agent Configurations -# ============================================ -# Email address used for SEC API requests. -# You can set this to any valid email address. -SEC_EMAIL= - -# ============================================ -# Auto Trading Agent Configurations -# ============================================ -# OKX exchange API. -OKX_NETWORK=paper -OKX_API_KEY= -OKX_API_SECRET= -OKX_API_PASSPHRASE= -OKX_ALLOW_LIVE_TRADING=false -OKX_MARGIN_MODE=cash -OKX_USE_SERVER_TIME=false - - - -# ============================================ -# Third-Party Agent Configurations -# ============================================ -# Finnhub API Key — Required for financial news and insider trading data. -# Get your free API key from: https://finnhub.io/register -# Required by the trading agent. -FINNHUB_API_KEY= - - -# ============================================ -# Additional Configurations -# ============================================ -# Optional: Set your https://xueqiu.com/ token if YFinance data fetching becomes unstable. -# XUEQIU_TOKEN= diff --git a/python/configs/config.yaml b/python/configs/config.yaml index 9600321f6..c034c1fe2 100644 --- a/python/configs/config.yaml +++ b/python/configs/config.yaml @@ -43,6 +43,9 @@ models: config_file: "providers/azure.yaml" api_key_env: "AZURE_OPENAI_API_KEY" endpoint_env: "AZURE_OPENAI_ENDPOINT" + deepseek: + config_file: "providers/deepseek.yaml" + api_key_env: "DEEPSEEK_API_KEY" # Agent Configuration agents: diff --git a/python/configs/providers/deepseek.yaml b/python/configs/providers/deepseek.yaml new file mode 100644 index 000000000..d5d17a241 --- /dev/null +++ b/python/configs/providers/deepseek.yaml @@ -0,0 +1,24 @@ +# ============================================ +# DeepSeek Provider Configuration +# ============================================ +name: "DeepSeek" +provider_type: "deepseek" + +enabled: true # Default is true if not specified + +# Connection Configuration +connection: + base_url: "https://api.deepseek.com/v1" + api_key_env: "DEEPSEEK_API_KEY" + +# Default model if none specified +default_model: "deepseek-chat" + +# Model Parameters Defaults +defaults: + temperature: 0.7 + +# Available Models (commonly used) +models: + - id: "deepseek-chat" + name: "DeepSeek Chat" \ No newline at end of file diff --git a/python/configs/providers/openrouter.yaml b/python/configs/providers/openrouter.yaml index bdf5b7ce1..192b42251 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -1,44 +1,50 @@ -# ============================================ -# OpenRouter Provider Configuration -# ============================================ -name: "OpenRouter" -provider_type: "openrouter" + # ============================================ + # OpenRouter Provider Configuration + # ============================================ + name: "OpenRouter" + provider_type: "openrouter" -enabled: true # Default is true if not specified + enabled: true # Default is true if not specified -# Connection Configuration -connection: - base_url: "https://openrouter.ai/api/v1" - api_key_env: "OPENROUTER_API_KEY" + # Connection Configuration + connection: + base_url: "https://openrouter.ai/api/v1" + api_key_env: "OPENROUTER_API_KEY" -# Default model if none specified -default_model: "anthropic/claude-haiku-4.5" + # Default model if none specified + default_model: "anthropic/claude-haiku-4.5" -# Model Parameters Defaults -defaults: - temperature: 0.5 - max_tokens: 4096 + # Model Parameters Defaults + defaults: + temperature: 0.5 -# Extra headers for OpenRouter API -extra_headers: - HTTP-Referer: "https://valuecell.ai" - X-Title: "ValueCell" + # Extra headers for OpenRouter API + extra_headers: + HTTP-Referer: "https://valuecell.ai" + X-Title: "ValueCell" -# Available Models (commonly used) -models: + # Available Models (commonly used) + models: - - id: "anthropic/claude-haiku-4.5" - name: "Claude Haiku 4.5" + - id: "anthropic/claude-haiku-4.5" + name: "Claude Haiku 4.5" - - id: "x-ai/grok-4" - name: "Grok 4" + - id: "x-ai/grok-4" + name: "Grok 4" - - id: "qwen/qwen-max" - name: "Qwen Max" + - id: "qwen/qwen-max" + name: "Qwen Max" - - id: "openai/gpt-5" - name: "GPT-5" + - id: "openai/gpt-5" + name: "GPT-5" + + - id: "google/gemini-2.5-flash" + name: "Gemini 2.5 Flash" + + - id: "google/gemini-2.5-pro" + name: "Gemini 2.5 Pro" + + # Note: OpenRouter does not support embedding models + # For embedding, use other providers like OpenAI or SiliconFlow etc -# Note: OpenRouter does not support embedding models -# For embedding, use other providers like OpenAI or SiliconFlow etc diff --git a/python/valuecell/__init__.py b/python/valuecell/__init__.py index 73084c23b..eebb18e5c 100644 --- a/python/valuecell/__init__.py +++ b/python/valuecell/__init__.py @@ -23,7 +23,7 @@ def load_env_file_early() -> None: """Load environment variables from .env file at package import time. Uses python-dotenv for reliable parsing and respects existing environment variables. - Looks for .env file in project root (two levels up from this file). + Looks for .env file in repository root (three levels up from this file). Note: - .env file variables override existing environment variables (override=True) @@ -34,10 +34,24 @@ def load_env_file_early() -> None: try: from dotenv import load_dotenv - # Look for .env file in project root (up 2 levels from this file) + # Look for .env file in repository root (up 3 levels from this file) current_dir = Path(__file__).parent - project_root = current_dir.parent.parent + project_root = current_dir.parent.parent.parent env_file = project_root / ".env" + example_file = project_root / ".env.example" + + # If .env is missing but .env.example exists, copy it to create .env + if not env_file.exists() and example_file.exists(): + try: + import shutil + + shutil.copy(example_file, env_file) + if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": + logger.info(f"✓ Created .env by copying .env.example to {env_file}") + except Exception as e: + # Only log errors if debug mode is enabled + if os.getenv("VALUECELL_DEBUG", "false").lower() == "true": + logger.info(f"⚠️ Failed to copy .env.example to .env: {e}") if env_file.exists(): # Load with override=True to allow .env file to override system variables @@ -77,8 +91,19 @@ def _load_env_file_manual() -> None: """ try: current_dir = Path(__file__).parent - project_root = current_dir.parent.parent + project_root = current_dir.parent.parent.parent env_file = project_root / ".env" + example_file = project_root / ".env.example" + + # If .env is missing but .env.example exists, copy it to create .env + if not env_file.exists() and example_file.exists(): + try: + import shutil + + shutil.copy(example_file, env_file) + except Exception: + # Fail silently to avoid breaking imports + pass if env_file.exists(): with open(env_file, "r", encoding="utf-8") as f: diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index 9f3db1aec..8c7c1c53e 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -1,12 +1,27 @@ """Models API router: provide LLM model configuration defaults.""" +import os +from pathlib import Path from typing import List -from fastapi import APIRouter, HTTPException +import yaml +from fastapi import APIRouter, HTTPException, Query +from valuecell.config.constants import CONFIG_DIR, PROJECT_ROOT +from valuecell.config.loader import get_config_loader from valuecell.config.manager import get_config_manager from ..schemas import LLMProviderConfigData, SuccessResponse +from ..schemas.model import ( + AddModelRequest, + ModelItem, + ModelProviderSummary, + ProviderDetailData, + ProviderModelEntry, + ProviderUpdateRequest, + SetDefaultModelRequest, + SetDefaultProviderRequest, +) # Optional fallback constants from StrategyAgent try: @@ -20,10 +35,102 @@ def create_models_router() -> APIRouter: - """Create models-related router with endpoints for model configs.""" + """Create models-related router with endpoints for model configs and provider management.""" router = APIRouter(prefix="/models", tags=["Models"]) + # ---- Utility helpers (local to router) ---- + def _env_paths() -> List[Path]: + """Return the repository root .env as the single source of truth. + + Only use repo-root/.env and do not write python/.env. + """ + repo_env = PROJECT_ROOT.parent / ".env" + return [repo_env] + + def _set_env(key: str, value: str) -> bool: + os.environ[key] = value + updated_any = False + for env_file in _env_paths(): + lines: List[str] = [] + if env_file.exists(): + with open(env_file, "r", encoding="utf-8") as f: + lines = f.readlines() + updated = False + found = False + new_lines: List[str] = [] + for line in lines: + stripped = line.strip() + if stripped.startswith(f"{key}="): + new_lines.append(f"{key}={value}\n") + found = True + updated = True + else: + new_lines.append(line) + if not found: + new_lines.append(f"{key}={value}\n") + updated = True + with open(env_file, "w", encoding="utf-8") as f: + f.writelines(new_lines) + updated_any = updated_any or updated + return updated_any + + def _provider_yaml(provider: str) -> Path: + return CONFIG_DIR / "providers" / f"{provider}.yaml" + + def _load_yaml(path: Path) -> dict: + if not path.exists(): + return {} + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + def _write_yaml(path: Path, data: dict) -> None: + with open(path, "w", encoding="utf-8") as f: + yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) + + def _refresh_configs() -> None: + loader = get_config_loader() + loader.clear_cache() + manager = get_config_manager() + manager._config = manager.loader.load_config() + + def _preferred_provider_order(names: List[str]) -> List[str]: + """Return providers ordered with preferred defaults first. + + Ensures 'openrouter' is first and 'siliconflow' is second when present, + followed by the remaining providers in their original order. + """ + preferred = ["openrouter", "siliconflow"] + seen = set() + ordered: List[str] = [] + + # Add preferred providers in order if they exist + for p in preferred: + if p in names and p not in seen: + ordered.append(p) + seen.add(p) + + # Append the rest while preserving original order + for name in names: + if name not in seen: + ordered.append(name) + seen.add(name) + + return ordered + + def _api_key_url_for(provider: str) -> str | None: + """Return the URL for obtaining an API key for the given provider.""" + mapping = { + "google": "https://aistudio.google.com/app/api-keys", + "openrouter": "https://openrouter.ai/settings/keys", + "openai": "https://platform.openai.com/api-keys", + "azure": "https://azure.microsoft.com/en-us/products/ai-foundry/models/openai/", + "siliconflow": "https://cloud.siliconflow.cn/account/ak", + "deepseek": "https://platform.deepseek.com/api_keys", + } + return mapping.get(provider) + + # ---- Existing: LLM config list ---- @router.get( "/llm/config", response_model=SuccessResponse[List[LLMProviderConfigData]], @@ -37,9 +144,7 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] try: manager = get_config_manager() - # Build ordered provider list: primary first, then fallbacks providers = [manager.primary_provider] + manager.fallback_providers - # Deduplicate while preserving order seen = set() ordered = [p for p in providers if not (p in seen or seen.add(p))] @@ -49,8 +154,7 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] if provider_cfg is None: configs.append( LLMProviderConfigData( - provider=DEFAULT_MODEL_PROVIDER, - api_key=None, + provider=DEFAULT_MODEL_PROVIDER, api_key=None ) ) else: @@ -61,13 +165,9 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] ) ) - # If no providers were detected, return a single default entry if not configs: configs.append( - LLMProviderConfigData( - provider=DEFAULT_MODEL_PROVIDER, - api_key=None, - ) + LLMProviderConfigData(provider=DEFAULT_MODEL_PROVIDER, api_key=None) ) return SuccessResponse.create( @@ -78,4 +178,330 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] status_code=500, detail=f"Failed to get LLM config list: {str(e)}" ) + @router.get( + "/providers", + response_model=SuccessResponse[List[ModelProviderSummary]], + summary="List model providers", + description="List available providers with status and basics.", + ) + async def list_providers() -> SuccessResponse[List[ModelProviderSummary]]: + try: + manager = get_config_manager() + loader = get_config_loader() + # Prefer default ordering: openrouter first, siliconflow second + names = _preferred_provider_order(loader.list_providers()) + items: List[ModelProviderSummary] = [] + for name in names: + cfg = manager.get_provider_config(name) + if not cfg: + continue + items.append( + ModelProviderSummary( + provider=cfg.name, + ) + ) + return SuccessResponse.create( + data=items, msg=f"Retrieved {len(items)} providers" + ) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to list providers: {e}" + ) + + @router.get( + "/providers/{provider}", + response_model=SuccessResponse[ProviderDetailData], + summary="Get provider details", + description="Get configuration and models for a provider.", + ) + async def get_provider_detail(provider: str) -> SuccessResponse[ProviderDetailData]: + try: + manager = get_config_manager() + cfg = manager.get_provider_config(provider) + if cfg is None: + raise HTTPException( + status_code=404, detail=f"Provider '{provider}' not found" + ) + models_entries: List[ProviderModelEntry] = [] + for m in cfg.models or []: + if isinstance(m, dict): + mid = m.get("id") + name = m.get("name") + if mid: + models_entries.append( + ProviderModelEntry(model_id=mid, model_name=name) + ) + detail = ProviderDetailData( + api_key=cfg.api_key, + base_url=cfg.base_url, + is_default=(cfg.name == manager.primary_provider), + default_model_id=cfg.default_model, + api_key_url=_api_key_url_for(cfg.name), + models=models_entries, + ) + return SuccessResponse.create( + data=detail, msg=f"Provider '{provider}' details" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get provider: {e}") + + @router.put( + "/providers/{provider}/config", + response_model=SuccessResponse[ProviderDetailData], + summary="Update provider config", + description="Update provider API key and host, then refresh configs.", + ) + async def update_provider_config( + provider: str, payload: ProviderUpdateRequest + ) -> SuccessResponse[ProviderDetailData]: + try: + loader = get_config_loader() + provider_raw = loader.load_provider_config(provider) + if not provider_raw: + raise HTTPException( + status_code=404, detail=f"Provider '{provider}' not found" + ) + + connection = provider_raw.get("connection", {}) + api_key_env = connection.get("api_key_env") + endpoint_env = connection.get("endpoint_env") + + # Update API key via env var + if payload.api_key and api_key_env: + _set_env(api_key_env, payload.api_key) + + # Update base_url via env when endpoint_env exists (Azure), otherwise YAML + if payload.base_url: + if endpoint_env: + _set_env(endpoint_env, payload.base_url) + else: + path = _provider_yaml(provider) + data = _load_yaml(path) + data.setdefault("connection", {}) + data["connection"]["base_url"] = payload.base_url + _write_yaml(path, data) + + _refresh_configs() + + # Return updated detail + manager = get_config_manager() + cfg = manager.get_provider_config(provider) + if not cfg: + raise HTTPException( + status_code=500, detail="Provider not found after update" + ) + models_items = [ + ProviderModelEntry(model_id=m.get("id", ""), model_name=m.get("name")) + for m in (cfg.models or []) + if isinstance(m, dict) + ] + + detail = ProviderDetailData( + api_key=cfg.api_key, + base_url=cfg.base_url, + is_default=(cfg.name == manager.primary_provider), + default_model_id=cfg.default_model, + models=models_items, + ) + return SuccessResponse.create( + data=detail, msg=f"Provider '{provider}' config updated" + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to update provider config: {e}" + ) + + @router.post( + "/providers/{provider}/models", + response_model=SuccessResponse[ModelItem], + summary="Add provider model", + description="Add a model id to provider YAML.", + ) + async def add_provider_model( + provider: str, payload: AddModelRequest + ) -> SuccessResponse[ModelItem]: + try: + path = _provider_yaml(provider) + data = _load_yaml(path) + if not data: + raise HTTPException( + status_code=404, detail=f"Provider '{provider}' not found" + ) + models = data.get("models") or [] + for m in models: + if isinstance(m, dict) and m.get("id") == payload.model_id: + if payload.model_name: + m["name"] = payload.model_name + # If provider has no default model, set this one as default + existing_default = str(data.get("default_model", "")).strip() + if not existing_default: + data["default_model"] = payload.model_id + _write_yaml(path, data) + _refresh_configs() + return SuccessResponse.create( + data=ModelItem( + model_id=payload.model_id, model_name=m.get("name") + ), + msg=( + "Model already exists; updated model_name if provided" + + ("; set as default model" if not existing_default else "") + ), + ) + models.append( + {"id": payload.model_id, "name": payload.model_name or payload.model_id} + ) + data["models"] = models + # If provider has no default model, set the added one as default + existing_default = str(data.get("default_model", "")).strip() + if not existing_default: + data["default_model"] = payload.model_id + _write_yaml(path, data) + _refresh_configs() + return SuccessResponse.create( + data=ModelItem( + model_id=payload.model_id, + model_name=payload.model_name or payload.model_id, + ), + msg="Model added" + + ("; set as default model" if not existing_default else ""), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to add model: {e}") + + @router.delete( + "/providers/{provider}/models", + response_model=SuccessResponse[dict], + summary="Remove provider model", + description="Remove a model id from provider YAML.", + ) + async def remove_provider_model( + provider: str, + model_id: str = Query(..., description="Model identifier to remove"), + ) -> SuccessResponse[dict]: + try: + path = _provider_yaml(provider) + data = _load_yaml(path) + if not data: + raise HTTPException( + status_code=500, detail=f"Provider '{provider}' not found" + ) + models = data.get("models") or [] + before = len(models) + models = [ + m + for m in models + if not (isinstance(m, dict) and m.get("id") == model_id) + ] + after = len(models) + data["models"] = models + _write_yaml(path, data) + _refresh_configs() + removed = before != after + return SuccessResponse.create( + data={"removed": removed, "remaining": after}, + msg="Model removed" if removed else "Model not found", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to remove model: {e}") + + @router.put( + "/providers/default", + response_model=SuccessResponse[dict], + summary="Set default provider", + description="Set PRIMARY_PROVIDER via env and refresh configs.", + ) + async def set_default_provider( + payload: SetDefaultProviderRequest, + ) -> SuccessResponse[dict]: + try: + _set_env("PRIMARY_PROVIDER", payload.provider) + _refresh_configs() + manager = get_config_manager() + return SuccessResponse.create( + data={"primary_provider": manager.primary_provider}, + msg=f"Primary provider set to '{payload.provider}'", + ) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to set default provider: {e}" + ) + + @router.put( + "/providers/{provider}/default-model", + response_model=SuccessResponse[ProviderDetailData], + summary="Set provider default model", + description="Update provider default_model in YAML and refresh configs.", + ) + async def set_provider_default_model( + provider: str, payload: SetDefaultModelRequest + ) -> SuccessResponse[ProviderDetailData]: + try: + path = _provider_yaml(provider) + data = _load_yaml(path) + if not data: + raise HTTPException( + status_code=404, detail=f"Provider '{provider}' not found" + ) + + # Ensure the model exists in the list and optionally update name + models = data.get("models") or [] + found = False + for m in models: + if isinstance(m, dict) and m.get("id") == payload.model_id: + if payload.model_name: + m["name"] = payload.model_name + found = True + break + if not found: + models.append( + { + "id": payload.model_id, + "name": payload.model_name or payload.model_id, + } + ) + data["models"] = models + + # Set default model + data["default_model"] = payload.model_id + _write_yaml(path, data) + _refresh_configs() + + # Build response from refreshed config + manager = get_config_manager() + cfg = manager.get_provider_config(provider) + if not cfg: + raise HTTPException( + status_code=500, detail="Provider not found after update" + ) + models_items = [ + ProviderModelEntry(model_id=m.get("id", ""), model_name=m.get("name")) + for m in (cfg.models or []) + if isinstance(m, dict) + ] + detail = ProviderDetailData( + api_key=cfg.api_key, + base_url=cfg.base_url, + is_default=(cfg.name == manager.primary_provider), + default_model_id=cfg.default_model, + models=models_items, + ) + return SuccessResponse.create( + data=detail, + msg=(f"Default model for '{provider}' set to '{payload.model_id}'"), + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to set default model: {e}" + ) + return router diff --git a/python/valuecell/server/api/schemas/model.py b/python/valuecell/server/api/schemas/model.py index fc282a24b..da731bc42 100644 --- a/python/valuecell/server/api/schemas/model.py +++ b/python/valuecell/server/api/schemas/model.py @@ -1,6 +1,6 @@ """Model-related API schemas.""" -from typing import Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -34,3 +34,63 @@ class LLMProviderConfigData(BaseModel): api_key: Optional[str] = Field( default=None, description="API key for the model provider (may be omitted)" ) + + +# Extended provider and model management schemas +class ModelItem(BaseModel): + model_id: str = Field(..., description="Model identifier") + model_name: Optional[str] = Field(None, description="Display name of the model") + metadata: Optional[Dict[str, Any]] = Field( + None, description="Optional metadata for the model" + ) + + +class ModelProviderSummary(BaseModel): + provider: str = Field(..., description="Provider key, e.g. 'openrouter'") + + +class ProviderModelEntry(BaseModel): + model_id: str = Field(..., description="Model identifier") + model_name: Optional[str] = Field(None, description="Display name of the model") + + +class ProviderDetailData(BaseModel): + api_key: Optional[str] = Field(None, description="API key if available") + base_url: Optional[str] = Field(None, description="API base URL") + is_default: bool = Field(..., description="Whether this is the primary provider") + default_model_id: Optional[str] = Field(None, description="Default model id") + api_key_url: Optional[str] = Field( + None, description="URL to obtain/apply for the provider's API key" + ) + models: List[ProviderModelEntry] = Field( + default_factory=list, description="Available provider models" + ) + + +class ProviderUpdateRequest(BaseModel): + api_key: Optional[str] = Field(None, description="New API key to set for provider") + base_url: Optional[str] = Field( + None, description="New API base URL to set for provider" + ) + + +class AddModelRequest(BaseModel): + model_id: str = Field(..., description="Model identifier to add") + model_name: Optional[str] = Field(None, description="Optional display name") + + +class ProviderValidateResponse(BaseModel): + is_valid: bool = Field(..., description="Validation result") + error: Optional[str] = Field(None, description="Error message if invalid") + + +class SetDefaultProviderRequest(BaseModel): + provider: str = Field(..., description="Provider key to set as default") + + +class SetDefaultModelRequest(BaseModel): + model_id: str = Field(..., description="Model identifier to set as default") + model_name: Optional[str] = Field( + None, + description="Optional display name; added/updated in models list if provided", + )