Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions docs/CONFIGURATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,13 @@ ValueCell supports multiple LLM providers. Choose at least one:
### Step 2: Configure .env File

Copy the example file and add your API keys:
Edit `.env` and add your credentials:

```bash
# In project root
cp .env.example .env
```

Edit `.env` and add your credentials:

```bash
# OpenRouter (recommended for multi-model support)
OPENROUTER_API_KEY=sk-or-v1-xxxxxxxxxxxxx

# Or SiliconFlow (best for Chinese models and cost)
SILICONFLOW_API_KEY=sk-xxxxxxxxxxxxx
Edit `.env` and add your credentials:

# Or Google Gemini
GOOGLE_API_KEY=AIzaSyDxxxxxxxxxxxxx
Expand Down Expand Up @@ -130,7 +123,7 @@ The system automatically reads `OPENROUTER_API_KEY` from `.env` or environment.
When you create an agent (e.g., `research_agent`), the system:

1. **Loads agent YAML** (e.g., `configs/agents/research_agent.yaml`)
2. **Resolves environment variable syntax** (`${VAR:default}`)
The system automatically reads `OPENROUTER_API_KEY` from `.env` or environment.
3. **Applies environment variable overrides** via `env_overrides` map
4. **Merges with global defaults** from `config.yaml`
5. **Returns AgentConfig** object with complete configuration
Expand Down
30 changes: 23 additions & 7 deletions python/scripts/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pathlib import Path
from typing import Dict

from valuecell.utils.env import ensure_system_env_dir, get_system_env_path

# Mapping from agent name to analyst key (for ai-hedge-fund agents)
MAP_NAME_ANALYST: Dict[str, str] = {
"AswathDamodaranAgent": "aswath_damodaran",
Expand Down Expand Up @@ -47,13 +49,14 @@

PROJECT_DIR = Path(__file__).resolve().parent.parent.parent
PYTHON_DIR = PROJECT_DIR / "python"
ENV_PATH = PROJECT_DIR / ".env"
ENV_PATH = get_system_env_path()

# Convert paths to POSIX format (forward slashes) for cross-platform compatibility
# as_posix() works on both Windows and Unix systems
PROJECT_DIR_STR = PROJECT_DIR.as_posix()
PYTHON_DIR_STR = PYTHON_DIR.as_posix()
ENV_PATH_STR = ENV_PATH.as_posix()
# Quote path to handle spaces (e.g., macOS Application Support)
ENV_PATH_STR = f'"{ENV_PATH.as_posix()}"'

AUTO_TRADING_ENV_OVERRIDES = {
"AUTO_TRADING_EXCHANGE": os.getenv("AUTO_TRADING_EXCHANGE"),
Expand Down Expand Up @@ -97,11 +100,24 @@

def check_envfile_is_set():
if not ENV_PATH.exists():
print(
f".env file not found at {ENV_PATH}. Please create it with necessary environment variables. "
"check python/.env.example for reference."
)
exit(1)
# Attempt to create system .env from repository example
example = PROJECT_DIR / ".env.example"
if example.exists():
try:
import shutil

ensure_system_env_dir()
shutil.copy(example, ENV_PATH)
print(f"Created system .env from example: {ENV_PATH}")
except Exception as e:
print(f"Failed to create system .env from example: {e}")
# Re-check after attempt
if not ENV_PATH.exists():
print(
f"System .env not found at {ENV_PATH}. Please create it with necessary environment variables. "
"System paths — macOS: ~/Library/Application Support/ValueCell/.env; Linux: ~/.config/valuecell/.env; Windows: %APPDATA%\\ValueCell\\.env."
)
exit(1)


def main():
Expand Down
80 changes: 34 additions & 46 deletions python/valuecell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,57 +16,54 @@
import os
from pathlib import Path

from valuecell.utils.env import ensure_system_env_dir, get_system_env_path

logger = logging.getLogger(__name__)


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 repository root (three levels up from this file).
"""Load environment variables using only the system `.env` file.

Note:
- .env file variables override existing environment variables (override=True)
- This ensures LANG and other config vars from .env take precedence
- Debug logging can be enabled via VALUECELL_DEBUG=true
- Falls back to manual parsing if python-dotenv is unavailable
Behavior:
- If the system `.env` exists, load it with `override=True`.
- If it does not exist and the repository has `.env.example`, copy it to the system path and then load.
- Do not create or use the repository root `.env`.
"""
try:
from dotenv import load_dotenv

# Look for .env file in repository root (up 3 levels from this file)
# Resolve system `.env` and fallback create from example
current_dir = Path(__file__).parent
project_root = current_dir.parent.parent.parent
env_file = project_root / ".env"
sys_env = get_system_env_path()
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
try:
import shutil

shutil.copy(example_file, env_file)
if not sys_env.exists() and example_file.exists():
ensure_system_env_dir()
shutil.copy(example_file, sys_env)
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}")
logger.info(f"✓ Created system .env from example: {sys_env}")
except Exception as e:
if os.getenv("VALUECELL_DEBUG", "false").lower() == "true":
logger.info(f"⚠️ Failed to prepare system .env: {e}")

if env_file.exists():
if sys_env.exists():
# Load with override=True to allow .env file to override system variables
# This is especially important for LANG which is often set by the system
load_dotenv(env_file, override=True)
load_dotenv(sys_env, override=True)

# Optional: Log successful loading if DEBUG is enabled
if os.getenv("VALUECELL_DEBUG", "false").lower() == "true":
logger.info(f"✓ Environment variables loaded from {env_file}")
logger.info(f"✓ Environment variables loaded from {sys_env}")
logger.info(f" LANG: {os.environ.get('LANG', 'not set')}")
logger.info(f" TIMEZONE: {os.environ.get('TIMEZONE', 'not set')}")
else:
# Only log if debug mode is enabled
if os.getenv("VALUECELL_DEBUG", "false").lower() == "true":
logger.info(f"ℹ️ No .env file found at {env_file}")
logger.info(f"ℹ️ No system .env file found at {sys_env}")

except ImportError:
# Fallback to manual parsing if python-dotenv is not available
Expand All @@ -79,34 +76,25 @@ def load_env_file_early() -> None:


def _load_env_file_manual() -> None:
"""Fallback manual .env file parsing.

This function provides a simple .env parser when python-dotenv is not available.
It overrides existing environment variables and handles basic quote removal.

Note:
- Lines starting with # are treated as comments
- Only KEY=VALUE format is supported
- Environment variables are overwritten to match dotenv behavior
"""
"""Fallback manual parsing: use only the system `.env`; create from example if needed."""
try:
current_dir = Path(__file__).parent
project_root = current_dir.parent.parent.parent
env_file = project_root / ".env"
sys_env = get_system_env_path()
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
try:
import shutil

shutil.copy(example_file, env_file)
except Exception:
# Fail silently to avoid breaking imports
pass
if not sys_env.exists() and example_file.exists():
ensure_system_env_dir()
shutil.copy(example_file, sys_env)
except Exception:
# Fail silently to avoid breaking imports
pass

if env_file.exists():
with open(env_file, "r", encoding="utf-8") as f:
if sys_env.exists():
with open(sys_env, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
Expand Down
38 changes: 20 additions & 18 deletions python/valuecell/server/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fastapi.middleware.cors import CORSMiddleware

from ...adapters.assets import get_adapter_manager
from ...utils.env import ensure_system_env_dir, get_system_env_path
from ..config.settings import get_settings
from ..db import init_database
from .exceptions import (
Expand Down Expand Up @@ -36,37 +37,38 @@
from .schemas import AppInfoData, SuccessResponse


def _ensure_root_env_and_load() -> None:
"""Ensure repo-root .env exists (copy from .env.example) and load it.
def _ensure_system_env_and_load() -> None:
"""Ensure the system `.env` exists and is loaded; use only the system path.

This protects scenarios where the package-level early loader isn't executed
or resolves a different path (e.g., running via `uv run -m valuecell.server.main`).
Behavior:
- If the system `.env` exists, load it with `override=True`.
- If not, and the repository has `.env.example`, copy it to the system path and then load.
- Do not create or load the repository root `.env`.
"""
try:
repo_root = Path(__file__).resolve().parents[4]
env_file = repo_root / ".env"
sys_env = get_system_env_path()
example_file = repo_root / ".env.example"

# Create .env from example if missing
if not env_file.exists() and example_file.exists():
try:
import shutil
try:
import shutil

shutil.copy(example_file, env_file)
except Exception:
# Best-effort; continue even if copy fails
pass
if not sys_env.exists() and example_file.exists():
ensure_system_env_dir()
shutil.copy(example_file, sys_env)
except Exception:
pass

# Load .env into process environment
if env_file.exists():
# Load system .env into process environment
if sys_env.exists():
try:
from dotenv import load_dotenv

load_dotenv(env_file, override=True)
load_dotenv(sys_env, override=True)
except Exception:
# Fallback manual parsing
try:
with open(env_file, "r", encoding="utf-8") as f:
with open(sys_env, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
Expand All @@ -88,7 +90,7 @@ def _ensure_root_env_and_load() -> None:
def create_app() -> FastAPI:
"""Create and configure FastAPI application."""
# Ensure .env exists and is loaded before reading settings
_ensure_root_env_and_load()
_ensure_system_env_and_load()
settings = get_settings()

@asynccontextmanager
Expand Down
18 changes: 11 additions & 7 deletions python/valuecell/server/api/routers/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
import yaml
from fastapi import APIRouter, HTTPException, Query

from valuecell.config.constants import CONFIG_DIR, PROJECT_ROOT
from valuecell.config.constants import CONFIG_DIR
from valuecell.config.loader import get_config_loader
from valuecell.config.manager import get_config_manager
from valuecell.utils.env import get_system_env_path

from ..schemas import LLMProviderConfigData, SuccessResponse
from ..schemas.model import (
Expand Down Expand Up @@ -41,17 +42,20 @@ def create_models_router() -> APIRouter:

# ---- 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]
"""Return only system .env path for writes (single source of truth)."""
system_env = get_system_env_path()
return [system_env]

def _set_env(key: str, value: str) -> bool:
os.environ[key] = value
updated_any = False
for env_file in _env_paths():
# Ensure parent directory exists for system env file
try:
env_file.parent.mkdir(parents=True, exist_ok=True)
except Exception:
# Best effort; continue even if directory creation fails
pass
lines: List[str] = []
if env_file.exists():
with open(env_file, "r", encoding="utf-8") as f:
Expand Down
57 changes: 56 additions & 1 deletion python/valuecell/utils/env.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
"""Utilities for resolving system-level .env paths consistently across OSes.

Provides helpers to locate the OS user configuration directory for ValueCell
and to construct the system `.env` file path. This centralizes path logic so
other modules can mirror or write environment variables consistently.
"""

import os
from pathlib import Path


def get_system_env_dir() -> Path:
"""Return the OS user configuration directory for ValueCell.

- macOS: ~/Library/Application Support/ValueCell
- Linux: ~/.config/valuecell
- Windows: %APPDATA%\ValueCell
"""
home = Path.home()
# Windows
if os.name == "nt":
appdata = os.getenv("APPDATA")
base = Path(appdata) if appdata else (home / "AppData" / "Roaming")
return base / "ValueCell"
# macOS (posix with darwin kernel)
if sys_platform_is_darwin():
return home / "Library" / "Application Support" / "ValueCell"
# Linux and other Unix-like
return home / ".config" / "valuecell"


def get_system_env_path() -> Path:
"""Return the full path to the system `.env` file."""
return get_system_env_dir() / ".env"


def ensure_system_env_dir() -> Path:
"""Ensure the system config directory exists and return it."""
d = get_system_env_dir()
d.mkdir(parents=True, exist_ok=True)
return d


def sys_platform_is_darwin() -> bool:
"""Detect macOS platform without importing `platform` globally."""
try:
import sys

return sys.platform == "darwin"
except Exception:
return False


def agent_debug_mode_enabled() -> bool:
return os.getenv("AGENT_DEBUG_MODE", "false").lower() == "true"
"""Return whether agent debug mode is enabled via environment.

Checks `AGENT_DEBUG_MODE` first; falls back to `VALUECELL_DEBUG`.
"""
flag = os.getenv("AGENT_DEBUG_MODE", os.getenv("VALUECELL_DEBUG", "false"))
return str(flag).lower() == "true"