Files
DinQuant/backend_api_python/app/routes/settings.py
T

1126 lines
42 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Settings API - Reading and saving .env configurations
Admin-only endpoints for system configuration management.
"""
import os
import re
import importlib
from flask import Blueprint, request, jsonify
from app.utils.logger import get_logger
from app.utils.config_loader import clear_config_cache
from app.utils.auth import login_required, admin_required
from dotenv import load_dotenv
logger = get_logger(__name__)
settings_bp = Blueprint('settings', __name__)
# .env file path
ENV_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), '.env')
def _reload_runtime_env() -> None:
"""
Reload .env into current process so settings take effect immediately.
Priority keeps backend_api_python/.env over repo-root/.env.
"""
backend_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
root_dir = os.path.dirname(backend_dir)
# Load root first, then backend .env to keep backend file higher priority
load_dotenv(os.path.join(root_dir, '.env'), override=True)
load_dotenv(os.path.join(backend_dir, '.env'), override=True)
def _refresh_runtime_services() -> None:
"""
Reset singleton services so new env/config is picked up lazily
on next request without restarting the Python process.
"""
# Prefer dedicated reset function where available.
try:
search_mod = importlib.import_module('app.services.search')
if hasattr(search_mod, 'reset_search_service'):
search_mod.reset_search_service()
except Exception as e:
logger.warning(f"reset_search_service skipped: {e}")
# Generic singleton fields used across services.
singleton_fields = [
('app.services.fast_analysis', '_fast_analysis_service'),
('app.services.billing_service', '_billing_service'),
('app.services.security_service', '_security_service'),
('app.services.oauth_service', '_oauth_service'),
('app.services.user_service', '_user_service'),
('app.services.email_service', '_email_service'),
('app.services.community_service', '_community_service'),
('app.services.usdt_payment_service', '_svc'),
('app.services.usdt_payment_service', '_worker'),
('app.services.analysis_memory', '_memory_instance'),
]
for module_name, field_name in singleton_fields:
try:
mod = importlib.import_module(module_name)
if hasattr(mod, field_name):
setattr(mod, field_name, None)
except Exception as e:
logger.warning(f"Singleton reset skipped: {module_name}.{field_name}: {e}")
# Configuration item definition (grouping) - divided by functional modules, each configuration item contains a description
# ---------------------------------------------------------------
# Streamlining principle:
# - Deployment-level configuration (host/port/debug) is not exposed in the UI, users can set it through .env or docker-compose
# - Internal tuning parameters (timeout/retry/tick interval/vector dimension, etc.) can use default values and are not exposed to ordinary users.
# - Only keep the function switches and API Keys that users really need to configure
# ---------------------------------------------------------------
CONFIG_SCHEMA = {
# ==================== 1. Security certification ====================
'auth': {
'title': 'Security & Authentication',
'icon': 'lock',
'order': 1,
'items': [
{
'key': 'SECRET_KEY',
'label': 'Secret Key',
'type': 'password',
'default': 'quantdinger-secret-key-change-me',
'description': 'JWT signing secret key. MUST change in production for security'
},
{
'key': 'ADMIN_USER',
'label': 'Admin Username',
'type': 'text',
'default': 'quantdinger',
'description': 'Administrator login username'
},
{
'key': 'ADMIN_PASSWORD',
'label': 'Admin Password',
'type': 'password',
'default': '123456',
'description': 'Administrator login password. MUST change in production'
},
{
'key': 'ADMIN_EMAIL',
'label': 'Admin Email',
'type': 'text',
'default': 'admin@example.com',
'description': 'Administrator email for password reset and notifications'
},
]
},
# ==================== 2. AI/LLM configuration ====================
'ai': {
'title': 'AI / LLM & Search',
'icon': 'robot',
'order': 2,
'items': [
{
'key': 'LLM_PROVIDER',
'label': 'LLM Provider',
'type': 'select',
'default': 'openrouter',
'options': [
{'value': 'openrouter', 'label': 'OpenRouter (Multi-model gateway)'},
{'value': 'openai', 'label': 'OpenAI Direct'},
{'value': 'google', 'label': 'Google Gemini'},
{'value': 'deepseek', 'label': 'DeepSeek'},
{'value': 'grok', 'label': 'xAI Grok'},
],
'description': 'Select your preferred LLM provider'
},
{
'key': 'AI_CODE_GEN_MODEL',
'label': 'Code Generation Model',
'type': 'text',
'default': '',
'required': False,
'description': 'Optional model override for AI code generation. If empty, uses provider default model'
},
# OpenRouter
{
'key': 'OPENROUTER_API_KEY',
'label': 'OpenRouter API Key',
'type': 'password',
'required': False,
'link': 'https://openrouter.ai/keys',
'link_text': 'settings.link.getApiKey',
'description': 'OpenRouter API key. Supports 100+ models via single API',
'group': 'openrouter'
},
{
'key': 'OPENROUTER_MODEL',
'label': 'OpenRouter Model',
'type': 'text',
'default': 'openai/gpt-4o',
'link': 'https://openrouter.ai/models',
'link_text': 'settings.link.viewModels',
'description': 'Model ID, e.g. openai/gpt-4o, anthropic/claude-3.5-sonnet',
'group': 'openrouter'
},
# OpenAI Direct
{
'key': 'OPENAI_API_KEY',
'label': 'OpenAI API Key',
'type': 'password',
'required': False,
'link': 'https://platform.openai.com/api-keys',
'link_text': 'settings.link.getApiKey',
'description': 'OpenAI official API key',
'group': 'openai'
},
{
'key': 'OPENAI_MODEL',
'label': 'OpenAI Model',
'type': 'text',
'default': 'gpt-4o',
'description': 'Model name: gpt-4o, gpt-4o-mini, gpt-4-turbo, etc.',
'group': 'openai'
},
{
'key': 'OPENAI_BASE_URL',
'label': 'OpenAI Base URL',
'type': 'text',
'default': 'https://api.openai.com/v1',
'description': 'Custom API endpoint (for proxies or Azure)',
'group': 'openai'
},
# Google Gemini
{
'key': 'GOOGLE_API_KEY',
'label': 'Google API Key',
'type': 'password',
'required': False,
'link': 'https://aistudio.google.com/apikey',
'link_text': 'settings.link.getApiKey',
'description': 'Google AI Studio API key for Gemini',
'group': 'google'
},
{
'key': 'GOOGLE_MODEL',
'label': 'Gemini Model',
'type': 'text',
'default': 'gemini-1.5-flash',
'description': 'Model: gemini-1.5-flash, gemini-1.5-pro, gemini-2.0-flash-exp',
'group': 'google'
},
# DeepSeek
{
'key': 'DEEPSEEK_API_KEY',
'label': 'DeepSeek API Key',
'type': 'password',
'required': False,
'link': 'https://platform.deepseek.com/api_keys',
'link_text': 'settings.link.getApiKey',
'description': 'DeepSeek API key',
'group': 'deepseek'
},
{
'key': 'DEEPSEEK_MODEL',
'label': 'DeepSeek Model',
'type': 'text',
'default': 'deepseek-chat',
'description': 'Model: deepseek-chat, deepseek-coder',
'group': 'deepseek'
},
{
'key': 'DEEPSEEK_BASE_URL',
'label': 'DeepSeek Base URL',
'type': 'text',
'default': 'https://api.deepseek.com/v1',
'description': 'DeepSeek API endpoint',
'group': 'deepseek'
},
# xAI Grok
{
'key': 'GROK_API_KEY',
'label': 'Grok API Key',
'type': 'password',
'required': False,
'link': 'https://console.x.ai/',
'link_text': 'settings.link.getApiKey',
'description': 'xAI Grok API key',
'group': 'grok'
},
{
'key': 'GROK_MODEL',
'label': 'Grok Model',
'type': 'text',
'default': 'grok-beta',
'description': 'Model: grok-beta, grok-2',
'group': 'grok'
},
{
'key': 'GROK_BASE_URL',
'label': 'Grok Base URL',
'type': 'text',
'default': 'https://api.x.ai/v1',
'description': 'xAI Grok API endpoint',
'group': 'grok'
},
# Common settings
{
'key': 'OPENROUTER_TEMPERATURE',
'label': 'Temperature',
'type': 'number',
'default': '0.7',
'description': 'Model creativity (0-1). Lower = more deterministic'
},
{
'key': 'AI_ANALYSIS_CONSENSUS_TIMEFRAMES',
'label': 'Consensus Timeframes',
'type': 'text',
'default': '1D,4H',
'required': False,
'description': 'Multi-timeframe consensus for fast AI analysis. Comma-separated, e.g. "1D,4H"'
},
{
'key': 'SEARCH_PROVIDER',
'label': 'Search Provider',
'type': 'select',
'options': ['tavily', 'google', 'bing', 'none'],
'default': 'google',
'description': 'News / web search provider used by AI analysis. Configure both LLM and search to get full AI analysis results'
},
{
'key': 'SEARCH_MAX_RESULTS',
'label': 'Search Max Results',
'type': 'number',
'default': '10',
'description': 'Maximum number of search/news results returned per AI analysis request'
},
{
'key': 'TAVILY_API_KEYS',
'label': 'Tavily API Keys',
'type': 'password',
'required': False,
'link': 'https://tavily.com/',
'link_text': 'settings.link.getApiKey',
'description': 'Tavily search API keys (comma-separated). Recommended lightweight search source for AI analysis'
},
{
'key': 'SEARCH_GOOGLE_API_KEY',
'label': 'Google Search API Key',
'type': 'password',
'required': False,
'link': 'https://console.cloud.google.com/apis/credentials',
'link_text': 'settings.link.getApiKey',
'description': 'Google Custom Search JSON API key'
},
{
'key': 'SEARCH_GOOGLE_CX',
'label': 'Google Search Engine ID (CX)',
'type': 'text',
'required': False,
'link': 'https://programmablesearchengine.google.com/',
'link_text': 'settings.link.getApiKey',
'description': 'Google Programmable Search Engine ID'
},
{
'key': 'SEARCH_BING_API_KEY',
'label': 'Bing Search API Key',
'type': 'password',
'required': False,
'link': 'https://portal.azure.com/',
'link_text': 'settings.link.getApiKey',
'description': 'Microsoft Bing Web Search API key'
},
{
'key': 'SERPAPI_KEYS',
'label': 'SerpAPI Keys',
'type': 'password',
'required': False,
'link': 'https://serpapi.com/',
'link_text': 'settings.link.getApiKey',
'description': 'SerpAPI keys (comma-separated)'
},
]
},
# ==================== 3. Real offer ====================
'trading': {
'title': 'Live Trading',
'icon': 'stock',
'order': 3,
'items': [
{
'key': 'ORDER_MODE',
'label': 'Order Execution Mode',
'type': 'select',
'options': ['market', 'maker'],
'default': 'market',
'description': 'market: Market order (instant fill, recommended), maker: Limit order first (lower fees but may not fill)'
},
{
'key': 'MAKER_WAIT_SEC',
'label': 'Limit Order Wait (sec)',
'type': 'number',
'default': '10',
'description': 'Wait time for limit order fill before switching to market order'
},
]
},
# ==================== 4. Data source configuration ====================
'data_source': {
'title': 'Data Sources',
'icon': 'database',
'order': 4,
'items': [
{
'key': 'CCXT_DEFAULT_EXCHANGE',
'label': 'Default Crypto Exchange',
'type': 'text',
'default': 'coinbase',
'link': 'https://github.com/ccxt/ccxt#supported-cryptocurrency-exchange-markets',
'link_text': 'settings.link.supportedExchanges',
'description': 'Default exchange for crypto data (binance, coinbase, okx, etc.)'
},
{
'key': 'FINNHUB_API_KEY',
'label': 'Finnhub API Key',
'type': 'password',
'required': False,
'link': 'https://finnhub.io/register',
'link_text': 'settings.link.freeRegister',
'description': 'Finnhub API key for US stock data (free tier available)'
},
{
'key': 'TIINGO_API_KEY',
'label': 'Tiingo API Key',
'type': 'password',
'required': False,
'link': 'https://www.tiingo.com/account/api/token',
'link_text': 'settings.link.getToken',
'description': 'Tiingo API key for Forex/Metals data'
},
{
'key': 'TWELVE_DATA_API_KEY',
'label': 'Twelve Data API Key',
'type': 'password',
'required': False,
'link': 'https://twelvedata.com/apikey',
'link_text': 'settings.link.getApiKey',
'description': 'Twelve Data API key for CN/HK stock K-lines (free 800 credits/day)'
},
]
},
# ==================== 5. Email configuration ====================
'email': {
'title': 'Email (SMTP)',
'icon': 'mail',
'order': 5,
'items': [
{
'key': 'SMTP_HOST',
'label': 'SMTP Server',
'type': 'text',
'required': False,
'description': 'SMTP server hostname (e.g. smtp.gmail.com)'
},
{
'key': 'SMTP_PORT',
'label': 'SMTP Port',
'type': 'number',
'default': '587',
'description': 'SMTP port (587 for TLS, 465 for SSL)'
},
{
'key': 'SMTP_USER',
'label': 'SMTP Username',
'type': 'text',
'required': False,
'description': 'SMTP authentication username (usually email address)'
},
{
'key': 'SMTP_PASSWORD',
'label': 'SMTP Password',
'type': 'password',
'required': False,
'description': 'SMTP authentication password or app-specific password'
},
{
'key': 'SMTP_FROM',
'label': 'Sender Address',
'type': 'text',
'required': False,
'description': 'Email sender address (From header)'
},
{
'key': 'SMTP_USE_TLS',
'label': 'Use TLS',
'type': 'boolean',
'default': 'True',
'description': 'Enable STARTTLS encryption (recommended for port 587)'
},
{
'key': 'SMTP_USE_SSL',
'label': 'Use SSL',
'type': 'boolean',
'default': 'False',
'description': 'Enable SSL encryption (for port 465)'
},
]
},
# ==================== 6. SMS configuration ====================
'sms': {
'title': 'SMS (Twilio)',
'icon': 'phone',
'order': 6,
'items': [
{
'key': 'TWILIO_ACCOUNT_SID',
'label': 'Account SID',
'type': 'password',
'required': False,
'link': 'https://console.twilio.com/',
'link_text': 'settings.link.getApi',
'description': 'Twilio Account SID from console dashboard'
},
{
'key': 'TWILIO_AUTH_TOKEN',
'label': 'Auth Token',
'type': 'password',
'required': False,
'description': 'Twilio Auth Token from console dashboard'
},
{
'key': 'TWILIO_FROM_NUMBER',
'label': 'Sender Number',
'type': 'text',
'required': False,
'description': 'Twilio phone number for sending SMS (e.g. +1234567890)'
},
]
},
# ==================== 7. AI Agent ====================
'agent': {
'title': 'AI Agent',
'icon': 'experiment',
'order': 7,
'items': [
{
'key': 'ENABLE_REFLECTION_WORKER',
'label': 'Enable Auto Reflection',
'type': 'boolean',
'default': 'False',
'description': 'Enable background worker for automatic trade reflection and calibration'
},
{
'key': 'REFLECTION_WORKER_INTERVAL_SEC',
'label': 'Reflection Interval (sec)',
'type': 'number',
'default': '86400',
'description': 'Reflection worker run interval in seconds (86400 = 1 day)'
},
{
'key': 'REFLECTION_MIN_AGE_DAYS',
'label': 'Min Age for Validation (days)',
'type': 'number',
'default': '7',
'description': 'Only validate analyses older than N days'
},
{
'key': 'REFLECTION_VALIDATE_LIMIT',
'label': 'Validation Batch Limit',
'type': 'number',
'default': '200',
'description': 'Max records to validate per reflection cycle'
},
{
'key': 'ENABLE_CONFIDENCE_CALIBRATION',
'label': 'Enable Confidence Calibration',
'type': 'boolean',
'default': 'False',
'description': 'Adjust confidence by historical accuracy in each bucket'
},
{
'key': 'ENABLE_AI_ENSEMBLE',
'label': 'Enable Multi-Model Voting',
'type': 'boolean',
'default': 'False',
'description': 'Use 2-3 models and majority vote for more stable decisions'
},
{
'key': 'AI_ENSEMBLE_MODELS',
'label': 'Ensemble Models',
'type': 'text',
'default': 'openai/gpt-4o,openai/gpt-4o-mini',
'description': 'Comma-separated model IDs for ensemble voting'
},
{
'key': 'AI_CALIBRATION_MARKETS',
'label': 'Calibration Markets',
'type': 'text',
'default': 'Crypto',
'description': 'Comma-separated markets to run threshold calibration'
},
{
'key': 'AI_CALIBRATION_LOOKBACK_DAYS',
'label': 'Calibration Lookback (days)',
'type': 'number',
'default': '30',
'description': 'Days of validated data for calibration'
},
{
'key': 'AI_CALIBRATION_MIN_SAMPLES',
'label': 'Calibration Min Samples',
'type': 'number',
'default': '80',
'description': 'Minimum validated samples required for calibration'
},
]
},
# ==================== 8. Network proxy ====================
'network': {
'title': 'Network & Proxy',
'icon': 'global',
'order': 8,
'items': [
{
'key': 'PROXY_URL',
'label': 'Proxy URL',
'type': 'text',
'required': False,
'description': 'Global outbound proxy URL. Used by requests and by crypto data requests when a proxy is needed.'
},
]
},
# ==================== 10. Registration and OAuth ====================
'security': {
'title': 'Registration & OAuth',
'icon': 'safety',
'order': 10,
'items': [
{
'key': 'ENABLE_REGISTRATION',
'label': 'Enable Registration',
'type': 'boolean',
'default': 'True',
'description': 'Allow new users to register accounts'
},
{
'key': 'FRONTEND_URL',
'label': 'Frontend URL',
'type': 'text',
'default': 'http://localhost:8080',
'description': 'Frontend URL for OAuth redirects'
},
{
'key': 'TURNSTILE_SITE_KEY',
'label': 'Turnstile Site Key',
'type': 'text',
'required': False,
'link': 'https://dash.cloudflare.com/?to=/:account/turnstile',
'link_text': 'settings.link.getTurnstileKey',
'description': 'Cloudflare Turnstile site key for CAPTCHA'
},
{
'key': 'TURNSTILE_SECRET_KEY',
'label': 'Turnstile Secret Key',
'type': 'password',
'required': False,
'description': 'Cloudflare Turnstile secret key'
},
{
'key': 'GOOGLE_CLIENT_ID',
'label': 'Google OAuth Client ID',
'type': 'text',
'required': False,
'link': 'https://console.cloud.google.com/apis/credentials',
'link_text': 'settings.link.getGoogleCredentials',
'description': 'Google OAuth Client ID for Google login'
},
{
'key': 'GOOGLE_CLIENT_SECRET',
'label': 'Google OAuth Secret',
'type': 'password',
'required': False,
'description': 'Google OAuth Client Secret'
},
{
'key': 'GITHUB_CLIENT_ID',
'label': 'GitHub OAuth Client ID',
'type': 'text',
'required': False,
'link': 'https://github.com/settings/developers',
'link_text': 'settings.link.getGithubCredentials',
'description': 'GitHub OAuth Client ID for GitHub login'
},
{
'key': 'GITHUB_CLIENT_SECRET',
'label': 'GitHub OAuth Secret',
'type': 'password',
'required': False,
'description': 'GitHub OAuth Client Secret'
},
]
},
# ==================== 11. Billing configuration ====================
'billing': {
'title': 'Billing & Credits',
'icon': 'dollar',
'order': 11,
'items': [
{
'key': 'BILLING_ENABLED',
'label': 'Enable Billing',
'type': 'boolean',
'default': 'False',
'description': 'Enable billing system. Users need credits to use certain features'
},
# ===== Membership Plans (3 tiers) =====
{
'key': 'MEMBERSHIP_MONTHLY_PRICE_USD',
'label': 'Monthly Membership Price (USD)',
'type': 'number',
'default': '19.9',
'description': 'Monthly membership price in USD (mock payment in current version)'
},
{
'key': 'MEMBERSHIP_MONTHLY_CREDITS',
'label': 'Monthly Membership Bonus Credits',
'type': 'number',
'default': '500',
'description': 'Credits granted immediately after purchasing monthly membership'
},
{
'key': 'MEMBERSHIP_YEARLY_PRICE_USD',
'label': 'Yearly Membership Price (USD)',
'type': 'number',
'default': '199',
'description': 'Yearly membership price in USD (mock payment in current version)'
},
{
'key': 'MEMBERSHIP_YEARLY_CREDITS',
'label': 'Yearly Membership Bonus Credits',
'type': 'number',
'default': '8000',
'description': 'Credits granted immediately after purchasing yearly membership'
},
{
'key': 'MEMBERSHIP_LIFETIME_PRICE_USD',
'label': 'Lifetime Membership Price (USD)',
'type': 'number',
'default': '499',
'description': 'Lifetime membership price in USD (mock payment in current version)'
},
{
'key': 'MEMBERSHIP_LIFETIME_MONTHLY_CREDITS',
'label': 'Lifetime Membership Monthly Credits',
'type': 'number',
'default': '800',
'description': 'Credits granted every 30 days for lifetime members'
},
# ===== USDT Pay (Plan B: independent address for each order) =====
{
'key': 'USDT_PAY_ENABLED',
'label': 'Enable USDT Pay',
'type': 'boolean',
'default': 'False',
'description': 'Enable USDT scan-to-pay flow (per-order unique address)'
},
{
'key': 'USDT_PAY_CHAIN',
'label': 'USDT Chain',
'type': 'select',
'default': 'TRC20',
'options': ['TRC20'],
'description': 'Currently only TRC20 is supported'
},
{
'key': 'USDT_TRC20_XPUB',
'label': 'TRC20 XPUB (Watch-only)',
'type': 'password',
'required': False,
'description': 'Watch-only xpub used to derive per-order deposit addresses. Do NOT paste private key.'
},
{
'key': 'USDT_TRC20_CONTRACT',
'label': 'USDT TRC20 Contract',
'type': 'text',
'default': 'TXLAQ63Xg1NAzckPwKHvzw7CSEmLMEqcdj',
'description': 'USDT contract address on TRON'
},
{
'key': 'TRONGRID_BASE_URL',
'label': 'TronGrid Base URL',
'type': 'text',
'default': 'https://api.trongrid.io',
'description': 'TronGrid API base URL'
},
{
'key': 'TRONGRID_API_KEY',
'label': 'TronGrid API Key',
'type': 'password',
'required': False,
'description': 'Optional TronGrid API key for higher rate limits'
},
{
'key': 'USDT_PAY_CONFIRM_SECONDS',
'label': 'Confirm Delay (sec)',
'type': 'number',
'default': '30',
'description': 'Delay before marking a paid transaction as confirmed (TRC20)'
},
{
'key': 'USDT_PAY_EXPIRE_MINUTES',
'label': 'Order Expire (min)',
'type': 'number',
'default': '30',
'description': 'USDT payment order expiration time in minutes'
},
{
'key': 'BILLING_COST_AI_ANALYSIS',
'label': 'AI Analysis Cost (per symbol)',
'type': 'number',
'default': '10',
'description': 'Credits per symbol (instant analysis, AI filter, scheduled tasks all use this price)'
},
{
'key': 'BILLING_COST_AI_CODE_GEN',
'label': 'AI Code Generation Cost',
'type': 'number',
'default': '30',
'description': 'Credits per AI strategy/indicator code generation (higher token usage)'
},
{
'key': 'CREDITS_REGISTER_BONUS',
'label': 'Register Bonus',
'type': 'number',
'default': '100',
'description': 'Credits awarded to new users on registration'
},
{
'key': 'CREDITS_REFERRAL_BONUS',
'label': 'Referral Bonus',
'type': 'number',
'default': '50',
'description': 'Credits awarded to referrer for each signup'
},
]
},
}
def read_env_file():
"""Read .env file"""
env_values = {}
if not os.path.exists(ENV_FILE_PATH):
logger.warning(f".env file not found at {ENV_FILE_PATH}")
return env_values
try:
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
# Skip empty lines and comments
if not line or line.startswith('#'):
continue
# Parse KEY=VALUE
if '=' in line:
key, value = line.split('=', 1)
key = key.strip()
value = value.strip()
# Remove quotes
if (value.startswith('"') and value.endswith('"')) or \
(value.startswith("'") and value.endswith("'")):
value = value[1:-1]
env_values[key] = value
except Exception as e:
logger.error(f"Failed to read .env file: {e}")
return env_values
def write_env_file(env_values):
"""Write to .env file, preserving comments and formatting"""
lines = []
existing_keys = set()
# Read the original file and keep the format
if os.path.exists(ENV_FILE_PATH):
try:
with open(ENV_FILE_PATH, 'r', encoding='utf-8') as f:
for line in f:
original_line = line
stripped = line.strip()
# Keep blank lines and comments
if not stripped or stripped.startswith('#'):
lines.append(original_line)
continue
# Update an existing key
if '=' in stripped:
key = stripped.split('=', 1)[0].strip()
if key in env_values:
existing_keys.add(key)
value = env_values[key]
# If the value contains special characters, wrap it in quotes
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
lines.append(f'{key}="{value}"\n')
else:
lines.append(f'{key}={value}\n')
else:
lines.append(original_line)
else:
lines.append(original_line)
except Exception as e:
logger.error(f"Failed to read .env file for update: {e}")
# Add new key
new_keys = set(env_values.keys()) - existing_keys
if new_keys:
if lines and not lines[-1].endswith('\n'):
lines.append('\n')
lines.append('\n# Added by Settings UI\n')
for key in sorted(new_keys):
value = env_values[key]
if ' ' in str(value) or '"' in str(value) or "'" in str(value):
lines.append(f'{key}="{value}"\n')
else:
lines.append(f'{key}={value}\n')
# write file
try:
with open(ENV_FILE_PATH, 'w', encoding='utf-8') as f:
f.writelines(lines)
return True
except Exception as e:
logger.error(f"Failed to write .env file: {e}")
return False
@settings_bp.route('/schema', methods=['GET'])
@login_required
@admin_required
def get_settings_schema():
"""Get configuration item definition (admin only)"""
return jsonify({
'code': 1,
'msg': 'success',
'data': CONFIG_SCHEMA
})
@settings_bp.route('/values', methods=['GET'])
@login_required
@admin_required
def get_settings_values():
"""Get current configuration values - including sensitive information (real values) (admin only)"""
env_values = read_env_file()
# Construct return data and return true value
result = {}
for group_key, group in CONFIG_SCHEMA.items():
result[group_key] = {}
for item in group['items']:
key = item['key']
value = env_values.get(key, item.get('default', ''))
result[group_key][key] = value
# Marks whether the password type is configured
if item['type'] == 'password':
result[group_key][f'{key}_configured'] = bool(value)
return jsonify({
'code': 1,
'msg': 'success',
'data': result
})
@settings_bp.route('/save', methods=['POST'])
@login_required
@admin_required
def save_settings():
"""Save configuration (admin only)"""
try:
data = request.get_json()
if not data:
return jsonify({'code': 0, 'msg': 'Invalid request payload'})
# Read current configuration
current_env = read_env_file()
# Update configuration
updates = {}
for group_key, group_values in data.items():
if group_key not in CONFIG_SCHEMA:
continue
for item in CONFIG_SCHEMA[group_key]['items']:
key = item['key']
if key in group_values:
new_value = group_values[key]
# Null value handling
if new_value is None or new_value == '':
if not item.get('required', True):
updates[key] = ''
else:
updates[key] = str(new_value)
# Merge updates
current_env.update(updates)
# write file
if write_env_file(current_env):
# Clear configuration cache
clear_config_cache()
# Hot reload runtime environment variables (no need to restart the process)
_reload_runtime_env()
# Reset the service singleton that depends on the configuration (the next request will be automatically rebuilt according to the new configuration)
_refresh_runtime_services()
return jsonify({
'code': 1,
'msg': 'Settings saved successfully',
'data': {
'updated_keys': list(updates.keys()),
'requires_restart': False,
'hot_reloaded': True,
'services_refreshed': True
}
})
else:
return jsonify({'code': 0, 'msg': 'Failed to save settings'})
except Exception as e:
logger.error(f"Failed to save settings: {e}")
return jsonify({'code': 0, 'msg': f'Save failed: {str(e)}'})
@settings_bp.route('/openrouter-balance', methods=['GET'])
@login_required
@admin_required
def get_openrouter_balance():
"""Check OpenRouter account balance (admin only)"""
try:
import requests
from app.config.api_keys import APIKeys
api_key = APIKeys.OPENROUTER_API_KEY
if not api_key:
return jsonify({
'code': 0,
'msg': 'OpenRouter API key is not configured',
'data': None
})
# Call OpenRouter API to query balance
# https://openrouter.ai/docs#limits
resp = requests.get(
'https://openrouter.ai/api/v1/auth/key',
headers={
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
},
timeout=10
)
if resp.status_code == 200:
data = resp.json()
# OpenRouter return format: {"data": {"label": "...", "usage": 0.0, "limit": null, ...}}
key_data = data.get('data', {})
usage = key_data.get('usage', 0) # Amount used
limit = key_data.get('limit') # limit (may be null for no limit)
limit_remaining = key_data.get('limit_remaining') # remaining balance
is_free_tier = key_data.get('is_free_tier', False)
rate_limit = key_data.get('rate_limit', {})
return jsonify({
'code': 1,
'msg': 'success',
'data': {
'usage': round(usage, 4), # Used (USD)
'limit': limit, # total limit
'limit_remaining': round(limit_remaining, 4) if limit_remaining is not None else None, # remaining balance
'is_free_tier': is_free_tier,
'rate_limit': rate_limit,
'label': key_data.get('label', '')
}
})
elif resp.status_code == 401:
return jsonify({
'code': 0,
'msg': 'API key is invalid or expired',
'data': None
})
else:
return jsonify({
'code': 0,
'msg': f'Query failed: HTTP {resp.status_code}',
'data': None
})
except requests.exceptions.Timeout:
return jsonify({
'code': 0,
'msg': 'Request timed out. Please check the network connection.',
'data': None
})
except Exception as e:
logger.error(f"Get OpenRouter balance failed: {e}")
return jsonify({
'code': 0,
'msg': f'Query failed: {str(e)}',
'data': None
})
@settings_bp.route('/test-connection', methods=['POST'])
@login_required
@admin_required
def test_connection():
"""Test API connection (admin only)"""
try:
data = request.get_json()
service = data.get('service')
if service == 'openrouter':
# Test OpenRouter connection
from app.services.llm import LLMService
llm = LLMService()
result = llm.test_connection()
if result:
return jsonify({'code': 1, 'msg': 'OpenRouter connection successful'})
else:
return jsonify({'code': 0, 'msg': 'OpenRouter connection failed'})
elif service == 'finnhub':
# Test Finnhub connection
import requests
api_key = data.get('api_key') or os.getenv('FINNHUB_API_KEY')
if not api_key:
return jsonify({'code': 0, 'msg': 'API key is not configured'})
resp = requests.get(
f'https://finnhub.io/api/v1/quote?symbol=AAPL&token={api_key}',
timeout=10
)
if resp.status_code == 200:
return jsonify({'code': 1, 'msg': 'Finnhub connection successful'})
else:
return jsonify({'code': 0, 'msg': f'Finnhub connection failed: {resp.status_code}'})
return jsonify({'code': 0, 'msg': 'Unknown service'})
except Exception as e:
logger.error(f"Connection test failed: {e}")
return jsonify({'code': 0, 'msg': f'Test failed: {str(e)}'})