3 Commits

Author SHA1 Message Date
8086d76e93 feat: 添加浏览器标签图标 favicon
- 创建 SVG 格式 favicon(深色背景+大脑/网络节点设计)
- 在所有后台管理页面添加 favicon
2026-04-11 11:31:17 +08:00
247f9e2165 fix: 修复高优先级提供商失败时自动切换到备用提供商
- 任何非200响应(400、429、500等)都会触发切换
- 增加 exclude_providers 参数排除已尝试的提供商
- 避免重复尝试失败的提供商
- 添加切换日志便于调试
2026-04-10 01:51:36 +08:00
e96cf6527a feat: 添加请求统计功能 + API使用示例
- 主服务增加请求统计(请求次数、成功/失败、token数)
- 按模型和提供商分别统计
- 每日请求计数自动重置
- 首页增加多种API调用示例(curl、Python OpenAI SDK、Python requests、JavaScript fetch)
2026-04-10 01:34:32 +08:00
9 changed files with 160 additions and 16 deletions

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect x="2" y="2" width="28" height="28" rx="4" fill="#1a1a2e"/>
<circle cx="16" cy="16" r="10" fill="#4a90d9" opacity="0.3"/>
<path d="M16 6 L16 10 M16 22 L16 26 M6 16 L10 16 M22 16 L26 16" stroke="#4a90d9" stroke-width="2"/>
<circle cx="16" cy="16" r="6" fill="#4a90d9"/>
<path d="M12 14 L16 16 L20 14 M12 18 L16 16 L20 18" stroke="#fff" stroke-width="1.5" fill="none"/>
<circle cx="10" cy="10" r="2" fill="#28a745"/>
<circle cx="22" cy="10" r="2" fill="#28a745"/>
<circle cx="10" cy="22" r="2" fill="#28a745"/>
<circle cx="22" cy="22" r="2" fill="#28a745"/>
<path d="M10 10 L16 16 M22 10 L16 16 M10 22 L16 16 M22 22 L16 16" stroke="#28a745" stroke-width="1" opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auto配置管理 - LLM Proxy</title> <title>Auto配置管理 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>对话 - LLM Proxy</title> <title>对话 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统配置 - LLM Proxy</title> <title>系统配置 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Proxy - 后台管理</title> <title>LLM Proxy - 后台管理</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>日志查看 - LLM Proxy</title> <title>日志查看 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型管理 - LLM Proxy</title> <title>模型管理 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>提供商管理 - LLM Proxy</title> <title>提供商管理 - LLM Proxy</title>
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style> <style>

157
app.py
View File

@@ -9,9 +9,10 @@ import requests
import json import json
import time import time
import logging import logging
from datetime import datetime from datetime import datetime, date
from pathlib import Path from pathlib import Path
import sys import sys
import threading
# 添加配置路径 # 添加配置路径
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
@@ -20,6 +21,75 @@ from config.settings import (
LOG_CONFIG, RETRY_CONFIG LOG_CONFIG, RETRY_CONFIG
) )
# 数据目录和统计文件
DATA_DIR = Path(__file__).parent / 'data'
DATA_DIR.mkdir(exist_ok=True)
STATS_FILE = DATA_DIR / 'stats.json'
# 统计锁(避免并发写入冲突)
stats_lock = threading.Lock()
def load_stats():
"""加载统计数据"""
if STATS_FILE.exists():
try:
return json.loads(STATS_FILE.read_text(encoding='utf-8'))
except:
pass
return {
'total_requests': 0,
'total_success': 0,
'total_errors': 0,
'total_tokens': 0,
'requests_today': 0,
'requests_by_model': {},
'providers': {},
'last_updated': None,
'date': None # 用于判断是否需要重置每日计数
}
def save_stats(stats):
"""保存统计数据"""
stats['last_updated'] = datetime.now().isoformat()
STATS_FILE.write_text(json.dumps(stats, ensure_ascii=False, indent=2), encoding='utf-8')
def increment_stats(model, provider_name, success=False, tokens=0, error=None):
"""增加统计计数"""
with stats_lock:
stats = load_stats()
today = date.today().isoformat()
# 如果是新的一天,重置每日计数
if stats.get('date') != today:
stats['date'] = today
stats['requests_today'] = 0
stats['total_requests'] += 1
stats['requests_today'] += 1
# 模型统计
if model not in stats['requests_by_model']:
stats['requests_by_model'][model] = {'count': 0, 'success': 0, 'tokens': 0}
stats['requests_by_model'][model]['count'] += 1
if success:
stats['requests_by_model'][model]['success'] += 1
stats['requests_by_model'][model]['tokens'] += tokens
# 提供商统计
if provider_name not in stats['providers']:
stats['providers'][provider_name] = {'requests': 0, 'success': 0, 'errors': 0, 'tokens': 0}
stats['providers'][provider_name]['requests'] += 1
if success:
stats['providers'][provider_name]['success'] += 1
stats['providers'][provider_name]['tokens'] += tokens
stats['total_success'] += 1
stats['total_tokens'] += tokens
else:
stats['providers'][provider_name]['errors'] += 1
stats['total_errors'] += 1
save_stats(stats)
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@@ -107,10 +177,17 @@ def get_provider_for_model(model_name):
return None, None return None, None
def get_available_provider_for_auto(auto_name='auto'): def get_available_provider_for_auto(auto_name='auto', exclude_providers=None):
"""获取auto模式下的可用提供商支持自定义auto配置""" """获取auto模式下的可用提供商支持自定义auto配置
Args:
auto_name: auto配置名称
exclude_providers: 要排除的提供商名称列表(已尝试过的)
"""
refresh_config() refresh_config()
exclude_providers = exclude_providers or []
# 获取auto配置 # 获取auto配置
profile = _cached_auto_profiles.get(auto_name, _cached_auto_profiles.get('auto', {})) profile = _cached_auto_profiles.get(auto_name, _cached_auto_profiles.get('auto', {}))
@@ -124,6 +201,8 @@ def get_available_provider_for_auto(auto_name='auto'):
for provider in sorted_providers: for provider in sorted_providers:
if not provider['enabled']: if not provider['enabled']:
continue continue
if provider['name'] in exclude_providers:
continue
if not provider_status.get(provider['name'], {}).get('available', True): if not provider_status.get(provider['name'], {}).get('available', True):
continue continue
@@ -244,8 +323,27 @@ def index():
"endpoints": { "endpoints": {
"chat": "/v1/chat/completions", "chat": "/v1/chat/completions",
"models": "/v1/models", "models": "/v1/models",
"embeddings": "/v1/embeddings",
"health": "/health", "health": "/health",
"status": "/status" "status": "/status"
},
"examples": {
"curl": {
"description": "curl 命令行",
"code": "curl -X POST http://localhost:19007/v1/chat/completions -H 'Content-Type: application/json' -d '{\"model\": \"auto\", \"messages\": [{\"role\": \"user\", \"content\": \"你好\"}]}'"
},
"python_openai": {
"description": "Python OpenAI SDK",
"code": "from openai import OpenAI\nclient = OpenAI(base_url='http://localhost:19007/v1', api_key='any')\nresponse = client.chat.completions.create(model='auto', messages=[{'role': 'user', 'content': '你好'}])\nprint(response.choices[0].message.content)"
},
"python_requests": {
"description": "Python requests",
"code": "import requests\nresponse = requests.post('http://localhost:19007/v1/chat/completions', json={'model': 'auto', 'messages': [{'role': 'user', 'content': '你好'}]})\nprint(response.json()['choices'][0]['message']['content'])"
},
"javascript": {
"description": "JavaScript fetch",
"code": "fetch('http://localhost:19007/v1/chat/completions', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({model: 'auto', messages: [{role: 'user', content: '你好'}]})}).then(r => r.json()).then(d => console.log(d.choices[0].message.content))"
}
} }
}) })
@@ -292,19 +390,29 @@ def list_models():
@app.route('/v1/chat/completions', methods=['POST']) @app.route('/v1/chat/completions', methods=['POST'])
def chat_completions(): def chat_completions():
"""聊天完成API""" """聊天完成API"""
# 用于统计的变量
request_model = None
request_provider = None
request_success = False
request_tokens = 0
try: try:
data = request.get_json() data = request.get_json()
if not data: if not data:
increment_stats('unknown', 'unknown', success=False, error='Invalid request body')
return jsonify({"error": "Invalid request body"}), 400 return jsonify({"error": "Invalid request body"}), 400
model = data.get('model', 'auto') model = data.get('model', 'auto')
stream = data.get('stream', False) stream = data.get('stream', False)
request_model = model
# 获取提供商 # 获取提供商
provider, resolved_model = get_provider_for_model(model) provider, resolved_model = get_provider_for_model(model)
if not provider: if not provider:
increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}')
return jsonify({ return jsonify({
"error": { "error": {
"message": f"No available provider for model: {model}", "message": f"No available provider for model: {model}",
@@ -312,6 +420,8 @@ def chat_completions():
} }
}), 400 }), 400
request_provider = provider['name']
logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}") logger.info(f"Request: model={model} -> provider={provider['name']}, resolved_model={resolved_model}, stream={stream}")
# 重试逻辑 # 重试逻辑
@@ -324,9 +434,11 @@ def chat_completions():
if response.status_code == 200: if response.status_code == 200:
mark_provider_success(provider['name']) mark_provider_success(provider['name'])
request_success = True
if stream: if stream:
# 流式响应 # 流式响应 - 统计流式请求
increment_stats(model, provider['name'], success=True, tokens=0)
return Response( return Response(
stream_with_context(stream_response(response)), stream_with_context(stream_response(response)),
content_type='text/event-stream', content_type='text/event-stream',
@@ -336,26 +448,36 @@ def chat_completions():
} }
) )
else: else:
# 非流式响应 # 非流式响应 - 提取token统计
return jsonify(response.json()) result = response.json()
# 尝试提取usage信息
usage = result.get('usage', {})
request_tokens = usage.get('total_tokens', 0)
elif response.status_code == 429: increment_stats(model, provider['name'], success=True, tokens=request_tokens)
# 速率限制,尝试下一个提供商 return jsonify(result)
mark_provider_error(provider['name'], "Rate limit")
else:
# 任何非200响应都尝试下一个提供商
error_info = response.json() if response.headers.get('content-type', '').startswith('application/json') else {"error": response.text}
last_error = error_info
logger.warning(f"Provider {provider['name']} returned {response.status_code}: {error_info}")
mark_provider_error(provider['name'], f"HTTP {response.status_code}")
tried_providers.append(provider['name']) tried_providers.append(provider['name'])
# 尝试下一个提供商 # 尝试下一个提供商
next_provider, next_model = get_available_provider_for_auto('auto') next_provider, next_model = get_available_provider_for_auto('auto', exclude_providers=tried_providers)
if next_provider and next_provider['name'] not in tried_providers: if next_provider and next_provider['name'] not in tried_providers:
logger.info(f"Switching to next provider: {next_provider['name']}")
provider = next_provider provider = next_provider
resolved_model = next_model resolved_model = next_model
request_provider = provider['name']
time.sleep(RETRY_CONFIG['retry_delay'])
continue continue
return jsonify(response.json()), response.status_code # 所有提供商都尝试过了,返回最后一个错误
increment_stats(model, provider['name'], success=False, error=str(last_error))
else: return jsonify(error_info), response.status_code
last_error = response.json() if response.headers.get('content-type', '').startswith('application/json') else {"error": response.text}
return jsonify(last_error), response.status_code
except Exception as e: except Exception as e:
last_error = str(e) last_error = str(e)
@@ -363,14 +485,16 @@ def chat_completions():
tried_providers.append(provider['name']) tried_providers.append(provider['name'])
# 尝试下一个提供商 # 尝试下一个提供商
next_provider, next_model = get_available_provider_for_auto('auto') next_provider, next_model = get_available_provider_for_auto('auto', exclude_providers=tried_providers)
if next_provider and next_provider['name'] not in tried_providers: if next_provider and next_provider['name'] not in tried_providers:
provider = next_provider provider = next_provider
resolved_model = next_model resolved_model = next_model
request_provider = provider['name']
time.sleep(RETRY_CONFIG['retry_delay']) time.sleep(RETRY_CONFIG['retry_delay'])
continue continue
# 所有重试都失败 # 所有重试都失败
increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error))
return jsonify({ return jsonify({
"error": { "error": {
"message": f"All providers failed. Last error: {last_error}", "message": f"All providers failed. Last error: {last_error}",
@@ -380,6 +504,7 @@ def chat_completions():
except Exception as e: except Exception as e:
logger.error(f"Unexpected error: {e}") logger.error(f"Unexpected error: {e}")
increment_stats(request_model or 'unknown', request_provider or 'unknown', success=False, error=str(e))
return jsonify({ return jsonify({
"error": { "error": {
"message": str(e), "message": str(e),