From 86198e83c5c4ea5a367e4f7e981c30ce1f321c72 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Fri, 28 Aug 2026 15:24:18 +0800 Subject: [PATCH] =?UTF-8?q?v2.2.0:=20API=20Key=E9=89=B4=E6=9D=83(=E5=8F=AF?= =?UTF-8?q?=E9=85=8D=E7=BD=AE)=20+=20Function=20Calling=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=20+=20MCP=20Server=E6=A0=87=E5=87=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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- --- README.md | 39 +++++- app.py | 297 +++++++++++++++++++++++++++++++++++++++++- config/settings.py | 29 +++++ data/config.json | 6 + templates/config.html | 107 +++++++++++++++ 5 files changed, 472 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7e3d5d5..ec139a1 100644 --- a/README.md +++ b/README.md @@ -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://: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://:16003/mcp`(Streamable HTTP + SSE),工具:`text_complete` / `vision_complete` / `image_generate` | + +### MCP 使用示例 + +```bash +curl http://: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://: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://:16003/mcp` 配为 MCP Server。 + ## 项目结构 ``` diff --git a/app.py b/app.py index 35237fd..0e1606e 100644 --- a/app.py +++ b/app.py @@ -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 +# ============ 后台管理 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:对话 ============ 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 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__': refresh_config() diff --git a/config/settings.py b/config/settings.py index 62b96dd..bfff5a1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -215,6 +215,35 @@ EMAIL_CONFIG = { "cooldown_seconds": 300, # 失败通知最小间隔(防轰炸) } +# 接口 API Key 鉴权(OpenAI 兼容:Authorization: Bearer ) +# 应用于 /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(): """加载邮件通知配置(运行时配置优先)""" diff --git a/data/config.json b/data/config.json index ff8ed12..0a1fc75 100644 --- a/data/config.json +++ b/data/config.json @@ -224,5 +224,11 @@ "routing_config": { "prefer_cache_model": true, "cache_ttl_seconds": 3600 + }, + "api_auth": { + "enabled": true, + "keys": [ + "sk-hz4th-2cc2656c1cb78891838557af0fed19c0" + ] } } \ No newline at end of file diff --git a/templates/config.html b/templates/config.html index 41343ba..dde1eb3 100644 --- a/templates/config.html +++ b/templates/config.html @@ -118,6 +118,33 @@ +
+

接口认证(API Key)

+

为 /v1/* 与 /mcp 接口启用鉴权,调用时携带 Authorization: Bearer <key>(OpenAI 兼容)

+
+

加载中...

+
+
+ +
+

标准接口

+
+
+

OpenAI 兼容

+

/v1/chat/completions 等,支持 tools / function calling / 流式

+
+
+

MCP Server

+

连接地址: http://localhost:${data.server_config.port}/mcp

+

工具: text_complete / vision_complete / image_generate

+
+
+

Function Calling

+

请求体携带 tools 数组即可透传到上游模型,返回 tool_calls

+
+
+
+

提供商配置

@@ -160,6 +187,7 @@
`; loadEmailConfig(); + loadApiKeys(); } async function toggleCacheModel() { @@ -350,6 +378,85 @@ btn.disabled = false; btn.innerHTML = ' 发送测试邮件'; } + async function loadApiKeys() { + const res = await fetch('/api/admin/apikeys'); + const d = await res.json(); + const container = document.getElementById('apiKeyConfigContent'); + container.innerHTML = ` +
+ 启用接口鉴权 + + 关闭后所有请求免鉴权,谨慎操作 +
+
+ +
+ ${d.keys.map(k => ` +
+ ${k} + + +
+ `).join('')} +
+
+
+ + +
+
+ + 使用示例:curl http://:16003/v1/chat/completions -H "Authorization: Bearer <key>" ... +
+ `; + } + + 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();