fix(api): 统一错误日志时间戳并传递 request_datetime

- 统一 add_error_log 的 request_time:优先使用 request_datetime,
  否则使用 datetime.now(),去除 timezone.utc,避免与请求日志时区不一致
- 在 Gemini/OpenAI/Vertex/Embedding 等服务的异常处理处补充传入
  request_datetime,使错误日志与请求日志可一一对应
- stats: 移除失败记录的错误日志时间窗匹配与 error_log_id 附带,降低查询开销
  与误关联风险;建议通过统一时间戳(key + request_time)或独立错误日志
  查询接口完成关联
- 调整部分导入顺序与长行换行等代码风格,无功能改动

BREAKING CHANGE: 统计详情接口不再返回 error_log_id 字段。需要关联错误日志的
客户端请改为基于 key 与 request_time 在错误日志接口中检索。
This commit is contained in:
snaily
2025-08-18 17:26:53 +08:00
parent 669123f348
commit 2001bfdcd9
8 changed files with 187 additions and 150 deletions

View File

@@ -141,9 +141,7 @@ async def add_error_log(
model_name=model_name,
error_code=error_code,
request_msg=request_msg_json,
request_time=(
request_datetime if request_datetime else datetime.now(timezone.utc)
),
request_time=(request_datetime if request_datetime else datetime.now()),
)
await database.execute(query)
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")

View File

@@ -1,19 +1,20 @@
# app/services/chat_service.py
import datetime
import json
import re
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.database.services import add_error_log, add_request_log, get_file_api_key
from app.domain.gemini_models import GeminiRequest
from app.handler.response_handler import GeminiResponseHandler
from app.handler.stream_optimizer import gemini_optimizer
from app.log.logger import get_gemini_logger
from app.service.client.api_client import GeminiApiClient
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log, add_request_log, get_file_api_key
from app.utils.helpers import redact_key_for_logging
logger = get_gemini_logger()
@@ -28,6 +29,7 @@ def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
return True
return False
def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
"""從內容中提取文件引用"""
file_names = []
@@ -42,7 +44,9 @@ def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
file_uri = file_data["fileUri"]
# 從 URI 中提取文件名
# 1. https://generativelanguage.googleapis.com/v1beta/files/{file_id}
match = re.match(rf"{re.escape(settings.BASE_URL)}/(files/.*)", file_uri)
match = re.match(
rf"{re.escape(settings.BASE_URL)}/(files/.*)", file_uri
)
if not match:
logger.warning(f"Invalid file URI: {file_uri}")
continue
@@ -51,19 +55,36 @@ def _extract_file_references(contents: List[Dict[str, Any]]) -> List[str]:
logger.info(f"Found file reference: {file_id}")
return file_names
def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
"exclusiveMaximum",
"exclusiveMinimum",
"const",
"examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
@@ -74,13 +95,13 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
"""检查内容中是否包含 functionCall"""
if not contents or not isinstance(contents, list):
@@ -95,7 +116,7 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(part, dict) and "functionCall" in part:
return True
return False
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
@@ -146,16 +167,18 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
if tool.get("functionDeclarations") or _has_function_call(
payload.get("contents", [])
):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
tool.pop("urlContext", None)
@@ -189,10 +212,16 @@ def _filter_empty_parts(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
filtered_contents = []
for content in contents:
if not content or "parts" not in content or not isinstance(content.get("parts"), list):
if (
not content
or "parts" not in content
or not isinstance(content.get("parts"), list)
):
continue
valid_parts = [part for part in content["parts"] if isinstance(part, dict) and part]
valid_parts = [
part for part in content["parts"] if isinstance(part, dict) and part
]
if valid_parts:
new_content = content.copy()
@@ -241,30 +270,32 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
if "gemini-2.5-pro" in model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000),
"includeThoughts": True,
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000)
}
return payload
@@ -311,11 +342,15 @@ class GeminiChatService:
logger.info(f"Request contains file references: {file_names}")
file_api_key = await get_file_api_key(file_names[0])
if file_api_key:
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
logger.info(
f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}"
)
api_key = file_api_key # 使用文件的 API key
else:
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
logger.warning(
f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}"
)
payload = _build_payload(model, request)
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
@@ -344,7 +379,8 @@ class GeminiChatService:
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
request_datetime=request_datetime,
)
raise e
finally:
@@ -356,7 +392,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def count_tokens(
@@ -364,7 +400,9 @@ class GeminiChatService:
) -> Dict[str, Any]:
"""计算token数量"""
# countTokens API只需要contents
payload = {"contents": _filter_empty_parts(request.model_dump().get("contents", []))}
payload = {
"contents": _filter_empty_parts(request.model_dump().get("contents", []))
}
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
@@ -392,7 +430,7 @@ class GeminiChatService:
error_type="gemini-count-tokens",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
)
raise e
finally:
@@ -404,7 +442,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def stream_generate_content(
@@ -417,11 +455,15 @@ class GeminiChatService:
logger.info(f"Request contains file references: {file_names}")
file_api_key = await get_file_api_key(file_names[0])
if file_api_key:
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
logger.info(
f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}"
)
api_key = file_api_key # 使用文件的 API key
else:
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
logger.warning(
f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}"
)
retries = 0
max_retries = settings.MAX_RETRIES
payload = _build_payload(model, request)
@@ -482,20 +524,23 @@ class GeminiChatService:
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
request_datetime=request_datetime,
)
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(f"No valid API key available after {retries} retries.")
break
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
end_time = time.perf_counter()
@@ -506,5 +551,5 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)

View File

@@ -559,6 +559,7 @@ class OpenAIChatService:
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_datetime=request_datetime,
)
if self.key_manager:
@@ -672,6 +673,7 @@ class OpenAIChatService:
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]},
request_datetime=request_datetime,
)
yield f"data: {json.dumps({'error': error_log_msg})}\n\n"
yield "data: [DONE]\n\n"
@@ -722,6 +724,7 @@ class OpenAIChatService:
error_log=error_log_msg,
error_code=status_code,
request_msg={"image_data_truncated": image_data[:1000]},
request_datetime=request_datetime,
)
raise e
finally:

View File

@@ -1,19 +1,20 @@
# app/services/chat_service.py
import datetime
import json
import re
import datetime
import time
from typing import Any, AsyncGenerator, Dict, List
from app.config.config import settings
from app.core.constants import GEMINI_2_FLASH_EXP_SAFETY_SETTINGS
from app.database.services import add_error_log, add_request_log
from app.domain.gemini_models import GeminiRequest
from app.handler.response_handler import GeminiResponseHandler
from app.handler.stream_optimizer import gemini_optimizer
from app.log.logger import get_gemini_logger
from app.service.client.api_client import GeminiApiClient
from app.service.key.key_manager import KeyManager
from app.database.services import add_error_log, add_request_log
from app.utils.helpers import redact_key_for_logging
logger = get_gemini_logger()
@@ -33,15 +34,31 @@ def _clean_json_schema_properties(obj: Any) -> Any:
"""清理JSON Schema中Gemini API不支持的字段"""
if not isinstance(obj, dict):
return obj
# Gemini API不支持的JSON Schema字段
unsupported_fields = {
"exclusiveMaximum", "exclusiveMinimum", "const", "examples",
"contentEncoding", "contentMediaType", "if", "then", "else",
"allOf", "anyOf", "oneOf", "not", "definitions", "$schema",
"$id", "$ref", "$comment", "readOnly", "writeOnly"
"exclusiveMaximum",
"exclusiveMinimum",
"const",
"examples",
"contentEncoding",
"contentMediaType",
"if",
"then",
"else",
"allOf",
"anyOf",
"oneOf",
"not",
"definitions",
"$schema",
"$id",
"$ref",
"$comment",
"readOnly",
"writeOnly",
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
@@ -52,13 +69,13 @@ def _clean_json_schema_properties(obj: Any) -> Any:
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
"""构建工具"""
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
"""检查内容中是否包含 functionCall"""
if not contents or not isinstance(contents, list):
@@ -73,7 +90,7 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
if isinstance(part, dict) and "functionCall" in part:
return True
return False
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
record = dict()
for item in tools:
@@ -124,16 +141,18 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
and not _has_image_parts(payload.get("contents", []))
):
tool["codeExecution"] = {}
if model.endswith("-search"):
tool["googleSearch"] = {}
real_model = _get_real_model(model)
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
tool["urlContext"] = {}
# 解决 "Tool use with function calling is unsupported" 问题
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
if tool.get("functionDeclarations") or _has_function_call(
payload.get("contents", [])
):
tool.pop("googleSearch", None)
tool.pop("codeExecution", None)
tool.pop("urlContext", None)
@@ -167,7 +186,7 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if request.generationConfig.maxOutputTokens is None:
# 如果未指定最大输出长度,则不传递该字段,解决截断的问题
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"tools": _build_tools(model, request_dict),
@@ -179,30 +198,32 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
client_thinking_config = None
if request.generationConfig and request.generationConfig.thinkingConfig:
client_thinking_config = request.generationConfig.thinkingConfig
if client_thinking_config is not None:
# 客户端提供了思考配置,直接使用
payload["generationConfig"]["thinkingConfig"] = client_thinking_config
else:
# 客户端没有提供思考配置,使用默认配置
# 客户端没有提供思考配置,使用默认配置
if model.endswith("-non-thinking"):
if "gemini-2.5-pro" in model:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000),
"includeThoughts": True,
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model, 1000)
}
return payload
@@ -271,7 +292,8 @@ class GeminiChatService:
error_type="gemini-chat-non-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
request_datetime=request_datetime,
)
raise e
finally:
@@ -283,7 +305,7 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)
async def stream_generate_content(
@@ -301,7 +323,7 @@ class GeminiChatService:
request_datetime = datetime.datetime.now()
start_time = time.perf_counter()
current_attempt_key = api_key
final_api_key = current_attempt_key # Update final key used
final_api_key = current_attempt_key # Update final key used
try:
async for line in self.api_client.stream_generate_content(
payload, model, current_attempt_key
@@ -350,20 +372,23 @@ class GeminiChatService:
error_type="gemini-chat-stream",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
request_msg=payload,
request_datetime=request_datetime,
)
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
api_key = await self.key_manager.handle_api_failure(
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(f"No valid API key available after {retries} retries.")
break
if retries >= max_retries:
logger.error(
f"Max retries ({max_retries}) reached for streaming."
)
logger.error(f"Max retries ({max_retries}) reached for streaming.")
break
finally:
end_time = time.perf_counter()
@@ -374,5 +399,5 @@ class GeminiChatService:
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
request_time=request_datetime,
)

View File

@@ -1,6 +1,6 @@
import datetime
import time
import re
import time
from typing import List, Union
import openai
@@ -8,8 +8,8 @@ from openai import APIStatusError
from openai.types import CreateEmbeddingResponse
from app.config.config import settings
from app.log.logger import get_embeddings_logger
from app.database.services import add_error_log, add_request_log
from app.log.logger import get_embeddings_logger
logger = get_embeddings_logger()
@@ -27,12 +27,20 @@ class EmbeddingService:
response = None
error_log_msg = ""
if isinstance(input_text, list):
request_msg_log = {"input_truncated": [str(item)[:100] + "..." if len(str(item)) > 100 else str(item) for item in input_text[:5]]}
request_msg_log = {
"input_truncated": [
str(item)[:100] + "..." if len(str(item)) > 100 else str(item)
for item in input_text[:5]
]
}
if len(input_text) > 5:
request_msg_log["input_truncated"].append("...")
request_msg_log["input_truncated"].append("...")
else:
request_msg_log = {"input_truncated": input_text[:1000] + "..." if len(input_text) > 1000 else input_text}
request_msg_log = {
"input_truncated": (
input_text[:1000] + "..." if len(input_text) > 1000 else input_text
)
}
try:
client = openai.OpenAI(api_key=api_key, base_url=settings.BASE_URL)
@@ -66,13 +74,14 @@ class EmbeddingService:
error_type="openai-embedding",
error_log=error_log_msg,
error_code=status_code,
request_msg=request_msg_log
)
request_msg=request_msg_log,
request_datetime=request_datetime,
)
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
request_time=request_datetime,
)

View File

@@ -84,6 +84,7 @@ class GeminiEmbeddingService:
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_datetime=request_datetime,
)
raise e
finally:
@@ -133,6 +134,7 @@ class GeminiEmbeddingService:
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_datetime=request_datetime,
)
raise e
finally:

View File

@@ -1,4 +1,3 @@
import datetime
import json
import re
@@ -11,20 +10,21 @@ from app.database.services import (
add_request_log,
)
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
from app.log.logger import get_openai_compatible_logger
from app.service.client.api_client import OpenaiApiClient
from app.service.key.key_manager import KeyManager
from app.utils.helpers import redact_key_for_logging
from app.log.logger import get_openai_compatible_logger
logger = get_openai_compatible_logger()
class OpenAICompatiableService:
def __init__(self, base_url: str, key_manager: KeyManager = None):
self.key_manager = key_manager
self.base_url = base_url
self.api_client = OpenaiApiClient(base_url, settings.TIME_OUT)
async def get_models(self, api_key: str) -> Dict[str, Any]:
return await self.api_client.get_models(api_key)
@@ -37,10 +37,12 @@ class OpenAICompatiableService:
request_dict = request.model_dump()
# 移除值为null的
request_dict = {k: v for k, v in request_dict.items() if v is not None}
del request_dict["top_k"] # 删除top_k参数目前不支持该参数
del request_dict["top_k"] # 删除top_k参数目前不支持该参数
if request.stream:
return self._handle_stream_completion(request.model, request_dict, api_key)
return await self._handle_normal_completion(request.model, request_dict, api_key)
return await self._handle_normal_completion(
request.model, request_dict, api_key
)
async def generate_images(
self,
@@ -153,6 +155,7 @@ class OpenAICompatiableService:
error_log=error_log_msg,
error_code=status_code,
request_msg=payload,
request_datetime=request_datetime,
)
if self.key_manager:
@@ -160,15 +163,17 @@ class OpenAICompatiableService:
current_attempt_key, retries
)
if api_key:
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
logger.info(
f"Switched to new API key: {redact_key_for_logging(api_key)}"
)
else:
logger.error(
f"No valid API key available after {retries} retries."
)
break
break
else:
logger.error("KeyManager not available for retry logic.")
break
break
if retries >= max_retries:
logger.error(f"Max retries ({max_retries}) reached for streaming.")
@@ -187,5 +192,3 @@ class OpenAICompatiableService:
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"

View File

@@ -178,9 +178,6 @@ class StatsService:
results = await database.fetch_all(query)
# 为失败调用尝试查找匹配的错误日志ID时间窗口 +/- 5 分钟)
from app.database.models import ErrorLog # 延迟导入避免循环依赖
details: list[dict] = []
for row in results:
status = "failure"
@@ -196,30 +193,6 @@ class StatsService:
"latency_ms": row["latency_ms"],
}
# 如果失败尝试附带一个相关的错误日志ID便于前端拉取详情
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time <= end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info(
@@ -260,8 +233,6 @@ class StatsService:
results = await database.fetch_all(query)
from app.database.models import ErrorLog
details: list[dict] = []
for row in results:
status = "failure"
@@ -277,29 +248,6 @@ class StatsService:
"latency_ms": row["latency_ms"],
}
if status == "failure" and row["key"]:
try:
ts = row["timestamp"]
start_win = ts - datetime.timedelta(minutes=5)
end_win = ts + datetime.timedelta(minutes=5)
err_query = (
select(ErrorLog.id)
.where(
ErrorLog.gemini_key == row["key"],
ErrorLog.request_time >= start_win,
ErrorLog.request_time < end_win,
)
.order_by(ErrorLog.request_time.desc())
.limit(1)
)
err = await database.fetch_one(err_query)
if err:
record["error_log_id"] = err["id"]
except Exception as _e:
logger.debug(
f"No matching error log found for key ending ...{row['key'][-4:] if row['key'] else ''}: {_e}"
)
details.append(record)
logger.info(
@@ -312,7 +260,9 @@ class StatsService:
)
raise
async def get_attention_keys_last_24h(self, include_keys: set[str], limit: int = 20, status_code: int = 429) -> list[dict]:
async def get_attention_keys_last_24h(
self, include_keys: set[str], limit: int = 20, status_code: int = 429
) -> list[dict]:
"""返回最近24小时内指定状态码(默认429)最多的Key列表仅包含include_keys中的Key。
Returns: [{"key": str, "count": int, "status_code": int}, ...] 按次数降序
@@ -344,7 +294,9 @@ class StatsService:
if row["key"]
]
except Exception as e:
logger.error(f"Failed to get attention keys ({status_code}) in last 24h: {e}")
logger.error(
f"Failed to get attention keys ({status_code}) in last 24h: {e}"
)
return []
async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]: