2 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
5 changed files with 474 additions and 7 deletions
+38 -1
View File
@@ -2,7 +2,7 @@
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
**版本:v2.1.2**
**版本:v2.2.0**
## 功能特点
@@ -212,6 +212,43 @@ MODEL_ALIASES = {
- **端点**`/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。
## 项目结构
```
+294 -6
View File
@@ -19,7 +19,7 @@ import logging
import smtplib
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr
from email.utils import formataddr, formatdate
from datetime import datetime, date
from pathlib import Path
import sys
@@ -34,7 +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_email_config, save_email_config, load_api_auth, save_api_auth,
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
)
@@ -42,7 +42,7 @@ app = Flask(__name__, template_folder='templates')
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
CORS(app)
VERSION = "2.1.2"
VERSION = "2.2.0"
# 数据目录和统计文件
DATA_DIR = Path(__file__).parent / 'data'
@@ -140,6 +140,7 @@ def send_email(subject, body, cfg=None, html=False):
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)
@@ -186,7 +187,52 @@ def notify_auto_failure(model, capability, last_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 = {}
# 配置缓存时间(秒)
@@ -1873,6 +1919,55 @@ def api_admin_email_test():
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():
@@ -2001,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()
@@ -2054,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()
@@ -2121,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()
+29
View File
@@ -215,6 +215,35 @@ EMAIL_CONFIG = {
"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():
"""加载邮件通知配置(运行时配置优先)"""
+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"
]
}
}
+107
View File
@@ -118,6 +118,33 @@
</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">
@@ -160,6 +187,7 @@
</div>
`;
loadEmailConfig();
loadApiKeys();
}
async function toggleCacheModel() {
@@ -350,6 +378,85 @@
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>