3 Commits
Author SHA1 Message Date
hz4th_coder 86198e83c5 v2.2.0: API Key鉴权(可配置) + Function Calling透传 + MCP Server标准接口
- API Key鉴权: /v1/* 与 /mcp 接口支持 Bearer 鉴权(OpenAI兼容), 系统配置页可启停/增删Key
  (GET/PUT /api/admin/apikeys, key脱敏显示; /health /status 不受限; 内部自调用自动带key)
- Function Calling: tools/tool_calls 全透传上游, 实测 GLM-5.3-flash 返回 tool_calls(get_weather)
- MCP Server: /mcp 端点(Streamable HTTP + SSE兼容), 暴露 text_complete/vision_complete/image_generate 工具
  (initialize/ping/tools/list/tools/call/prompts.list/resources.list; 实测生图返回图片URL)
- 默认生成 API Key: sk-hz4th-<hex>
2026-08-28 15:24:18 +08:00
hz4th_coder ddcf97a441 v2.1.3: 修复邮件被退信-补Date头
- 根因: mail.tphai.com 的 amavisd 内容过滤器强制要求 Date 头
  (554 5.6.0 BAD HEADER - Missing required header field: Date)
- 之前 llm-proxy 发的测试邮件/auto失败通知全被过滤退信(8封退信在 hz4th_coder 邮箱)
- 工作区标准 send_email.py 一直带 msg['Date']=formatdate() 所以能收到
- 修复: send_email() 补 msg['Date'] = formatdate(localtime=True), 与标准脚本一致
- 验证: 修复后测试邮件无退信, 已送达 wlq@tphai.com
2026-08-28 14:54:07 +08:00
hz4th_coder 904a4d0d6c v2.1.2: auto调用全部失败时邮件通知 + 网页配置邮箱
- 新增邮件通知: auto 配置被调用后所有提供商都失败(含无可用提供商)时自动发邮件
- 通知路径: chat_completions 400/503 + 通用能力端点(生图/语音/视频) + ASR端点
- 防轰炸: cooldown_seconds(默认300s)最小间隔, 连续失败只发一封(实测3次失败1封)
- 网页配置: 系统配置页新增「邮件通知」卡片, 支持 SMTP服务器/端口/加密(plain/starttls/ssl)/账号密码/发件人/收件人/间隔 + 发送测试邮件按钮
- API: GET/PUT /api/admin/email(密码掩码), POST /api/admin/email/test
- 默认SMTP: mail.tphai.com:587 plain, 收件人 wlq@tphai.com
- 修复: config页loadEmailConfig竞态(卡片未渲染就填充); 邮件失败不影响API响应
2026-08-28 13:17:31 +08:00
5 changed files with 848 additions and 5 deletions
+48 -1
View File
@@ -2,7 +2,7 @@
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
**版本:v2.1.1**
**版本:v2.2.0**
## 功能特点
@@ -202,6 +202,53 @@ 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`
## 接口认证(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。
## 项目结构
```
+470 -4
View File
@@ -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, formatdate
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, load_api_auth, save_api_auth,
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.2.0"
# 数据目录和统计文件
DATA_DIR = Path(__file__).parent / 'data'
@@ -101,7 +106,133 @@ 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')
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 = {}
# 配置缓存时间(秒)
@@ -664,6 +795,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 +876,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 +946,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 +994,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 +1834,140 @@ 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
# ============ 后台管理 APIAPI 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:对话 ============
def load_chats():
@@ -1823,7 +2096,7 @@ def api_admin_chat_send():
'model': model,
'prompt': user_message,
'n': 1,
}, timeout=180)
}, headers=_internal_auth_header(), timeout=180)
if img_resp.status_code == 200:
img_result = img_resp.json()
@@ -1876,7 +2149,7 @@ def api_admin_chat_send():
'model': model,
'messages': messages,
'stream': False
}, timeout=180)
}, headers=_internal_auth_header(), timeout=180)
if response.status_code == 200:
result = response.json()
@@ -1943,6 +2216,199 @@ def api_admin_clear_chat(chat_id):
return jsonify({'error': 'Chat not found'}), 404
# ============ MCP (Model Context Protocol) 服务端 ============
# 将本系统暴露为 MCP ServerStreamable 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__':
refresh_config()
+59
View File
@@ -200,6 +200,65 @@ 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, # 失败通知最小间隔(防轰炸)
}
# 接口 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():
"""加载路由缓存配置(运行时配置优先)"""
+6
View File
@@ -224,5 +224,11 @@
"routing_config": {
"prefer_cache_model": true,
"cache_ttl_seconds": 3600
},
"api_auth": {
"enabled": true,
"keys": [
"sk-hz4th-2cc2656c1cb78891838557af0fed19c0"
]
}
}
+265
View File
@@ -110,6 +110,41 @@
</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 &lt;key&gt;</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">
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
<div class="space-y-4">
@@ -151,6 +186,8 @@
</p>
</div>
`;
loadEmailConfig();
loadApiKeys();
}
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 &lt;key&gt;" ...</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();
</script>
</body>