Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86198e83c5 | ||
|
|
ddcf97a441 | ||
|
|
904a4d0d6c |
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
|
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
|
||||||
|
|
||||||
**版本:v2.1.1**
|
**版本:v2.2.0**
|
||||||
|
|
||||||
## 功能特点
|
## 功能特点
|
||||||
|
|
||||||
@@ -202,6 +202,53 @@ MODEL_ALIASES = {
|
|||||||
3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变)
|
3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变)
|
||||||
4. 连续失败3次的提供商被熔断,冷却期(默认60秒)后自动半开恢复
|
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`
|
||||||
|
|
||||||
|
## 接口认证(API Key)
|
||||||
|
|
||||||
|
为 `/v1/*` 与 `/mcp` 接口启用 Bearer 鉴权(OpenAI 兼容):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<IP>:16003/v1/chat/completions \
|
||||||
|
-H "Authorization: Bearer sk-hz4th-xxxx" \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- **配置入口**:后台「系统配置」页 → 接口认证(API Key),可启停鉴权、增删多个 Key
|
||||||
|
- **管理接口**:`/api/admin/apikeys`(GET/PUT)
|
||||||
|
- `/health`、`/status` 不受鉴权影响
|
||||||
|
|
||||||
|
## 标准接口
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| OpenAI 兼容 | `/v1/chat/completions` 等,支持流式、`tools`/function calling 透传 |
|
||||||
|
| Function Calling | 请求体带 `tools` 数组即透传上游,返回 `tool_calls`(实测 GLM/DeepSeek 正常) |
|
||||||
|
| MCP Server | 连接 `http://<IP>:16003/mcp`(Streamable HTTP + SSE),工具:`text_complete` / `vision_complete` / `image_generate` |
|
||||||
|
|
||||||
|
### MCP 使用示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://<IP>:16003/mcp \
|
||||||
|
-H "Authorization: Bearer sk-hz4th-xxxx" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
|
||||||
|
|
||||||
|
# 调用文本模型
|
||||||
|
curl http://<IP>:16003/mcp -H "Authorization: Bearer sk-hz4th-xxxx" -H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"text_complete","arguments":{"model":"auto-text","prompt":"你好"}}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Desktop / Cursor 等支持 MCP 的客户端可直接将 `http://<IP>:16003/mcp` 配为 MCP Server。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import time
|
|||||||
import random
|
import random
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.header import Header
|
||||||
|
from email.utils import formataddr, formatdate
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
@@ -30,6 +34,7 @@ from config.settings import (
|
|||||||
load_config, save_config, get_provider, add_provider, update_provider,
|
load_config, save_config, get_provider, add_provider, update_provider,
|
||||||
delete_provider, update_priority, update_model_alias, delete_model_alias,
|
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,
|
add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config,
|
||||||
|
load_email_config, save_email_config, load_api_auth, save_api_auth,
|
||||||
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
|
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ app = Flask(__name__, template_folder='templates')
|
|||||||
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
|
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
|
||||||
VERSION = "2.1.1"
|
VERSION = "2.2.0"
|
||||||
|
|
||||||
# 数据目录和统计文件
|
# 数据目录和统计文件
|
||||||
DATA_DIR = Path(__file__).parent / 'data'
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
@@ -101,7 +106,133 @@ def remember_history_model(fp, provider_name, model):
|
|||||||
with history_cache_lock:
|
with history_cache_lock:
|
||||||
history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()}
|
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')
|
||||||
|
msg['Date'] = formatdate(localtime=True) # 必须带 Date 头,否则 mail.tphai.com 的 amavisd 会拒收(554 BAD HEADER)
|
||||||
|
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://<IP>: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}")
|
||||||
|
|
||||||
|
# ============ API Key 鉴权 ============
|
||||||
|
|
||||||
|
def _is_valid_api_key(token):
|
||||||
|
"""校验 API Key"""
|
||||||
|
cfg = load_api_auth()
|
||||||
|
if not cfg.get('enabled'):
|
||||||
|
return True
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
return token in (cfg.get('keys') or [])
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def require_api_key():
|
||||||
|
"""保护 /v1/* 与 /mcp 接口(OpenAI 兼容 Bearer 鉴权)"""
|
||||||
|
if request.method == 'OPTIONS':
|
||||||
|
return None
|
||||||
|
path = request.path
|
||||||
|
if not (path.startswith('/v1/') or path == '/mcp' or path.startswith('/mcp/')):
|
||||||
|
return None
|
||||||
|
cfg = load_api_auth()
|
||||||
|
if not cfg.get('enabled'):
|
||||||
|
return None
|
||||||
|
auth = request.headers.get('Authorization', '')
|
||||||
|
token = ''
|
||||||
|
if auth.lower().startswith('bearer '):
|
||||||
|
token = auth[7:].strip()
|
||||||
|
elif auth.lower().startswith('apikey '):
|
||||||
|
token = auth[6:].strip()
|
||||||
|
if not _is_valid_api_key(token):
|
||||||
|
return jsonify({"error": {"message": "Invalid API key", "type": "authentication_error"}}), 401
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 通用内部调用 ============
|
||||||
|
|
||||||
|
def _internal_auth_header():
|
||||||
|
"""内部调用自己的 /v1 接口时携带鉴权头"""
|
||||||
|
cfg = load_api_auth()
|
||||||
|
keys = cfg.get('keys') or []
|
||||||
|
if cfg.get('enabled') and keys:
|
||||||
|
return {'Authorization': f"Bearer {keys[0]}"}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# ============ 提供商状态缓存 ============
|
||||||
provider_status = {}
|
provider_status = {}
|
||||||
|
|
||||||
# 配置缓存时间(秒)
|
# 配置缓存时间(秒)
|
||||||
@@ -664,6 +795,8 @@ def chat_completions():
|
|||||||
|
|
||||||
if not provider:
|
if not provider:
|
||||||
increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}')
|
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
|
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
|
||||||
|
|
||||||
request_provider = provider['name']
|
request_provider = provider['name']
|
||||||
@@ -743,6 +876,8 @@ def chat_completions():
|
|||||||
break
|
break
|
||||||
|
|
||||||
increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error))
|
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
|
return jsonify({"error": {"message": f"All providers failed. Last error: {last_error}", "type": "api_error"}}), 503
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -811,6 +946,8 @@ def _generic_capability_endpoint(capability, path, error_msg="No available provi
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not provider:
|
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
|
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')
|
data['model'] = resolved_model if resolved_model else data.get('model')
|
||||||
@@ -857,6 +994,8 @@ def audio_transcriptions():
|
|||||||
break
|
break
|
||||||
|
|
||||||
if not provider:
|
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
|
return jsonify({"error": {"message": "No ASR provider available (capability: audio_in)", "type": "invalid_request_error"}}), 400
|
||||||
|
|
||||||
response = proxy_raw(provider, 'audio/transcriptions')
|
response = proxy_raw(provider, 'audio/transcriptions')
|
||||||
@@ -1695,6 +1834,140 @@ def api_admin_routing_put():
|
|||||||
return jsonify({'success': True, 'routing_config': result})
|
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:API Key 鉴权 ============
|
||||||
|
|
||||||
|
def _mask_key(k):
|
||||||
|
if len(k) <= 8:
|
||||||
|
return k
|
||||||
|
return k[:4] + '****' + k[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/apikeys', methods=['GET'])
|
||||||
|
def api_admin_apikeys_get():
|
||||||
|
"""获取 API 鉴权配置(key 脱敏)"""
|
||||||
|
cfg = load_api_auth()
|
||||||
|
return jsonify({
|
||||||
|
'enabled': cfg.get('enabled', True),
|
||||||
|
'keys': cfg.get('keys', []),
|
||||||
|
'masked_keys': [_mask_key(k) for k in (cfg.get('keys') or [])],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/admin/apikeys', methods=['PUT'])
|
||||||
|
def api_admin_apikeys_put():
|
||||||
|
"""更新 API 鉴权配置(启用开关 / 增删 key)"""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'error': 'Invalid request body'}), 400
|
||||||
|
|
||||||
|
cfg = load_api_auth()
|
||||||
|
keys = list(cfg.get('keys') or [])
|
||||||
|
|
||||||
|
if 'enabled' in data:
|
||||||
|
cfg['enabled'] = bool(data['enabled'])
|
||||||
|
if 'add_key' in data and data['add_key']:
|
||||||
|
k = str(data['add_key']).strip()
|
||||||
|
if k and k not in keys:
|
||||||
|
keys.append(k)
|
||||||
|
if 'remove_key' in data and data['remove_key']:
|
||||||
|
keys = [k for k in keys if k != data['remove_key']]
|
||||||
|
if 'keys' in data and isinstance(data['keys'], list):
|
||||||
|
keys = [str(k).strip() for k in data['keys'] if str(k).strip()]
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
return jsonify({'error': '至少保留一个 API Key'}), 400
|
||||||
|
|
||||||
|
cfg['keys'] = keys
|
||||||
|
result = save_api_auth(cfg)
|
||||||
|
return jsonify({'success': True, 'enabled': result.get('enabled', True),
|
||||||
|
'masked_keys': [_mask_key(k) for k in (result.get('keys') or [])]})
|
||||||
|
|
||||||
|
|
||||||
# ============ 后台管理 API:对话 ============
|
# ============ 后台管理 API:对话 ============
|
||||||
|
|
||||||
def load_chats():
|
def load_chats():
|
||||||
@@ -1823,7 +2096,7 @@ def api_admin_chat_send():
|
|||||||
'model': model,
|
'model': model,
|
||||||
'prompt': user_message,
|
'prompt': user_message,
|
||||||
'n': 1,
|
'n': 1,
|
||||||
}, timeout=180)
|
}, headers=_internal_auth_header(), timeout=180)
|
||||||
|
|
||||||
if img_resp.status_code == 200:
|
if img_resp.status_code == 200:
|
||||||
img_result = img_resp.json()
|
img_result = img_resp.json()
|
||||||
@@ -1876,7 +2149,7 @@ def api_admin_chat_send():
|
|||||||
'model': model,
|
'model': model,
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'stream': False
|
'stream': False
|
||||||
}, timeout=180)
|
}, headers=_internal_auth_header(), timeout=180)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
result = response.json()
|
result = response.json()
|
||||||
@@ -1943,6 +2216,199 @@ def api_admin_clear_chat(chat_id):
|
|||||||
return jsonify({'error': 'Chat not found'}), 404
|
return jsonify({'error': 'Chat not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# ============ MCP (Model Context Protocol) 服务端 ============
|
||||||
|
# 将本系统暴露为 MCP Server(Streamable HTTP + SSE 兼容)
|
||||||
|
# 客户端可连接 /mcp 使用 text_complete / vision_complete / image_generate 等工具
|
||||||
|
|
||||||
|
MCP_PROTOCOL_VERSION = "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_error(msg_id, code, message):
|
||||||
|
return jsonify({"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}})
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_ok(msg_id, result):
|
||||||
|
return jsonify({"jsonrpc": "2.0", "id": msg_id, "result": result})
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_tools_def():
|
||||||
|
"""MCP 工具列表定义(按能力暴露)"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "text_complete",
|
||||||
|
"description": "调用文本大模型完成对话或生成文本(支持 function calling/tools 透传)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"model": {"type": "string", "description": "模型名或 auto 配置(auto-text 等),默认 auto-text"},
|
||||||
|
"prompt": {"type": "string", "description": "单条提示词(与 messages 二选一)"},
|
||||||
|
"messages": {"type": "array", "description": "OpenAI 格式消息列表(含 tool_calls/tools 透传)"},
|
||||||
|
"max_tokens": {"type": "integer", "description": "最大输出 token 数"},
|
||||||
|
"temperature": {"type": "number", "description": "采样温度"},
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "vision_complete",
|
||||||
|
"description": "调用视觉多模态大模型理解图片",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"model": {"type": "string", "description": "视觉模型或 auto-vision,默认 auto-vision"},
|
||||||
|
"prompt": {"type": "string", "description": "对图片的问题"},
|
||||||
|
"image_url": {"type": "string", "description": "图片 URL 或 data:image/...;base64, 数据"},
|
||||||
|
"max_tokens": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "image_generate",
|
||||||
|
"description": "文生图(OpenAI images/generations 兼容)",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"model": {"type": "string", "description": "生图模型或 auto-image,默认 auto-image"},
|
||||||
|
"prompt": {"type": "string", "description": "图片描述"},
|
||||||
|
"size": {"type": "string", "description": "尺寸,如 1328x1328 / 1024x1024"},
|
||||||
|
"n": {"type": "integer", "description": "生成数量,默认 1"},
|
||||||
|
},
|
||||||
|
"required": ["prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_call_internal(path, payload, timeout=180):
|
||||||
|
"""内部调用自己的 /v1 接口(带鉴权头)"""
|
||||||
|
url = f"http://localhost:{SERVER_CONFIG['port']}{path}"
|
||||||
|
return requests.post(url, headers=_internal_auth_header(), json=payload, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def _mcp_handle_tools_call(msg_id, params):
|
||||||
|
name = params.get('name', '')
|
||||||
|
args = params.get('arguments') or {}
|
||||||
|
model = args.get('model', 'auto-text')
|
||||||
|
|
||||||
|
if name == 'text_complete':
|
||||||
|
prompt = args.get('prompt')
|
||||||
|
messages = args.get('messages')
|
||||||
|
if not messages and prompt:
|
||||||
|
messages = [{"role": "user", "content": prompt}]
|
||||||
|
if not messages:
|
||||||
|
return _mcp_error(msg_id, -32602, "prompt 或 messages 必填")
|
||||||
|
payload = {"model": model, "messages": messages, "stream": False}
|
||||||
|
for k in ('max_tokens', 'temperature'):
|
||||||
|
if k in args:
|
||||||
|
payload[k] = args[k]
|
||||||
|
try:
|
||||||
|
resp = _mcp_call_internal('/v1/chat/completions', payload)
|
||||||
|
except Exception as e:
|
||||||
|
return _mcp_error(msg_id, -32603, f"调用失败: {e}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return _mcp_ok(msg_id, {"content": [{"type": "text", "text": f"错误({resp.status_code}): {resp.text[:500]}"}], "isError": True})
|
||||||
|
data = resp.json()
|
||||||
|
choice = (data.get('choices') or [{}])[0]
|
||||||
|
message = choice.get('message') or {}
|
||||||
|
content = message.get('content')
|
||||||
|
text = content if content is not None else ""
|
||||||
|
# 函数调用结果透传
|
||||||
|
if message.get('tool_calls'):
|
||||||
|
text += "\n[tool_calls] " + json.dumps(message['tool_calls'], ensure_ascii=False)
|
||||||
|
return _mcp_ok(msg_id, {"content": [{"type": "text", "text": str(text)}], "isError": False})
|
||||||
|
|
||||||
|
if name == 'vision_complete':
|
||||||
|
prompt = args.get('prompt', '')
|
||||||
|
image_url = args.get('image_url', '')
|
||||||
|
if not prompt or not image_url:
|
||||||
|
return _mcp_error(msg_id, -32602, "prompt 与 image_url 必填")
|
||||||
|
content = [
|
||||||
|
{"type": "text", "text": prompt},
|
||||||
|
{"type": "image_url", "image_url": {"url": image_url}},
|
||||||
|
]
|
||||||
|
payload = {"model": model or 'auto-vision', "messages": [{"role": "user", "content": content}], "stream": False}
|
||||||
|
if args.get('max_tokens'):
|
||||||
|
payload['max_tokens'] = args['max_tokens']
|
||||||
|
try:
|
||||||
|
resp = _mcp_call_internal('/v1/chat/completions', payload)
|
||||||
|
except Exception as e:
|
||||||
|
return _mcp_error(msg_id, -32603, f"调用失败: {e}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return _mcp_ok(msg_id, {"content": [{"type": "text", "text": f"错误({resp.status_code}): {resp.text[:500]}"}], "isError": True})
|
||||||
|
data = resp.json()
|
||||||
|
content = (data.get('choices') or [{}])[0].get('message', {}).get('content', '')
|
||||||
|
return _mcp_ok(msg_id, {"content": [{"type": "text", "text": str(content)}], "isError": False})
|
||||||
|
|
||||||
|
if name == 'image_generate':
|
||||||
|
prompt = args.get('prompt', '')
|
||||||
|
if not prompt:
|
||||||
|
return _mcp_error(msg_id, -32602, "prompt 必填")
|
||||||
|
payload = {"model": model or 'auto-image', "prompt": prompt, "n": args.get('n', 1)}
|
||||||
|
if args.get('size'):
|
||||||
|
payload['size'] = args['size']
|
||||||
|
try:
|
||||||
|
resp = _mcp_call_internal('/v1/images/generations', payload)
|
||||||
|
except Exception as e:
|
||||||
|
return _mcp_error(msg_id, -32603, f"调用失败: {e}")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return _mcp_ok(msg_id, {"content": [{"type": "text", "text": f"错误({resp.status_code}): {resp.text[:500]}"}], "isError": True})
|
||||||
|
data = resp.json()
|
||||||
|
out = []
|
||||||
|
for item in data.get('data') or []:
|
||||||
|
if item.get('url'):
|
||||||
|
out.append({"type": "text", "text": f"图片: {item['url']}"})
|
||||||
|
elif item.get('b64_json'):
|
||||||
|
out.append({"type": "image", "data": item['b64_json'], "mimeType": "image/png"})
|
||||||
|
return _mcp_ok(msg_id, {"content": out or [{"type": "text", "text": "未生成图片"}], "isError": False})
|
||||||
|
|
||||||
|
return _mcp_error(msg_id, -32601, f"未知工具: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/mcp', methods=['POST'])
|
||||||
|
def mcp_http():
|
||||||
|
"""MCP Streamable HTTP 端点(JSON-RPC 2.0)"""
|
||||||
|
try:
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
except Exception:
|
||||||
|
body = {}
|
||||||
|
method = body.get('method')
|
||||||
|
msg_id = body.get('id')
|
||||||
|
|
||||||
|
if method == 'initialize':
|
||||||
|
params = body.get('params') or {}
|
||||||
|
return _mcp_ok(msg_id, {
|
||||||
|
"protocolVersion": params.get('protocolVersion', MCP_PROTOCOL_VERSION),
|
||||||
|
"capabilities": {"tools": {"listChanged": False}},
|
||||||
|
"serverInfo": {"name": "llm-proxy", "version": VERSION},
|
||||||
|
})
|
||||||
|
if method == 'notifications/initialized':
|
||||||
|
return Response('', status=202)
|
||||||
|
if method == 'ping':
|
||||||
|
return _mcp_ok(msg_id, {})
|
||||||
|
if method == 'tools/list':
|
||||||
|
return _mcp_ok(msg_id, {"tools": _mcp_tools_def()})
|
||||||
|
if method == 'tools/call':
|
||||||
|
return _mcp_handle_tools_call(msg_id, body.get('params') or {})
|
||||||
|
if method == 'prompts/list':
|
||||||
|
return _mcp_ok(msg_id, {"prompts": []})
|
||||||
|
if method == 'resources/list':
|
||||||
|
return _mcp_ok(msg_id, {"resources": []})
|
||||||
|
|
||||||
|
return _mcp_error(msg_id, -32601, f"未知方法: {method}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/mcp', methods=['GET'])
|
||||||
|
def mcp_sse():
|
||||||
|
"""MCP SSE 传输端点(兼容旧版 SSE 客户端)"""
|
||||||
|
def gen():
|
||||||
|
yield "event: endpoint\ndata: /mcp\n\n"
|
||||||
|
while True:
|
||||||
|
time.sleep(15)
|
||||||
|
yield ": keep-alive\n\n"
|
||||||
|
return Response(stream_with_context(gen()), content_type='text/event-stream')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
refresh_config()
|
refresh_config()
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,65 @@ ROUTING_CONFIG = {
|
|||||||
"cache_ttl_seconds": 3600, # 记忆有效期(秒)
|
"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, # 失败通知最小间隔(防轰炸)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 接口 API Key 鉴权(OpenAI 兼容:Authorization: Bearer <key>)
|
||||||
|
# 应用于 /v1/* 与 /mcp 接口;后台 /admin 不受此控制
|
||||||
|
API_AUTH = {
|
||||||
|
"enabled": True, # 是否启用鉴权
|
||||||
|
"keys": [], # 允许的 key 列表(可多个)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_api_auth():
|
||||||
|
"""加载 API 鉴权配置(未配置 key 时自动生成一个)"""
|
||||||
|
import hashlib
|
||||||
|
config = load_config()
|
||||||
|
merged = {**API_AUTH, **config.get("api_auth", {})}
|
||||||
|
if not merged.get("keys"):
|
||||||
|
import time
|
||||||
|
merged["keys"] = ["sk-hz4th-" + hashlib.md5((str(time.time()) + "llm-proxy").encode()).hexdigest()[:16]]
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def save_api_auth(data):
|
||||||
|
"""保存 API 鉴权配置"""
|
||||||
|
config = load_config()
|
||||||
|
merged = {**load_api_auth(), **data}
|
||||||
|
if not merged.get("keys"):
|
||||||
|
merged["keys"] = API_AUTH["keys"]
|
||||||
|
config["api_auth"] = merged
|
||||||
|
save_config(config)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def load_routing_config():
|
||||||
"""加载路由缓存配置(运行时配置优先)"""
|
"""加载路由缓存配置(运行时配置优先)"""
|
||||||
|
|||||||
@@ -224,5 +224,11 @@
|
|||||||
"routing_config": {
|
"routing_config": {
|
||||||
"prefer_cache_model": true,
|
"prefer_cache_model": true,
|
||||||
"cache_ttl_seconds": 3600
|
"cache_ttl_seconds": 3600
|
||||||
|
},
|
||||||
|
"api_auth": {
|
||||||
|
"enabled": true,
|
||||||
|
"keys": [
|
||||||
|
"sk-hz4th-2cc2656c1cb78891838557af0fed19c0"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-1">邮件通知(auto 调用全部失败时通知)</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人</p>
|
||||||
|
<div id="emailConfigContent" class="space-y-4">
|
||||||
|
<p class="text-gray-500 text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-1">接口认证(API Key)</h2>
|
||||||
|
<p class="text-sm text-gray-500 mb-4">为 /v1/* 与 /mcp 接口启用鉴权,调用时携带 <code>Authorization: Bearer <key></code>(OpenAI 兼容)</p>
|
||||||
|
<div id="apiKeyConfigContent" class="space-y-4">
|
||||||
|
<p class="text-gray-500 text-sm">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">标准接口</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p class="font-medium text-gray-800">OpenAI 兼容</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">/v1/chat/completions 等,支持 <code>tools</code> / <code>function calling</code> / 流式</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p class="font-medium text-gray-800">MCP Server</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">连接地址: <code>http://localhost:${data.server_config.port}/mcp</code></p>
|
||||||
|
<p class="text-xs text-gray-400 mt-1">工具: text_complete / vision_complete / image_generate</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p class="font-medium text-gray-800">Function Calling</p>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">请求体携带 <code>tools</code> 数组即可透传到上游模型,返回 <code>tool_calls</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -151,6 +186,8 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
loadEmailConfig();
|
||||||
|
loadApiKeys();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleCacheModel() {
|
async function toggleCacheModel() {
|
||||||
@@ -192,6 +229,234 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadEmailConfig() {
|
||||||
|
const res = await fetch('/api/admin/email');
|
||||||
|
const e = await res.json();
|
||||||
|
const container = document.getElementById('emailConfigContent');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span class="text-sm text-gray-700">启用邮件通知</span>
|
||||||
|
<button id="emailEnabledToggle" onclick="toggleEmailEnabled()" class="relative w-10 h-5 rounded-full transition ${e.enabled ? 'bg-green-500' : 'bg-gray-300'}">
|
||||||
|
<span id="emailEnabledKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.enabled ? 'left-5' : 'left-0.5'}"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span class="text-sm text-gray-700">auto 失败时通知</span>
|
||||||
|
<button id="notifyToggle" onclick="toggleNotify()" class="relative w-10 h-5 rounded-full transition ${e.notify_on_auto_failure ? 'bg-green-500' : 'bg-gray-300'}">
|
||||||
|
<span id="notifyKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.notify_on_auto_failure ? 'left-5' : 'left-0.5'}"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
|
||||||
|
<input type="text" id="emailHost" value="${e.smtp_host}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
|
||||||
|
<input type="number" id="emailPort" value="${e.smtp_port}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
|
||||||
|
<select id="emailMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
<option value="plain" ${e.smtp_mode==='plain'?'selected':''}>plain(无加密)</option>
|
||||||
|
<option value="starttls" ${e.smtp_mode==='starttls'?'selected':''}>STARTTLS</option>
|
||||||
|
<option value="ssl" ${e.smtp_mode==='ssl'?'selected':''}>SSL</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
|
||||||
|
<input type="text" id="emailUser" value="${e.smtp_user}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">密码(留空不改)</label>
|
||||||
|
<input type="password" id="emailPass" value="${e.smtp_password}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
|
||||||
|
<input type="text" id="emailFromName" value="${e.from_name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">发件地址</label>
|
||||||
|
<input type="text" id="emailFromAddr" value="${e.from_addr}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">收件人(逗号分隔)</label>
|
||||||
|
<input type="text" id="emailTo" value="${(e.to_addrs||[]).join(', ')}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">失败通知最小间隔(秒)</label>
|
||||||
|
<input type="number" id="emailCooldown" value="${e.cooldown_seconds}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<button onclick="saveEmail()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
|
||||||
|
<i class="ri-save-line mr-1"></i> 保存
|
||||||
|
</button>
|
||||||
|
<button onclick="testEmail(this)" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
|
||||||
|
<i class="ri-mail-send-line mr-1"></i> 发送测试邮件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<i class="ri-loader-4-line animate-spin mr-1"></i> 发送中...';
|
||||||
|
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 = '<i class="ri-mail-send-line mr-1"></i> 发送测试邮件';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApiKeys() {
|
||||||
|
const res = await fetch('/api/admin/apikeys');
|
||||||
|
const d = await res.json();
|
||||||
|
const container = document.getElementById('apiKeyConfigContent');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span class="text-sm text-gray-700">启用接口鉴权</span>
|
||||||
|
<button id="apiAuthToggle" onclick="toggleApiAuth()" class="relative w-10 h-5 rounded-full transition ${d.enabled ? 'bg-green-500' : 'bg-gray-300'}">
|
||||||
|
<span id="apiAuthKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${d.enabled ? 'left-5' : 'left-0.5'}"></span>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-gray-400">关闭后所有请求免鉴权,谨慎操作</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">已配置 Key(${d.keys.length} 个)</label>
|
||||||
|
<div id="apiKeyList" class="space-y-2">
|
||||||
|
${d.keys.map(k => `
|
||||||
|
<div class="flex items-center gap-2 p-2 bg-gray-50 rounded-lg">
|
||||||
|
<code class="flex-1 text-sm text-gray-800 truncate">${k}</code>
|
||||||
|
<button onclick="copyKey('${k}')" class="px-2 py-1 bg-indigo-100 text-indigo-600 rounded text-xs hover:bg-indigo-200">复制</button>
|
||||||
|
<button onclick="removeApiKey('${k}')" class="px-2 py-1 bg-red-100 text-red-600 rounded text-xs hover:bg-red-200">删除</button>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="text" id="newApiKey" placeholder="输入新的 API Key,如 sk-xxxx"
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-lg">
|
||||||
|
<button onclick="addApiKey()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
|
||||||
|
<i class="ri-add-line mr-1"></i> 添加 Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 bg-blue-50 rounded-lg text-xs text-blue-600">
|
||||||
|
<i class="ri-information-line mr-1"></i>
|
||||||
|
使用示例:<code>curl http://<IP>:16003/v1/chat/completions -H "Authorization: Bearer <key>" ...</code>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleApiAuth() {
|
||||||
|
const d = await (await fetch('/api/admin/apikeys')).json();
|
||||||
|
const newVal = !d.enabled;
|
||||||
|
const r = await fetch('/api/admin/apikeys', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: newVal })
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.success) {
|
||||||
|
const btn = document.getElementById('apiAuthToggle');
|
||||||
|
const knob = document.getElementById('apiAuthKnob');
|
||||||
|
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('操作失败: ' + (res.error || '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addApiKey() {
|
||||||
|
const k = document.getElementById('newApiKey').value.trim();
|
||||||
|
if (!k) { alert('请输入 Key'); return; }
|
||||||
|
const r = await fetch('/api/admin/apikeys', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ add_key: k })
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.success) { loadApiKeys(); } else { alert('添加失败: ' + (res.error || '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeApiKey(k) {
|
||||||
|
if (!confirm('确定删除这个 Key?')) return;
|
||||||
|
const r = await fetch('/api/admin/apikeys', {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ remove_key: k })
|
||||||
|
});
|
||||||
|
const res = await r.json();
|
||||||
|
if (res.success) { loadApiKeys(); } else { alert('删除失败: ' + (res.error || '')); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey(k) {
|
||||||
|
navigator.clipboard.writeText(k).then(() => alert('已复制: ' + k));
|
||||||
|
}
|
||||||
|
|
||||||
loadConfig();
|
loadConfig();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user