v2.1.1: Auto配置改为模型粒度拖动排序 + 历史缓存复用 + 对话生图
- Auto配置编辑: 粒度从提供商改为具体模型, 原生HTML5拖动排序, 修复已保存模型不显示勾选(字符串/对象混拼bug) - 历史缓存: 带历史上下文的auto请求按首条消息识别会话, 优先复用上次模型命中上游前缀缓存 (可配置: prefer_cache_model + cache_ttl_seconds, 系统配置页开关 + /api/admin/routing) - 对话生图: chat页选 auto-image/Qwen-Image 直接调生图接口并在对话内展示图片 - 修复: Flask模板缓存导致改模板不生效 -> TEMPLATES_AUTO_RELOAD - 修复: 自动回退/显式有序模型列表路由, 空列表回退提供商能力选择
This commit is contained in:
@@ -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']})
|
||||
|
||||
Reference in New Issue
Block a user