from __future__ import annotations from functools import lru_cache import os from pathlib import Path import sys from typing import Any from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict CONFIG_FIELDS: dict[str, dict[str, Any]] = { "model_provider": {"env": "MODEL_PROVIDER", "type": "string", "secret": False}, "ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False}, "ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False}, "ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False}, "openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False}, "openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False}, "uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False}, "scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False}, "cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False}, "openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True}, "uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True}, "uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True}, "traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False}, "traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False}, "uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False}, "require_write_approval": {"env": "REQUIRE_WRITE_APPROVAL", "type": "boolean", "secret": False}, } def app_data_dir() -> Path: if sys.platform == "win32": root = os.environ.get("LOCALAPPDATA") if root: return Path(root) / "TraderAI" return Path.home() / ".traderai" def ensure_app_data_dir() -> Path: path = app_data_dir() path.mkdir(parents=True, exist_ok=True) return path def user_config_path() -> Path: return ensure_app_data_dir() / ".env" def default_memory_path() -> Path: return ensure_app_data_dir() / "traderai.sqlite3" def log_path() -> Path: return ensure_app_data_dir() / "TraderAI.log" def edge_profile_dir() -> Path: return ensure_app_data_dir() / "EdgeProfile" class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=(".env", str(user_config_path())), env_file_encoding="utf-8", ) model_provider: str = "ollama" ollama_base_url: str = "http://localhost:11434" ollama_model: str = "qwen3.5:9b" ollama_num_ctx: int = 64512 openai_base_url: str = "https://api.openai.com/v1" openai_model: str = "gpt-5.3-codex" uex_base_url: str = "https://api.uexcorp.space/2.0" scmdb_base_url: str = "https://scmdb.net" cornerstone_base_url: str = "https://finder.cstone.space" openai_api_key: str | None = Field(default=None) uex_secret_key: str | None = Field(default=None) uex_bearer_token: str | None = Field(default=None) traderai_user_name: str | None = Field(default=None) traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path())) uex_notification_poll_seconds: int = 60 require_write_approval: bool = True @field_validator("openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before") @classmethod def _blank_optional(cls, value: Any) -> Any: return None if value == "" else value @field_validator("model_provider", mode="before") @classmethod def _normalize_model_provider(cls, value: Any) -> str: text = str(value or "ollama").strip().casefold() return text if text in {"ollama", "openai"} else "ollama" @field_validator("traderai_memory_path", mode="before") @classmethod def _blank_memory_path(cls, value: Any) -> Any: return str(default_memory_path()) if value == "" or value is None else value @lru_cache def get_settings() -> Settings: return Settings() def settings_payload(settings: Settings | None = None) -> dict[str, Any]: current = settings or get_settings() values = current.model_dump() secrets_configured = {} for key, meta in CONFIG_FIELDS.items(): if meta.get("secret"): secrets_configured[key] = bool(values.get(key)) values[key] = "" return { "app_data_dir": str(ensure_app_data_dir()), "config_path": str(user_config_path()), "log_path": str(log_path()), "edge_profile_dir": str(edge_profile_dir()), "values": values, "fields": CONFIG_FIELDS, "secrets_configured": secrets_configured, } def save_settings(values: dict[str, Any]) -> dict[str, Any]: current = get_settings().model_dump() next_values = dict(current) for key, value in values.items(): if key not in CONFIG_FIELDS: continue if CONFIG_FIELDS[key].get("secret") and value == "": continue next_values[key] = _coerce_value(key, value) path = user_config_path() lines = [ "# TraderAI desktop configuration", "# Saved by the app. Environment variables still override these values.", "", ] for key, meta in CONFIG_FIELDS.items(): value = next_values.get(key) lines.append(f"{meta['env']}={_env_value(value)}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") get_settings.cache_clear() return settings_payload(get_settings()) def _coerce_value(key: str, value: Any) -> Any: field_type = CONFIG_FIELDS[key]["type"] if value == "": return None if key in {"openai_api_key", "uex_secret_key", "uex_bearer_token", "traderai_user_name"} else "" if field_type == "integer": return int(value) if field_type == "boolean": if isinstance(value, bool): return value return str(value).strip().casefold() in {"1", "true", "yes", "on"} return str(value) def _env_value(value: Any) -> str: if value is None: return "" if isinstance(value, bool): return "true" if value else "false" text = str(value) if not text or any(char.isspace() for char in text) or "#" in text: return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"' return text