1 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
5 changed files with 472 additions and 6 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。
## 项目结构
```
+292 -5
View File
@@ -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'
@@ -187,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 = {}
# 配置缓存时间(秒)
@@ -1874,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():
@@ -2002,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()
@@ -2055,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()
@@ -2122,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>