197 lines
7.8 KiB
Python
197 lines
7.8 KiB
Python
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},
|
|
"deepseek_base_url": {"env": "DEEPSEEK_BASE_URL", "type": "string", "secret": False},
|
|
"deepseek_model": {"env": "DEEPSEEK_MODEL", "type": "string", "secret": False},
|
|
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
|
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
|
"codex_model": {"env": "CODEX_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},
|
|
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
|
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
|
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
|
"deepseek_api_key": {"env": "DEEPSEEK_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},
|
|
"uex_negotiation_close_endpoint": {"env": "UEX_NEGOTIATION_CLOSE_ENDPOINT", "type": "string", "secret": False},
|
|
"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.4-mini"
|
|
deepseek_base_url: str = "https://api.deepseek.com"
|
|
deepseek_model: str = "deepseek-v4-flash"
|
|
model_reasoning_effort: str = "medium"
|
|
codex_command: str = "codex"
|
|
codex_model: str = "gpt-5.4"
|
|
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"
|
|
scwiki_base_url: str = "https://starcitizen.tools"
|
|
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
|
openai_api_key: str | None = Field(default=None)
|
|
deepseek_api_key: str | None = Field(default=None)
|
|
uex_secret_key: str | None = Field(default=None)
|
|
uex_bearer_token: str | None = Field(default=None)
|
|
uex_negotiation_close_endpoint: str = "marketplace_negotiations_close"
|
|
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 = 300
|
|
require_write_approval: bool = True
|
|
|
|
@field_validator("openai_api_key", "deepseek_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", "deepseek"} else "ollama"
|
|
|
|
@field_validator("model_reasoning_effort", mode="before")
|
|
@classmethod
|
|
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
|
text = str(value or "medium").strip().casefold()
|
|
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh", "max"} else "medium"
|
|
|
|
@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", "deepseek_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
|