mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-05-15 20:39:05 +08:00
本次提交引入了 API 请求统计功能,并将原“密钥状态”页面重构为功能更全面的“监控面板”。
主要变更包括:
- **数据库与服务层:**
- 新增 `RequestLog` 数据模型 (`app/database/models.py`),用于存储 API 请求的详细信息(时间、模型、密钥、成功状态、状态码、耗时)。
- 在 `app/database/services.py` 中添加 `add_request_log` 和 `get_request_stats` 函数,分别用于记录单次请求和获取时间窗口内的统计数据。
- 新增 `app/service/stats_service.py`,封装了获取 API 调用统计逻辑。
- **API 请求日志记录:**
- 在 Gemini (`gemini_chat_service.py`) 和 OpenAI (`openai_chat_service.py`) 聊天服务中,于 API 调用前后添加了 `add_request_log` 调用,以记录请求的成功与否及耗时。
- **前端监控面板:**
- 将 `/keys` 路由对应的页面 (`keys_status.html`) 从“密钥状态”重构为“监控面板”。
- 页面顶部新增统计卡片区域,展示:
- 密钥统计:总数、有效数、无效数。
- API 调用统计:1分钟内、1小时内、24小时内、本月调用次数。
- 密钥列表(有效/无效)采用响应式网格布局 (`grid`),并增加了悬停动效和边框高亮。
- 优化了有效密钥列表的筛选逻辑,在无匹配项时显示提示信息。
- 为新的统计卡片和列表项添加了相应的 CSS 样式。
- 更新了 `keys_status.js` 以支持筛选无结果时的提示。
- **路由与导航:**
- 在 `app/router/routes.py` 中添加了 `/stats` 端点,用于获取 API 统计数据。
- 更新了 `config_editor.html` 和 `error_logs.html` 中的导航链接,使其指向新的“监控面板”。
- **日志配置:**
- 在 `app/log/logger.py` 中,为 `sqlalchemy.exc` 设置了 WARNING 日志级别。
这些更改旨在提供更好的系统可观测性,方便用户监控 API 密钥状态和请求频率。
405 lines
17 KiB
Python
405 lines
17 KiB
Python
# app/services/chat_service.py
|
||
|
||
import json
|
||
import re
|
||
import datetime # Add datetime import
|
||
import time # Add time import
|
||
from copy import deepcopy
|
||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||
|
||
from app.config.config import settings
|
||
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
||
from app.handler.message_converter import OpenAIMessageConverter
|
||
from app.handler.response_handler import OpenAIResponseHandler
|
||
from app.handler.stream_optimizer import openai_optimizer
|
||
from app.log.logger import get_openai_logger
|
||
from app.service.client.api_client import GeminiApiClient
|
||
from app.service.image.image_create_service import ImageCreateService
|
||
from app.service.key.key_manager import KeyManager
|
||
from app.database.services import add_error_log, add_request_log # Import add_request_log
|
||
|
||
logger = get_openai_logger()
|
||
|
||
|
||
def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
|
||
"""判断消息是否包含图片部分"""
|
||
for content in contents:
|
||
if "parts" in content:
|
||
for part in content["parts"]:
|
||
if "image_url" in part or "inline_data" in part:
|
||
return True
|
||
return False
|
||
|
||
|
||
def _build_tools(
|
||
request: ChatRequest, messages: List[Dict[str, Any]]
|
||
) -> List[Dict[str, Any]]:
|
||
"""构建工具"""
|
||
tool = dict()
|
||
model = request.model
|
||
|
||
if (
|
||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||
and not (
|
||
model.endswith("-search")
|
||
or "-thinking" in model
|
||
or model.endswith("-image")
|
||
or model.endswith("-image-generation")
|
||
)
|
||
and not _has_image_parts(messages)
|
||
):
|
||
tool["codeExecution"] = {}
|
||
if model.endswith("-search"):
|
||
tool["googleSearch"] = {}
|
||
|
||
# 将 request 中的 tools 合并到 tools 中
|
||
if request.tools:
|
||
function_declarations = []
|
||
for item in request.tools:
|
||
if not item or not isinstance(item, dict):
|
||
continue
|
||
|
||
if item.get("type", "") == "function" and item.get("function"):
|
||
function = deepcopy(item.get("function"))
|
||
parameters = function.get("parameters", {})
|
||
if parameters.get("type") == "object" and not parameters.get("properties", {}):
|
||
function.pop("parameters", None)
|
||
|
||
function_declarations.append(function)
|
||
|
||
if function_declarations:
|
||
# 按照 function 的 name 去重
|
||
names, functions = set(), []
|
||
for fc in function_declarations:
|
||
if fc.get("name") not in names:
|
||
names.add(fc.get("name"))
|
||
functions.append(fc)
|
||
|
||
tool["functionDeclarations"] = functions
|
||
|
||
# 解决 "Tool use with function calling is unsupported" 问题
|
||
if tool.get("functionDeclarations"):
|
||
tool.pop("googleSearch", None)
|
||
tool.pop("codeExecution", None)
|
||
|
||
return [tool] if tool else []
|
||
|
||
|
||
def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||
"""获取安全设置"""
|
||
# if (
|
||
# "2.0" in model
|
||
# and "gemini-2.0-flash-thinking-exp" not in model
|
||
# and "gemini-2.0-pro-exp" not in model
|
||
# ):
|
||
if model == "gemini-2.0-flash-exp":
|
||
return [
|
||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
|
||
]
|
||
return [
|
||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
|
||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
|
||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
|
||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
|
||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
|
||
]
|
||
|
||
|
||
def _build_payload(
|
||
request: ChatRequest,
|
||
messages: List[Dict[str, Any]],
|
||
instruction: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""构建请求payload"""
|
||
payload = {
|
||
"contents": messages,
|
||
"generationConfig": {
|
||
"temperature": request.temperature,
|
||
"stopSequences": request.stop,
|
||
"topP": request.top_p,
|
||
"topK": request.top_k,
|
||
},
|
||
"tools": _build_tools(request, messages),
|
||
"safetySettings": _get_safety_settings(request.model),
|
||
}
|
||
if request.max_tokens is not None:
|
||
payload["generationConfig"]["maxOutputTokens"] = request.max_tokens
|
||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||
|
||
if (
|
||
instruction
|
||
and isinstance(instruction, dict)
|
||
and instruction.get("role") == "system"
|
||
and instruction.get("parts")
|
||
and not request.model.endswith("-image")
|
||
and not request.model.endswith("-image-generation")
|
||
):
|
||
payload["systemInstruction"] = instruction
|
||
|
||
return payload
|
||
|
||
|
||
class OpenAIChatService:
|
||
"""聊天服务"""
|
||
|
||
def __init__(self, base_url: str, key_manager: KeyManager = None):
|
||
self.message_converter = OpenAIMessageConverter()
|
||
self.response_handler = OpenAIResponseHandler(config=None)
|
||
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
|
||
self.key_manager = key_manager
|
||
self.image_create_service = ImageCreateService()
|
||
|
||
def _extract_text_from_openai_chunk(self, chunk: Dict[str, Any]) -> str:
|
||
"""从OpenAI响应块中提取文本内容"""
|
||
if not chunk.get("choices"):
|
||
return ""
|
||
|
||
choice = chunk["choices"][0]
|
||
if "delta" in choice and "content" in choice["delta"]:
|
||
return choice["delta"]["content"]
|
||
return ""
|
||
|
||
def _create_char_openai_chunk(
|
||
self, original_chunk: Dict[str, Any], text: str
|
||
) -> Dict[str, Any]:
|
||
"""创建包含指定文本的OpenAI响应块"""
|
||
chunk_copy = json.loads(json.dumps(original_chunk)) # 深拷贝
|
||
if chunk_copy.get("choices") and "delta" in chunk_copy["choices"][0]:
|
||
chunk_copy["choices"][0]["delta"]["content"] = text
|
||
return chunk_copy
|
||
|
||
async def create_chat_completion(
|
||
self,
|
||
request: ChatRequest,
|
||
api_key: str,
|
||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||
"""创建聊天完成"""
|
||
# 转换消息格式
|
||
messages, instruction = self.message_converter.convert(request.messages)
|
||
|
||
# 构建请求payload
|
||
payload = _build_payload(request, messages, instruction)
|
||
|
||
if request.stream:
|
||
return self._handle_stream_completion(request.model, payload, api_key)
|
||
return await self._handle_normal_completion(request.model, payload, api_key)
|
||
|
||
async def _handle_normal_completion(
|
||
self, model: str, payload: Dict[str, Any], api_key: str
|
||
) -> Dict[str, Any]:
|
||
"""处理普通聊天完成"""
|
||
start_time = time.perf_counter()
|
||
request_datetime = datetime.datetime.now()
|
||
is_success = False
|
||
status_code = None
|
||
response = None
|
||
try:
|
||
response = await self.api_client.generate_content(payload, model, api_key)
|
||
is_success = True
|
||
status_code = 200 # Assume 200 on success
|
||
return self.response_handler.handle_response(
|
||
response, model, stream=False, finish_reason="stop"
|
||
)
|
||
except Exception as e:
|
||
is_success = False
|
||
error_log_msg = str(e)
|
||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||
# Try to parse status code from exception
|
||
match = re.search(r"status code (\d+)", error_log_msg)
|
||
if match:
|
||
status_code = int(match.group(1))
|
||
else:
|
||
status_code = 500 # Default if parsing fails
|
||
|
||
await add_error_log(
|
||
gemini_key=api_key, # Note: Parameter name is gemini_key in add_error_log
|
||
model_name=model,
|
||
error_type="openai_chat_service", # Indicate service type
|
||
error_log=error_log_msg,
|
||
error_code=status_code,
|
||
request_msg=payload
|
||
)
|
||
raise e # Re-throw exception
|
||
finally:
|
||
end_time = time.perf_counter()
|
||
latency_ms = int((end_time - start_time) * 1000)
|
||
await add_request_log(
|
||
model_name=model,
|
||
api_key=api_key,
|
||
is_success=is_success,
|
||
status_code=status_code,
|
||
latency_ms=latency_ms,
|
||
request_time=request_datetime
|
||
)
|
||
|
||
async def _handle_stream_completion(
|
||
self, model: str, payload: Dict[str, Any], api_key: str
|
||
) -> AsyncGenerator[str, None]:
|
||
"""处理流式聊天完成,添加重试逻辑"""
|
||
retries = 0
|
||
max_retries = settings.MAX_RETRIES
|
||
start_time = time.perf_counter() # Record start time before loop
|
||
request_datetime = datetime.datetime.now()
|
||
is_success = False
|
||
status_code = None
|
||
final_api_key = api_key # Store the initial key
|
||
|
||
try:
|
||
while retries < max_retries:
|
||
current_attempt_key = api_key # Key used for this attempt
|
||
final_api_key = current_attempt_key # Update final key used
|
||
try:
|
||
tool_call_flag = False
|
||
async for line in self.api_client.stream_generate_content(
|
||
payload, model, current_attempt_key
|
||
):
|
||
# print(line)
|
||
if line.startswith("data:"):
|
||
chunk = json.loads(line[6:])
|
||
openai_chunk = self.response_handler.handle_response(
|
||
chunk, model, stream=True, finish_reason=None
|
||
)
|
||
if openai_chunk:
|
||
# 提取文本内容
|
||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||
if text and settings.STREAM_OPTIMIZER_ENABLED:
|
||
# 使用流式输出优化器处理文本输出
|
||
async for (
|
||
optimized_chunk
|
||
) in openai_optimizer.optimize_stream_output(
|
||
text,
|
||
lambda t: self._create_char_openai_chunk(
|
||
openai_chunk, t
|
||
),
|
||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||
):
|
||
yield optimized_chunk
|
||
else:
|
||
# 如果没有文本内容(如工具调用等),整块输出
|
||
if "tool_calls" in json.dumps(openai_chunk):
|
||
tool_call_flag = True
|
||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||
if tool_call_flag:
|
||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='tool_calls'))}\n\n"
|
||
else:
|
||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
logger.info("Streaming completed successfully")
|
||
is_success = True
|
||
status_code = 200 # Assume 200 on success
|
||
break # 成功后退出循环
|
||
except Exception as e:
|
||
retries += 1
|
||
is_success = False # Mark as failed for this attempt
|
||
error_log_msg = str(e)
|
||
logger.warning(
|
||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||
)
|
||
# Parse error code for logging
|
||
match = re.search(r"status code (\d+)", error_log_msg)
|
||
if match:
|
||
status_code = int(match.group(1))
|
||
else:
|
||
status_code = 500 # Default if parsing fails
|
||
|
||
# Log error to error log table
|
||
await add_error_log(
|
||
gemini_key=current_attempt_key, # Note: Parameter name is gemini_key
|
||
model_name=model,
|
||
error_type="openai_chat_service", # Indicate service type
|
||
error_log=error_log_msg,
|
||
error_code=status_code,
|
||
request_msg=payload
|
||
)
|
||
|
||
# Attempt to switch API Key
|
||
# Ensure key_manager is available (might need adjustment if not always passed)
|
||
if self.key_manager:
|
||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||
if api_key:
|
||
logger.info(f"Switched to new API key: {api_key}")
|
||
else:
|
||
logger.error(f"No valid API key available after {retries} retries.")
|
||
break # Exit loop if no key available
|
||
else:
|
||
logger.error("KeyManager not available for retry logic.")
|
||
break # Exit loop if key manager is missing
|
||
|
||
if retries >= max_retries:
|
||
logger.error(
|
||
f"Max retries ({max_retries}) reached for streaming."
|
||
)
|
||
break # Exit loop after max retries
|
||
finally:
|
||
# Log the final outcome of the streaming request
|
||
end_time = time.perf_counter()
|
||
latency_ms = int((end_time - start_time) * 1000)
|
||
await add_request_log(
|
||
model_name=model,
|
||
api_key=final_api_key, # Log the last key used
|
||
is_success=is_success, # Log the final success status
|
||
status_code=status_code, # Log the last known status code
|
||
latency_ms=latency_ms, # Log total time including retries
|
||
request_time=request_datetime
|
||
)
|
||
# If the loop finished due to failure, yield error and DONE
|
||
if not is_success and retries >= max_retries:
|
||
yield f"data: {json.dumps({'error': 'Streaming failed after retries'})}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
|
||
async def create_image_chat_completion(
|
||
self,
|
||
request: ChatRequest,
|
||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||
|
||
image_generate_request = ImageGenerationRequest()
|
||
image_generate_request.prompt = request.messages[-1]["content"]
|
||
image_res = self.image_create_service.generate_images_chat(
|
||
image_generate_request
|
||
)
|
||
|
||
if request.stream:
|
||
return self._handle_stream_image_completion(request.model, image_res)
|
||
else:
|
||
return self._handle_normal_image_completion(request.model, image_res)
|
||
|
||
async def _handle_stream_image_completion(
|
||
self, model: str, image_data: str
|
||
) -> AsyncGenerator[str, None]:
|
||
if image_data:
|
||
openai_chunk = self.response_handler.handle_image_chat_response(
|
||
image_data, model, stream=True, finish_reason=None
|
||
)
|
||
if openai_chunk:
|
||
# 提取文本内容
|
||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||
if text:
|
||
# 使用流式输出优化器处理文本输出
|
||
async for (
|
||
optimized_chunk
|
||
) in openai_optimizer.optimize_stream_output(
|
||
text,
|
||
lambda t: self._create_char_openai_chunk(openai_chunk, t),
|
||
lambda c: f"data: {json.dumps(c)}\n\n",
|
||
):
|
||
yield optimized_chunk
|
||
else:
|
||
# 如果没有文本内容(如图片URL等),整块输出
|
||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||
yield f"data: {json.dumps(self.response_handler.handle_response({}, model, stream=True, finish_reason='stop'))}\n\n"
|
||
yield "data: [DONE]\n\n"
|
||
logger.info("Image chat streaming completed successfully")
|
||
|
||
def _handle_normal_image_completion(
|
||
self, model: str, image_data: str
|
||
) -> Dict[str, Any]:
|
||
|
||
return self.response_handler.handle_image_chat_response(
|
||
image_data, model, stream=False, finish_reason="stop"
|
||
)
|