diff --git a/README.md b/README.md index 19bd6d8..d98fe14 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ > 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换 -**版本:v2.1.0** +**版本:v2.1.1** ## 功能特点 @@ -73,6 +73,8 @@ curl http://localhost:16003/v1/chat/completions \ }' ``` +后台「对话」页选择 `auto-image` 或 `Qwen-Image` 可直接在对话中生成并展示图片。 + ### 列出模型(含能力标签) ```bash @@ -103,13 +105,13 @@ curl http://localhost:16003/v1/images/generations \ }' ``` -## Auto 配置(按能力固定功能) +## Auto 配置(按能力固定功能 + 具体模型排序) -每个 Auto 配置固定一个**功能类型**,调用时 `model="配置名称"` 即自动选择具备该能力的模型: +每个 Auto 配置固定一个**功能类型**,内部是一个**有序的具体模型列表**(可在后台拖动排序),请求时按序选择第一个可用模型。调用时 `model="配置名称"`: | 配置名称 | 功能 | 说明 | |----------|------|------| -| `auto` | 文本推理 | 默认,按优先级自动选择 | +| `auto` | 文本推理 | 默认,按模型列表顺序自动选择 | | `auto-text` | 文本推理 | 纯文本模型 | | `auto-vision` | 视觉能力 | 多模态视觉模型 | | `auto-image` | 图片生成 | 生图模型 | @@ -117,7 +119,14 @@ curl http://localhost:16003/v1/images/generations \ | `auto-voice-in` | 语音输入 | ASR 模型 | | `auto-video` | 视频生成 | 视频模型 | -可在后台「Auto配置」页创建/修改,从模型管理中选择具备对应能力的模型。 +在后台「Auto配置」页可**拖动具体模型**调整优先级(粒度是模型,不是提供商),勾选参与自动选择。 + +## 历史上下文缓存(可配置) + +带历史上下文的 `auto` 请求会**优先复用上次使用的模型**(按首条消息识别会话),命中上游前缀缓存、节省成本。可在「系统配置」页开关: + +- `prefer_cache_model`:是否启用(默认开) +- `cache_ttl_seconds`:记忆有效期(默认 3600 秒) ## 模型管理 diff --git a/app.py b/app.py index 2cd23b9..28dc46f 100644 --- a/app.py +++ b/app.py @@ -14,6 +14,7 @@ import requests import json import time import random +import hashlib import logging from datetime import datetime, date from pathlib import Path @@ -25,17 +26,18 @@ import uuid sys.path.insert(0, str(Path(__file__).parent)) from config.settings import ( get_providers, get_model_aliases, get_auto_profiles, get_auto_profile, - SERVER_CONFIG, LOG_CONFIG, RETRY_CONFIG, CAPABILITY_DEFS, + SERVER_CONFIG, LOG_CONFIG, RETRY_CONFIG, CAPABILITY_DEFS, ROUTING_CONFIG, 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, + add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config, DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES ) app = Flask(__name__, template_folder='templates') +app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启) CORS(app) -VERSION = "2.1.0" +VERSION = "2.1.1" # 数据目录和统计文件 DATA_DIR = Path(__file__).parent / 'data' @@ -49,6 +51,56 @@ LOGS_DIR.mkdir(exist_ok=True) stats_lock = threading.Lock() chats_lock = threading.Lock() +# 历史上下文→模型缓存(用于 auto 请求优先复用上次模型,命中前缀缓存) +history_model_cache = {} +history_cache_lock = threading.Lock() + + +def history_fingerprint(data): + """计算会话指纹:用首条消息作为会话锚点(同一对话各轮次首条消息不变,可跨轮次复用模型)""" + messages = data.get('messages', []) if isinstance(data, dict) else [] + if not messages: + return None + try: + s = json.dumps(messages[0], ensure_ascii=False, sort_keys=True) + except Exception: + return None + return hashlib.md5(s.encode('utf-8')).hexdigest() + + +def has_history(data): + """请求是否带历史上下文""" + messages = data.get('messages', []) if isinstance(data, dict) else [] + return len(messages) >= 2 + + +def get_cached_history_model(fp, capability=None): + """获取历史前缀对应的上次模型(未过期且可用才返回)""" + if not fp: + return None + with history_cache_lock: + entry = history_model_cache.get(fp) + if not entry: + return None + routing = load_routing_config() + ttl = routing.get('cache_ttl_seconds', 3600) + if time.time() - entry.get('ts', 0) > ttl: + with history_cache_lock: + history_model_cache.pop(fp, None) + return None + provider = find_provider_for_model(entry.get('model', ''), capability) + if not provider: + return None + return {'provider': provider, 'model': entry['model']} + + +def remember_history_model(fp, provider_name, model): + """记录某历史前缀使用的模型""" + if not fp: + return + with history_cache_lock: + history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()} + # 提供商状态缓存 provider_status = {} @@ -307,14 +359,34 @@ def find_provider_for_model(model_name, capability=None, exclude=None): def get_auto_provider(profile_name='auto', capability=None, exclude=None): - """获取auto模式下可用的提供商与模型(按能力过滤)""" + """获取auto模式下可用的提供商与模型 + + 优先级: + 1. 配置了显式有序 models 列表 → 按列表顺序逐个找能托管该模型的提供商 + 2. 否则按提供商能力 + 优先级选择(旧逻辑回退) + """ refresh_config() exclude = exclude or set() profile = _cached_auto_profiles.get(profile_name, _cached_auto_profiles.get('auto', {})) req_cap = profile.get('capability') or capability or 'text' allowed_providers = profile.get('providers', ['*']) strategy = profile.get('strategy', 'priority') + ordered_models = profile.get('models') or [] + # 方式1:显式有序模型列表(粒度=具体模型,用户可拖动排序) + if ordered_models: + candidates = [] + for mname in ordered_models: + provider = find_provider_for_model(mname, req_cap, exclude=exclude) + if provider: + candidates.append((provider, mname)) + if candidates: + if strategy == 'random': + return random.choice(candidates) + return candidates[0] + return None, None # 显式列表全不可用时,不悄悄换成别的模型 + + # 方式2:按提供商能力 + 优先级 candidates = [] for provider in sorted_providers(): if not provider['enabled']: @@ -383,6 +455,14 @@ def detect_capability(data): return 'text' +def is_image_gen_request(model): + """判断请求是否为图片生成(auto-image 配置或具备 image_gen 能力的模型)""" + resolved = resolve_model_name(model) + if is_auto_model(resolved): + return _cached_auto_profiles.get(resolved, {}).get('capability') == 'image_gen' + return find_provider_for_model(resolved, 'image_gen') is not None + + # ============ 上游转发 ============ def build_headers(provider, content_type='application/json', extra=None): @@ -563,14 +643,32 @@ def chat_completions(): request_model = model capability = detect_capability(data) - provider, resolved_model = get_provider_for_model(model, capability) + # 历史上下文缓存:auto 请求始终记录会话锚点(首条消息),带历史时优先复用上次模型 + history_fp = None + routing_cfg = load_routing_config() + if routing_cfg.get('prefer_cache_model', True) and is_auto_model(model): + history_fp = history_fingerprint(data) + if has_history(data): + cached = get_cached_history_model(history_fp, capability) + if cached: + provider = cached['provider'] + resolved_model = cached['model'] + request_provider = provider['name'] + logger.info(f"History cache hit: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}") + else: + provider, resolved_model = get_provider_for_model(model, capability) + else: + provider, resolved_model = get_provider_for_model(model, capability) + else: + provider, resolved_model = get_provider_for_model(model, capability) if not provider: increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}') return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400 request_provider = provider['name'] - logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}") + if not history_fp or not routing_cfg.get('prefer_cache_model', True): + logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}, capability={capability}") last_error = None tried_providers = set() @@ -585,6 +683,7 @@ def chat_completions(): if stream: increment_stats(model, provider['name'], success=True, tokens=0) + remember_history_model(history_fp, provider['name'], resolved_model) return Response( stream_with_context(stream_response(response)), content_type='text/event-stream', @@ -595,6 +694,7 @@ def chat_completions(): usage = result.get('usage', {}) request_tokens = usage.get('total_tokens', 0) increment_stats(model, provider['name'], success=True, tokens=request_tokens) + remember_history_model(history_fp, provider['name'], resolved_model) return jsonify(result) else: @@ -1384,20 +1484,38 @@ def api_admin_delete_alias(alias): # ============ 后台管理 API:Auto配置 ============ def _auto_profile_public(name, profile): - """Auto配置对外结构(含能力与匹配模型预览)""" + """Auto配置对外结构(含能力、有序模型列表与可挑选模型详情)""" providers = get_providers() allowed_providers = profile.get('providers', ['*']) capability = profile.get('capability', 'text') + ordered_models = profile.get('models') or [] - provider_details = [] - matched_models = [] + # 收集所有具备该能力、且在候选提供商范围内的模型(供编辑器挑选) + matched = [] + seen = set() for p in sorted(providers, key=lambda x: x['priority']): if not ('*' in allowed_providers or p.get('id') in allowed_providers or p['name'] in allowed_providers): continue - models = provider_capability_models(p, capability) - if models: - provider_details.append({'id': p.get('id'), 'name': p['name'], 'priority': p['priority'], 'selected': True, 'matched_models': models}) - matched_models.extend(models) + for m in provider_models(p): + if model_has_capability(p, m['name'], capability) and m['name'] not in seen: + seen.add(m['name']) + matched.append({ + 'name': m['name'], + 'provider_id': p.get('id'), + 'provider_name': p['name'], + 'provider_priority': p['priority'], + 'capabilities': m['capabilities'], + }) + + # 已保存的有序模型排前面,其余可挑选模型按提供商优先级追加 + by_name = {x['name']: x for x in matched} + sorted_matched = [] + for mname in ordered_models: + if mname in by_name: + sorted_matched.append(by_name[mname]) + for x in matched: + if x['name'] not in ordered_models: + sorted_matched.append(x) return { 'name': name, @@ -1407,8 +1525,9 @@ def _auto_profile_public(name, profile): 'capability_label': CAPABILITY_DEFS.get(capability, capability), 'strategy': profile.get('strategy', 'priority'), 'providers': allowed_providers, - 'provider_details': provider_details, - 'matched_models': matched_models, + 'models': ordered_models, # 有序的具体模型列表(优先级顺序) + 'matched_models': [x['name'] for x in matched], + 'matched_models_detail': sorted_matched, # 每个模型的提供商详情(编辑器用) } @@ -1452,6 +1571,7 @@ def api_admin_add_auto_profile(): 'name': data.get('display_name', data['name']), 'description': data.get('description', ''), 'capability': capability, + 'models': data.get('models', []), # 有序的具体模型列表(优先级顺序) 'providers': data.get('providers', ['*']), 'strategy': data.get('strategy', 'priority'), } @@ -1478,6 +1598,8 @@ def api_admin_update_auto_profile(profile_name): if data['capability'] not in CAPABILITY_DEFS: return jsonify({'error': f'Invalid capability: {data["capability"]}'}), 400 profile_data['capability'] = data['capability'] + if 'models' in data: + profile_data['models'] = data['models'] if 'providers' in data: profile_data['providers'] = data['providers'] if 'strategy' in data: @@ -1538,11 +1660,41 @@ def api_admin_config(): 'model_aliases': aliases, 'auto_profiles': get_auto_profiles(), 'retry_config': RETRY_CONFIG, + 'routing_config': load_routing_config(), 'capabilities': CAPABILITY_DEFS, 'server_config': {'port': SERVER_CONFIG['port']} }) +@app.route('/api/admin/routing', methods=['GET']) +def api_admin_routing_get(): + """获取路由缓存配置""" + return jsonify(load_routing_config()) + + +@app.route('/api/admin/routing', methods=['PUT']) +def api_admin_routing_put(): + """更新路由缓存配置""" + data = request.get_json() + if not data: + return jsonify({'error': 'Invalid request body'}), 400 + + update = {} + if 'prefer_cache_model' in data: + update['prefer_cache_model'] = bool(data['prefer_cache_model']) + if 'cache_ttl_seconds' in data: + try: + update['cache_ttl_seconds'] = max(0, int(data['cache_ttl_seconds'])) + except: + pass + + if not update: + return jsonify({'error': 'No valid fields'}), 400 + + result = save_routing_config(update) + return jsonify({'success': True, 'routing_config': result}) + + # ============ 后台管理 API:对话 ============ def load_chats(): @@ -1665,6 +1817,57 @@ def api_admin_chat_send(): try: proxy_url = f"http://localhost:{SERVER_CONFIG['port']}/v1/chat/completions" + # 图片生成模型(auto-image / Qwen-Image)→ 走生图接口 + if is_image_gen_request(model): + img_resp = requests.post(f"http://localhost:{SERVER_CONFIG['port']}/v1/images/generations", json={ + 'model': model, + 'prompt': user_message, + 'n': 1, + }, timeout=180) + + if img_resp.status_code == 200: + img_result = img_resp.json() + img_url = None + img_b64 = None + if img_result.get('data') and len(img_result['data']) > 0: + img_url = img_result['data'][0].get('url') + img_b64 = img_result['data'][0].get('b64_json') + used_model = img_result.get('model', model) + + with chats_lock: + data = load_chats() + for c in data['chats']: + if c['id'] == chat_id: + c['messages'].append({ + 'role': 'assistant', + 'content': '🖼️ 图片生成成功', + 'image_url': img_url, + 'image_b64': img_b64, + 'model': used_model, + 'time': datetime.now().isoformat() + }) + if len(c['messages']) == 2: + c['title'] = user_message[:30] + ('...' if len(user_message) > 30 else '') + c['updated_at'] = datetime.now().isoformat() + break + save_chats(data) + + return jsonify({ + 'success': True, + 'chat_id': chat_id, + 'response': '🖼️ 图片生成成功', + 'model': used_model, + 'image_url': img_url, + 'image_b64': img_b64, + 'title': chat.get('title', '新对话') + }) + else: + try: + error_msg = img_resp.json().get('error', {}).get('message', 'Unknown error') + except: + error_msg = f'HTTP {img_resp.status_code}' + return jsonify({'error': error_msg}), img_resp.status_code + messages = [] for msg in chat['messages'][-20:]: messages.append({'role': msg['role'], 'content': msg['content']}) diff --git a/config/settings.py b/config/settings.py index b36b5bb..3a42124 100644 --- a/config/settings.py +++ b/config/settings.py @@ -114,59 +114,106 @@ DEFAULT_MODEL_ALIASES = { # 默认Auto配置:每个auto配置固定绑定一个能力 # capability: 固定此auto的功能类型(取自 CAPABILITY_DEFS) +# models: 有序的**具体模型**列表(按优先级,可拖动排序);为空时回退为按提供商能力选择 # providers: 候选提供商(* 表示所有启用的、且具备该能力模型的提供商) DEFAULT_AUTO_PROFILES = { "auto": { "name": "默认Auto", - "description": "文本推理 - 自动选择可用提供商", + "description": "文本推理 - 按模型列表顺序自动选择", "capability": "text", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "deepseek-ai/DeepSeek-V4-Flash", + "meituan-longcat/LongCat-2.0", + "qwen3.6-plus", + "GLM-5.3-flash", + ], "providers": ["*"], "strategy": "priority", }, "auto-text": { "name": "文本推理", - "description": "纯文本推理,自动选择文本模型", + "description": "纯文本推理,按模型列表顺序自动选择", "capability": "text", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "deepseek-ai/DeepSeek-V4-Flash", + "meituan-longcat/LongCat-2.0", + "qwen3.6-plus", + "GLM-5.3-flash", + ], "providers": ["*"], "strategy": "priority", }, "auto-vision": { "name": "视觉能力", - "description": "多模态视觉理解,自动选择视觉模型", + "description": "多模态视觉理解,按模型列表顺序自动选择", "capability": "vision", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "qwen3.6-plus", + "GLM-5.3-flash", + ], "providers": ["*"], "strategy": "priority", }, "auto-image": { "name": "图片生成", - "description": "文生图,自动选择生图模型", + "description": "文生图,按模型列表顺序自动选择", "capability": "image_gen", + "models": ["Qwen-Image"], "providers": ["*"], "strategy": "priority", }, "auto-voice-out": { "name": "语音输出", - "description": "语音合成(TTS),自动选择语音输出模型", + "description": "语音合成(TTS),按模型列表顺序自动选择", "capability": "audio_out", + "models": [], "providers": ["*"], "strategy": "priority", }, "auto-voice-in": { "name": "语音输入", - "description": "语音识别(ASR),自动选择语音输入模型", + "description": "语音识别(ASR),按模型列表顺序自动选择", "capability": "audio_in", + "models": [], "providers": ["*"], "strategy": "priority", }, "auto-video": { "name": "视频生成", - "description": "文生视频,自动选择视频生成模型", + "description": "文生视频,按模型列表顺序自动选择", "capability": "video_gen", + "models": [], "providers": ["*"], "strategy": "priority", }, } +# 路由缓存配置(历史上下文优先复用上次模型,命中前缀缓存节省成本) +ROUTING_CONFIG = { + "prefer_cache_model": True, # 开启后:带历史上下文的 auto 请求优先复用上次使用的模型 + "cache_ttl_seconds": 3600, # 记忆有效期(秒) +} + + +def load_routing_config(): + """加载路由缓存配置(运行时配置优先)""" + config = load_config() + return {**ROUTING_CONFIG, **config.get("routing_config", {})} + + +def save_routing_config(data): + """保存路由缓存配置""" + config = load_config() + config["routing_config"] = {**ROUTING_CONFIG, **data} + save_config(config) + return config["routing_config"] + def load_config(): """加载配置""" diff --git a/data/config.json b/data/config.json index 44dfded..ff8ed12 100644 --- a/data/config.json +++ b/data/config.json @@ -131,8 +131,16 @@ "auto_profiles": { "auto": { "name": "默认Auto", - "description": "文本推理 - 自动选择可用提供商", + "description": "文本推理 - 按模型列表顺序自动选择", "capability": "text", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "deepseek-ai/DeepSeek-V4-Flash", + "meituan-longcat/LongCat-2.0", + "qwen3.6-plus", + "GLM-5.3-flash" + ], "providers": [ "*" ], @@ -140,8 +148,16 @@ }, "auto-text": { "name": "文本推理", - "description": "纯文本推理,自动选择文本模型", + "description": "纯文本推理,按模型列表顺序自动选择", "capability": "text", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "deepseek-ai/DeepSeek-V4-Flash", + "meituan-longcat/LongCat-2.0", + "qwen3.6-plus", + "GLM-5.3-flash" + ], "providers": [ "*" ], @@ -149,8 +165,14 @@ }, "auto-vision": { "name": "视觉能力", - "description": "多模态视觉理解,自动选择视觉模型", + "description": "多模态视觉理解,按模型列表顺序自动选择", "capability": "vision", + "models": [ + "unsloth/Qwen3.8-27B-Q6_K", + "unsloth/Qwen3.8-27B-Q4_K_M", + "qwen3.6-plus", + "GLM-5.3-flash" + ], "providers": [ "*" ], @@ -158,8 +180,11 @@ }, "auto-image": { "name": "图片生成", - "description": "文生图,自动选择生图模型", + "description": "文生图,按模型列表顺序自动选择", "capability": "image_gen", + "models": [ + "Qwen-Image" + ], "providers": [ "*" ], @@ -167,8 +192,9 @@ }, "auto-voice-out": { "name": "语音输出", - "description": "语音合成(TTS),自动选择语音输出模型", + "description": "语音合成(TTS),按模型列表顺序自动选择", "capability": "audio_out", + "models": [], "providers": [ "*" ], @@ -176,8 +202,9 @@ }, "auto-voice-in": { "name": "语音输入", - "description": "语音识别(ASR),自动选择语音输入模型", + "description": "语音识别(ASR),按模型列表顺序自动选择", "capability": "audio_in", + "models": [], "providers": [ "*" ], @@ -185,12 +212,17 @@ }, "auto-video": { "name": "视频生成", - "description": "文生视频,自动选择视频生成模型", + "description": "文生视频,按模型列表顺序自动选择", "capability": "video_gen", + "models": [], "providers": [ "*" ], "strategy": "priority" } + }, + "routing_config": { + "prefer_cache_model": true, + "cache_ttl_seconds": 3600 } } \ No newline at end of file diff --git a/templates/auto-profiles.html b/templates/auto-profiles.html index de8ae77..2288911 100644 --- a/templates/auto-profiles.html +++ b/templates/auto-profiles.html @@ -8,7 +8,9 @@
@@ -51,7 +53,7 @@每个Auto配置固定一个功能类型,并从模型管理中挑选具备该能力的模型
+每个Auto配置固定一个功能类型,按「具体模型」的顺序自动选择(可拖动排序)