diff --git a/README.md b/README.md index d98fe14..7e3d5d5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换 -**版本:v2.1.1** +**版本:v2.1.2** ## 功能特点 @@ -202,6 +202,16 @@ MODEL_ALIASES = { 3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变) 4. 连续失败3次的提供商被熔断,冷却期(默认60秒)后自动半开恢复 +## 邮件通知(auto 调用全部失败时) + +某个 auto 配置被调用后所有提供商都失败(如全部熔断/停用),自动发邮件通知,方便及时处理。 + +- **配置入口**:后台「系统配置」页 → 邮件通知(SMTP 服务器/账号/密码/收件人/加密方式,均可在网页配置) +- **防轰炸**:默认最小间隔 300 秒,短时间内连续失败只发一封 +- **测试**:配置页有「发送测试邮件」按钮 +- **端点**:`/api/admin/email`(GET/PUT)、`/api/admin/email/test`(POST) +- **默认配置**:`mail.tphai.com:587`(plain)、收件人 `wlq@tphai.com` + ## 项目结构 ``` diff --git a/app.py b/app.py index 28dc46f..d55a6f9 100644 --- a/app.py +++ b/app.py @@ -16,6 +16,10 @@ import time import random import hashlib import logging +import smtplib +from email.mime.text import MIMEText +from email.header import Header +from email.utils import formataddr from datetime import datetime, date from pathlib import Path import sys @@ -30,6 +34,7 @@ from config.settings import ( load_config, save_config, get_provider, add_provider, update_provider, delete_provider, update_priority, update_model_alias, delete_model_alias, add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config, + load_email_config, save_email_config, DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES ) @@ -37,7 +42,7 @@ app = Flask(__name__, template_folder='templates') app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启) CORS(app) -VERSION = "2.1.1" +VERSION = "2.1.2" # 数据目录和统计文件 DATA_DIR = Path(__file__).parent / 'data' @@ -101,6 +106,86 @@ def remember_history_model(fp, provider_name, model): with history_cache_lock: history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()} + +# ============ 邮件通知(auto 调用全部失败时发邮件) ============ + +_email_last_sent = 0 +_email_lock = threading.Lock() + + +def _smtp_connect(cfg): + """建立 SMTP 连接(支持 plain / starttls / ssl)""" + host = cfg.get('smtp_host', '') + port = int(cfg.get('smtp_port', 25) or 25) + mode = cfg.get('smtp_mode', 'plain') + if mode == 'ssl': + server = smtplib.SMTP_SSL(host, port, timeout=15) + else: + server = smtplib.SMTP(host, port, timeout=15) + if mode == 'starttls': + server.starttls() + if cfg.get('smtp_user'): + server.login(cfg.get('smtp_user'), cfg.get('smtp_password', '')) + return server + + +def send_email(subject, body, cfg=None, html=False): + """发送邮件,返回 {'success': bool, 'error'?: str}""" + cfg = cfg or load_email_config() + to_addrs = cfg.get('to_addrs') or [] + if isinstance(to_addrs, str): + to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()] + if not to_addrs: + return {'success': False, 'error': '未配置收件人'} + + msg = MIMEText(body, 'html' if html else 'plain', 'utf-8') + msg['Subject'] = Header(subject, 'utf-8') + from_addr = cfg.get('from_addr') or cfg.get('smtp_user') or '' + msg['From'] = formataddr((str(Header(cfg.get('from_name', ''), 'utf-8')), from_addr)) + msg['To'] = ', '.join(to_addrs) + + server = _smtp_connect(cfg) + try: + server.sendmail(from_addr, to_addrs, msg.as_string()) + return {'success': True} + finally: + try: + server.quit() + except Exception: + pass + + +def notify_auto_failure(model, capability, last_error): + """auto 调用全部失败时发送邮件通知(带冷却防轰炸)""" + global _email_last_sent + cfg = load_email_config() + if not cfg.get('enabled') or not cfg.get('notify_on_auto_failure'): + return + + with _email_lock: + now = time.time() + if now - _email_last_sent < int(cfg.get('cooldown_seconds', 300) or 300): + return + _email_last_sent = now + + try: + cap_label = CAPABILITY_DEFS.get(capability, capability) + subject = f"[LLM Proxy] Auto 调用全部失败: {model}" + body = ( + f"时间: {datetime.now().isoformat()}\n" + f"Auto 配置: {model}\n" + f"能力: {cap_label}\n" + f"最后错误: {last_error}\n\n" + f"提示: 系统已尝试所有可用提供商仍未成功,请登录后台 (http://:16003/admin) 检查提供商状态。" + ) + r = send_email(subject, body, cfg) + if r['success']: + logger.warning(f"Auto failure notification sent for {model} (capability={capability})") + else: + logger.error(f"Auto failure email send failed: {r.get('error')}") + except Exception as e: + logger.error(f"Failed to send auto failure email: {e}") + # 提供商状态缓存 provider_status = {} @@ -664,6 +749,8 @@ def chat_completions(): if not provider: increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}') + if is_auto_model(model): + notify_auto_failure(model, capability, f"No available provider (capability: {capability})") return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400 request_provider = provider['name'] @@ -743,6 +830,8 @@ def chat_completions(): break increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error)) + if is_auto_model(model): + notify_auto_failure(model, capability, str(last_error)) return jsonify({"error": {"message": f"All providers failed. Last error: {last_error}", "type": "api_error"}}), 503 except Exception as e: @@ -811,6 +900,8 @@ def _generic_capability_endpoint(capability, path, error_msg="No available provi break if not provider: + if is_auto_model(model): + notify_auto_failure(model, capability, error_msg) return jsonify({"error": {"message": f"{error_msg} (capability: {capability})", "type": "invalid_request_error"}}), 400 data['model'] = resolved_model if resolved_model else data.get('model') @@ -857,6 +948,8 @@ def audio_transcriptions(): break if not provider: + if is_auto_model(model): + notify_auto_failure(model, 'audio_in', "No ASR provider available") return jsonify({"error": {"message": "No ASR provider available (capability: audio_in)", "type": "invalid_request_error"}}), 400 response = proxy_raw(provider, 'audio/transcriptions') @@ -1695,6 +1788,91 @@ def api_admin_routing_put(): return jsonify({'success': True, 'routing_config': result}) +# ============ 后台管理 API:邮件通知 ============ + +def _mask_email_config(cfg): + """返回给前端时掩码密码""" + out = dict(cfg) + out['smtp_password'] = '********' if cfg.get('smtp_password') else '' + return out + + +@app.route('/api/admin/email', methods=['GET']) +def api_admin_email_get(): + """获取邮件通知配置(密码掩码)""" + return jsonify(_mask_email_config(load_email_config())) + + +@app.route('/api/admin/email', methods=['PUT']) +def api_admin_email_put(): + """更新邮件通知配置""" + data = request.get_json() + if not data: + return jsonify({'error': 'Invalid request body'}), 400 + + cfg = load_email_config() + update = {} + + for key in ['enabled', 'notify_on_auto_failure', 'smtp_host', 'smtp_port', 'smtp_mode', + 'smtp_user', 'from_name', 'from_addr', 'to_addrs', 'cooldown_seconds']: + if key not in data: + continue + v = data[key] + if key in ('enabled', 'notify_on_auto_failure'): + v = bool(v) + elif key == 'smtp_port': + try: + v = int(v) + except: + continue + elif key == 'cooldown_seconds': + try: + v = max(0, int(v)) + except: + continue + elif key == 'to_addrs': + if isinstance(v, str): + v = [a.strip() for a in v.split(',') if a.strip()] + elif isinstance(v, list): + v = [str(a).strip() for a in v if str(a).strip()] + elif key == 'smtp_mode': + if v not in ('plain', 'starttls', 'ssl'): + continue + update[key] = v + + # 密码:掩码占位表示未修改 + if 'smtp_password' in data and data['smtp_password'] and data['smtp_password'] != '********': + update['smtp_password'] = data['smtp_password'] + + if not update: + return jsonify({'error': 'No valid fields'}), 400 + + result = save_email_config(update) + return jsonify({'success': True, 'email_config': _mask_email_config(result)}) + + +@app.route('/api/admin/email/test', methods=['POST']) +def api_admin_email_test(): + """发送测试邮件(使用当前配置)""" + cfg = load_email_config() + to_addrs = cfg.get('to_addrs') or [] + if isinstance(to_addrs, str): + to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()] + if not to_addrs: + return jsonify({'success': False, 'error': '请先配置收件人'}), 400 + try: + r = send_email( + "[LLM Proxy] 测试邮件", + "这是一封来自 LLM Proxy 的测试邮件,说明邮件通知配置正常。", + cfg + ) + if r['success']: + return jsonify({'success': True, 'message': f'测试邮件已发送到 {to_addrs}'}) + return jsonify({'success': False, 'error': r.get('error', '发送失败')}), 400 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 400 + + # ============ 后台管理 API:对话 ============ def load_chats(): diff --git a/config/settings.py b/config/settings.py index 3a42124..62b96dd 100644 --- a/config/settings.py +++ b/config/settings.py @@ -200,6 +200,36 @@ ROUTING_CONFIG = { "cache_ttl_seconds": 3600, # 记忆有效期(秒) } +# 邮件通知配置(auto 调用全部失败时发邮件通知) +EMAIL_CONFIG = { + "enabled": True, # 总开关 + "notify_on_auto_failure": True, # auto 调用全部失败时发邮件 + "smtp_host": "mail.tphai.com", + "smtp_port": 587, + "smtp_mode": "plain", # plain | starttls | ssl + "smtp_user": "hz4th_coder@tphai.com", + "smtp_password": "hz4th_coder@!", + "from_name": "LLM Proxy", + "from_addr": "hz4th_coder@tphai.com", + "to_addrs": ["wlq@tphai.com"], + "cooldown_seconds": 300, # 失败通知最小间隔(防轰炸) +} + + +def load_email_config(): + """加载邮件通知配置(运行时配置优先)""" + config = load_config() + return {**EMAIL_CONFIG, **config.get("email_config", {})} + + +def save_email_config(data): + """保存邮件通知配置""" + config = load_config() + merged = {**EMAIL_CONFIG, **data} + config["email_config"] = merged + save_config(config) + return merged + def load_routing_config(): """加载路由缓存配置(运行时配置优先)""" diff --git a/templates/config.html b/templates/config.html index 3ea715b..41343ba 100644 --- a/templates/config.html +++ b/templates/config.html @@ -110,6 +110,14 @@ +
+

邮件通知(auto 调用全部失败时通知)

+

某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人

+
+

加载中...

+
+
+

提供商配置

@@ -151,6 +159,7 @@

`; + loadEmailConfig(); } async function toggleCacheModel() { @@ -192,6 +201,155 @@ } } + async function loadEmailConfig() { + const res = await fetch('/api/admin/email'); + const e = await res.json(); + const container = document.getElementById('emailConfigContent'); + container.innerHTML = ` +
+
+ 启用邮件通知 + +
+
+ auto 失败时通知 + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+ + +
+ `; + } + + async function toggleEmailEnabled() { + const res = await fetch('/api/admin/email'); + const e = await res.json(); + const newVal = !e.enabled; + const r = await fetch('/api/admin/email', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: newVal }) + }); + const d = await r.json(); + if (d.success) { + const btn = document.getElementById('emailEnabledToggle'); + const knob = document.getElementById('emailEnabledKnob'); + btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal); + knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal); + } else { alert('保存失败: ' + (d.error || '')); } + } + + async function toggleNotify() { + const res = await fetch('/api/admin/email'); + const e = await res.json(); + const newVal = !e.notify_on_auto_failure; + const r = await fetch('/api/admin/email', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ notify_on_auto_failure: newVal }) + }); + const d = await r.json(); + if (d.success) { + const btn = document.getElementById('notifyToggle'); + const knob = document.getElementById('notifyKnob'); + btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal); + knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal); + } else { alert('保存失败: ' + (d.error || '')); } + } + + async function saveEmail() { + const data = { + smtp_host: document.getElementById('emailHost').value.trim(), + smtp_port: parseInt(document.getElementById('emailPort').value) || 25, + smtp_mode: document.getElementById('emailMode').value, + smtp_user: document.getElementById('emailUser').value.trim(), + from_name: document.getElementById('emailFromName').value.trim(), + from_addr: document.getElementById('emailFromAddr').value.trim(), + to_addrs: document.getElementById('emailTo').value, + cooldown_seconds: parseInt(document.getElementById('emailCooldown').value) || 300 + }; + const pass = document.getElementById('emailPass').value; + if (pass && pass !== '********') data.smtp_password = pass; + + const res = await fetch('/api/admin/email', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + const d = await res.json(); + if (d.success) { + alert('✅ 邮件配置已保存'); + loadEmailConfig(); + } else { alert('保存失败: ' + (d.error || '')); } + } + + async function testEmail(btn) { + if (!btn) btn = event.target.closest('button'); + btn.disabled = true; btn.innerHTML = ' 发送中...'; + try { + const res = await fetch('/api/admin/email/test', { method: 'POST' }); + const d = await res.json(); + if (d.success) { alert('✅ ' + d.message); } + else { alert('❌ ' + (d.error || '发送失败')); } + } catch (e) { + alert('❌ ' + e.message); + } + btn.disabled = false; btn.innerHTML = ' 发送测试邮件'; + } + loadConfig();