From 5f96dd6acf6fefec045ab875b98fdc81d7e9e128 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 15:31:00 +0800 Subject: [PATCH 01/11] feat:config model interface --- .env.example | 12 +- python/configs/config.yaml | 3 + python/configs/providers/deepseek.yaml | 25 ++ python/configs/providers/openrouter.yaml | 52 +-- python/valuecell/server/api/routers/models.py | 326 +++++++++++++++++- python/valuecell/server/api/schemas/model.py | 51 ++- 6 files changed, 409 insertions(+), 60 deletions(-) create mode 100644 python/configs/providers/deepseek.yaml diff --git a/.env.example b/.env.example index ff37fbbad..699abc85b 100644 --- a/.env.example +++ b/.env.example @@ -68,17 +68,7 @@ OPENAI_COMPATIBLE_BASE_URL= # 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 + 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..a51d10d32 --- /dev/null +++ b/python/configs/providers/deepseek.yaml @@ -0,0 +1,25 @@ +# ============================================ +# 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 + max_tokens: 4096 + +# 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..2d6f02dc3 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -1,44 +1,22 @@ -# ============================================ -# OpenRouter Provider Configuration -# ============================================ -name: "OpenRouter" -provider_type: "openrouter" - -enabled: true # Default is true if not specified - -# Connection Configuration +name: OpenRouter +provider_type: openrouter +enabled: true 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" - -# Model Parameters Defaults + api_key_env: OPENROUTER_API_KEY +default_model: anthropic/claude-haiku-4.5 defaults: temperature: 0.5 max_tokens: 4096 - -# Extra headers for OpenRouter API extra_headers: - HTTP-Referer: "https://valuecell.ai" - X-Title: "ValueCell" - -# Available Models (commonly used) + HTTP-Referer: https://valuecell.ai + X-Title: ValueCell models: - - - id: "anthropic/claude-haiku-4.5" - name: "Claude Haiku 4.5" - - - id: "x-ai/grok-4" - name: "Grok 4" - - - id: "qwen/qwen-max" - name: "Qwen Max" - - - id: "openai/gpt-5" - name: "GPT-5" - -# Note: OpenRouter does not support embedding models -# For embedding, use other providers like OpenAI or SiliconFlow etc - +- id: anthropic/claude-haiku-4.5 + name: Claude Haiku 4.5 +- id: x-ai/grok-4 + name: Grok 4 +- id: qwen/qwen-max + name: Qwen Max +- id: openai/gpt-5 + name: GPT-5 diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index 9f3db1aec..811465053 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -1,12 +1,26 @@ """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, + SetDefaultProviderRequest, +) # Optional fallback constants from StrategyAgent try: @@ -20,10 +34,59 @@ 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_path() -> Path: + return PROJECT_ROOT / ".env" + + def _set_env(key: str, value: str) -> bool: + os.environ[key] = value + env_file = _env_path() + 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) + return updated + + 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() + + # ---- Existing: LLM config list ---- @router.get( "/llm/config", response_model=SuccessResponse[List[LLMProviderConfigData]], @@ -37,9 +100,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 +110,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 +121,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 +134,252 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] status_code=500, detail=f"Failed to get LLM config list: {str(e)}" ) + # ---- New: Providers list ---- + @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() + names = 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}" + ) + + # ---- New: Provider details ---- + @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=cfg.default_model, + 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}") + + # ---- New: Update provider config (API key / Host) ---- + @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=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}" + ) + + # ---- New: Add provider model ---- + @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 + _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", + ) + models.append( + {"id": payload.model_id, "name": payload.model_name or payload.model_id} + ) + data["models"] = models + _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", + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to add model: {e}") + + # ---- New: Remove provider model ---- + @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}") + + # ---- New: Set default provider ---- + @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}" + ) + return router diff --git a/python/valuecell/server/api/schemas/model.py b/python/valuecell/server/api/schemas/model.py index fc282a24b..03a91a11c 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,52 @@ 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: Optional[str] = Field(None, description="Default model id") + 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") From b6708dfe0a646d8ce5f4a23c66f9480b153169a6 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 15:37:05 +0800 Subject: [PATCH 02/11] remove useless comments --- python/valuecell/server/api/routers/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index 811465053..bb0067e74 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -272,7 +272,6 @@ async def update_provider_config( status_code=500, detail=f"Failed to update provider config: {e}" ) - # ---- New: Add provider model ---- @router.post( "/providers/{provider}/models", response_model=SuccessResponse[ModelItem], @@ -320,7 +319,6 @@ async def add_provider_model( except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to add model: {e}") - # ---- New: Remove provider model ---- @router.delete( "/providers/{provider}/models", response_model=SuccessResponse[dict], @@ -359,7 +357,6 @@ async def remove_provider_model( except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to remove model: {e}") - # ---- New: Set default provider ---- @router.put( "/providers/default", response_model=SuccessResponse[dict], From bd250cf7ed43c27d9e7fe097081a1b30eb823788 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 16:17:06 +0800 Subject: [PATCH 03/11] Update python/valuecell/server/api/schemas/model.py Co-authored-by: Felix <24791380+vcfgv@users.noreply.github.com> --- python/valuecell/server/api/schemas/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/valuecell/server/api/schemas/model.py b/python/valuecell/server/api/schemas/model.py index 03a91a11c..3c0bc2db7 100644 --- a/python/valuecell/server/api/schemas/model.py +++ b/python/valuecell/server/api/schemas/model.py @@ -58,7 +58,7 @@ 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: Optional[str] = Field(None, description="Default model id") + default_model_id: Optional[str] = Field(None, description="Default model id") models: List[ProviderModelEntry] = Field( default_factory=list, description="Available provider models" ) From 253b4fcaa75cb60039224bd8491ef7c1e642bbe2 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 16:22:18 +0800 Subject: [PATCH 04/11] fix comments --- .env.example | 26 ------------------------ python/configs/providers/deepseek.yaml | 1 - python/configs/providers/openrouter.yaml | 11 +++++++++- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 699abc85b..b6e0ba33f 100644 --- a/.env.example +++ b/.env.example @@ -60,29 +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= - - - - - -# ============================================ -# 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/providers/deepseek.yaml b/python/configs/providers/deepseek.yaml index a51d10d32..d5d17a241 100644 --- a/python/configs/providers/deepseek.yaml +++ b/python/configs/providers/deepseek.yaml @@ -17,7 +17,6 @@ default_model: "deepseek-chat" # Model Parameters Defaults defaults: temperature: 0.7 - max_tokens: 4096 # Available Models (commonly used) models: diff --git a/python/configs/providers/openrouter.yaml b/python/configs/providers/openrouter.yaml index 2d6f02dc3..f677fc8a9 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -7,7 +7,6 @@ connection: default_model: anthropic/claude-haiku-4.5 defaults: temperature: 0.5 - max_tokens: 4096 extra_headers: HTTP-Referer: https://valuecell.ai X-Title: ValueCell @@ -20,3 +19,13 @@ models: name: Qwen Max - 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 +- id: openai/gpt-5-mini + name: GPT-5 Mini +- id: qwen/qwen3-max + name: Qwen3 Max +- id: anthropic/claude-sonnet-4.5 + name: Claude Sonnet 4.5 From 3aa2be0a1e97a23200d881dd058ebcdfa8ef17a2 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 16:30:16 +0800 Subject: [PATCH 05/11] chore --- python/valuecell/server/api/routers/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index bb0067e74..8185ee3b6 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -192,7 +192,7 @@ async def get_provider_detail(provider: str) -> SuccessResponse[ProviderDetailDa api_key=cfg.api_key, base_url=cfg.base_url, is_default=(cfg.name == manager.primary_provider), - default_model=cfg.default_model, + default_model_id=cfg.default_model, models=models_entries, ) return SuccessResponse.create( @@ -259,7 +259,7 @@ async def update_provider_config( api_key=cfg.api_key, base_url=cfg.base_url, is_default=(cfg.name == manager.primary_provider), - default_model=cfg.default_model, + default_model_id=cfg.default_model, models=models_items, ) return SuccessResponse.create( From 899b15d4c49da78dbe4430deface245a23439ff1 Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 16:48:01 +0800 Subject: [PATCH 06/11] remove useless code --- python/valuecell/server/api/routers/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index 8185ee3b6..f00f3a55b 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -134,7 +134,6 @@ async def get_llm_model_config() -> SuccessResponse[List[LLMProviderConfigData]] status_code=500, detail=f"Failed to get LLM config list: {str(e)}" ) - # ---- New: Providers list ---- @router.get( "/providers", response_model=SuccessResponse[List[ModelProviderSummary]], @@ -164,7 +163,6 @@ async def list_providers() -> SuccessResponse[List[ModelProviderSummary]]: status_code=500, detail=f"Failed to list providers: {e}" ) - # ---- New: Provider details ---- @router.get( "/providers/{provider}", response_model=SuccessResponse[ProviderDetailData], @@ -203,7 +201,6 @@ async def get_provider_detail(provider: str) -> SuccessResponse[ProviderDetailDa except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get provider: {e}") - # ---- New: Update provider config (API key / Host) ---- @router.put( "/providers/{provider}/config", response_model=SuccessResponse[ProviderDetailData], From 1f705e8810451b57a64e4568bee550d2bf28cd6d Mon Sep 17 00:00:00 2001 From: paisley Date: Mon, 17 Nov 2025 18:50:11 +0800 Subject: [PATCH 07/11] set default model for provider --- python/valuecell/server/api/routers/models.py | 71 +++++++++++++++++++ python/valuecell/server/api/schemas/model.py | 8 +++ 2 files changed, 79 insertions(+) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index f00f3a55b..f32eb2cf4 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -19,6 +19,7 @@ ProviderDetailData, ProviderModelEntry, ProviderUpdateRequest, + SetDefaultModelRequest, SetDefaultProviderRequest, ) @@ -376,4 +377,74 @@ async def set_default_provider( 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 3c0bc2db7..a47b4b8be 100644 --- a/python/valuecell/server/api/schemas/model.py +++ b/python/valuecell/server/api/schemas/model.py @@ -83,3 +83,11 @@ class ProviderValidateResponse(BaseModel): 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", + ) From d4c4f27e5fdc203f01fe403fc6df3352881d4088 Mon Sep 17 00:00:00 2001 From: paisley Date: Tue, 18 Nov 2025 09:45:12 +0800 Subject: [PATCH 08/11] refine provider detail --- python/valuecell/server/api/routers/models.py | 40 ++++++++++++++++++- python/valuecell/server/api/schemas/model.py | 3 ++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index f32eb2cf4..ab2bc6685 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -87,6 +87,42 @@ def _refresh_configs() -> None: 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", @@ -145,7 +181,8 @@ async def list_providers() -> SuccessResponse[List[ModelProviderSummary]]: try: manager = get_config_manager() loader = get_config_loader() - names = loader.list_providers() + # 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) @@ -192,6 +229,7 @@ async def get_provider_detail(provider: str) -> SuccessResponse[ProviderDetailDa 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( diff --git a/python/valuecell/server/api/schemas/model.py b/python/valuecell/server/api/schemas/model.py index a47b4b8be..da731bc42 100644 --- a/python/valuecell/server/api/schemas/model.py +++ b/python/valuecell/server/api/schemas/model.py @@ -59,6 +59,9 @@ class ProviderDetailData(BaseModel): 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" ) From fc31e6c8528ace6b27099cef92c1affa7136fd2a Mon Sep 17 00:00:00 2001 From: paisley Date: Tue, 18 Nov 2025 11:36:17 +0800 Subject: [PATCH 09/11] update interface --- python/valuecell/server/api/routers/models.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index ab2bc6685..315da1789 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -329,18 +329,29 @@ async def add_provider_model( 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", + 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( @@ -348,7 +359,8 @@ async def add_provider_model( model_id=payload.model_id, model_name=payload.model_name or payload.model_id, ), - msg="Model added", + msg="Model added" + + ("; set as default model" if not existing_default else ""), ) except HTTPException: raise From d738914eaef35048d38f78f85500a7d3bd558261 Mon Sep 17 00:00:00 2001 From: paisley Date: Tue, 18 Nov 2025 14:12:57 +0800 Subject: [PATCH 10/11] fix update env file --- python/valuecell/__init__.py | 33 ++++++++++-- python/valuecell/server/api/routers/models.py | 51 +++++++++++-------- 2 files changed, 58 insertions(+), 26 deletions(-) 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 315da1789..8c7c1c53e 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -40,33 +40,40 @@ def create_models_router() -> APIRouter: router = APIRouter(prefix="/models", tags=["Models"]) # ---- Utility helpers (local to router) ---- - def _env_path() -> Path: - return PROJECT_ROOT / ".env" + 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 - env_file = _env_path() - 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}="): + 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") - 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) - return updated + 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" From 086868fc6ca10da3332e2c703acf0f6f2490c01f Mon Sep 17 00:00:00 2001 From: paisley Date: Tue, 18 Nov 2025 14:34:32 +0800 Subject: [PATCH 11/11] update open router --- python/configs/providers/openrouter.yaml | 81 +++++++++++++++--------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/python/configs/providers/openrouter.yaml b/python/configs/providers/openrouter.yaml index f677fc8a9..192b42251 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -1,31 +1,50 @@ -name: OpenRouter -provider_type: openrouter -enabled: true -connection: - base_url: "https://openrouter.ai/api/v1" - api_key_env: OPENROUTER_API_KEY -default_model: anthropic/claude-haiku-4.5 -defaults: - temperature: 0.5 -extra_headers: - HTTP-Referer: https://valuecell.ai - X-Title: ValueCell -models: -- id: anthropic/claude-haiku-4.5 - name: Claude Haiku 4.5 -- id: x-ai/grok-4 - name: Grok 4 -- id: qwen/qwen-max - name: Qwen Max -- 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 -- id: openai/gpt-5-mini - name: GPT-5 Mini -- id: qwen/qwen3-max - name: Qwen3 Max -- id: anthropic/claude-sonnet-4.5 - name: Claude Sonnet 4.5 + # ============================================ + # OpenRouter Provider Configuration + # ============================================ + name: "OpenRouter" + provider_type: "openrouter" + + enabled: true # Default is true if not specified + + # 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" + + # Model Parameters Defaults + defaults: + temperature: 0.5 + + # Extra headers for OpenRouter API + extra_headers: + HTTP-Referer: "https://valuecell.ai" + X-Title: "ValueCell" + + # Available Models (commonly used) + models: + + - id: "anthropic/claude-haiku-4.5" + name: "Claude Haiku 4.5" + + - id: "x-ai/grok-4" + name: "Grok 4" + + - id: "qwen/qwen-max" + name: "Qwen Max" + + - 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 + +