mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 06:11:32 +08:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b65814bc | ||
|
|
88f5b33018 | ||
|
|
8c62c8121d | ||
|
|
05762cb6a5 | ||
|
|
78f38cc981 | ||
|
|
79f47c315e | ||
|
|
708fb1604b | ||
|
|
7dbd3ad693 | ||
|
|
67dd1af583 | ||
|
|
e104a50cf4 | ||
|
|
6b9647813b | ||
|
|
f863e3065b | ||
|
|
1314e0ee09 | ||
|
|
81d92370ad | ||
|
|
5f6eba62cc | ||
|
|
a8a265c2a7 | ||
|
|
ee21e50305 | ||
|
|
611559d298 | ||
|
|
b0127e6fc2 | ||
|
|
1d15a21ce5 | ||
|
|
c206aa8e4a | ||
|
|
3f040b7075 | ||
|
|
1771555fe9 | ||
|
|
8711088ebc | ||
|
|
bb6c629aef | ||
|
|
4af17ce55d | ||
|
|
2001bfdcd9 | ||
|
|
669123f348 | ||
|
|
d06e418a61 | ||
|
|
fa6745454e | ||
|
|
1aa3d267bb | ||
|
|
e9601ca76c | ||
|
|
01312317a1 | ||
|
|
7827283d0a | ||
|
|
96c4b4fa50 | ||
|
|
892392742d | ||
|
|
380e6426ed | ||
|
|
d2906d89a6 | ||
|
|
13e1db7d69 | ||
|
|
40c9689eae | ||
|
|
548dcccf2f | ||
|
|
b52092a72b | ||
|
|
67efd067c6 | ||
|
|
fd39c2c9cb | ||
|
|
f58ae2b340 | ||
|
|
f51a4d20ad | ||
|
|
b89d3ea144 | ||
|
|
3d6b5063d5 |
20
.env.example
20
.env.example
@@ -14,11 +14,11 @@ AUTH_TOKEN=sk-123456
|
||||
VERTEX_API_KEYS=["AQ.Abxxxxxxxxxxxxxxxxxxx"]
|
||||
# For Vertex AI Platform Express API Base URL
|
||||
VERTEX_EXPRESS_BASE_URL=https://aiplatform.googleapis.com/v1beta1/publishers/google
|
||||
TEST_MODEL=gemini-1.5-flash
|
||||
THINKING_MODELS=["gemini-2.5-flash-preview-04-17"]
|
||||
THINKING_BUDGET_MAP={"gemini-2.5-flash-preview-04-17": 4000}
|
||||
IMAGE_MODELS=["gemini-2.0-flash-exp"]
|
||||
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
|
||||
TEST_MODEL=gemini-2.5-flash-lite
|
||||
THINKING_MODELS=["gemini-2.5-flash","gemini-2.5-pro"]
|
||||
THINKING_BUDGET_MAP={"gemini-2.5-flash": -1}
|
||||
IMAGE_MODELS=["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]
|
||||
SEARCH_MODELS=["gemini-2.5-flash","gemini-2.5-pro"]
|
||||
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
|
||||
# 是否启用网址上下文,默认启用
|
||||
URL_CONTEXT_ENABLED=false
|
||||
@@ -44,9 +44,17 @@ CREATE_IMAGE_MODEL=imagen-3.0-generate-002
|
||||
UPLOAD_PROVIDER=smms
|
||||
SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
PICGO_API_KEY=xxxx
|
||||
PICGO_API_URL=https://www.picgo.net/api/1/upload
|
||||
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
|
||||
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
|
||||
# 阿里云OSS配置
|
||||
OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
OSS_ENDPOINT_INNER=oss-cn-shanghai-internal.aliyuncs.com
|
||||
OSS_ACCESS_KEY=LTAI5txxxxxxxxxxxxxxxx
|
||||
OSS_ACCESS_KEY_SECRET=yXxxxxxxxxxxxxxxxxxxxxx
|
||||
OSS_BUCKET_NAME=your-bucket-name
|
||||
OSS_REGION=cn-shanghai
|
||||
##########################################################################
|
||||
#########################stream_optimizer 相关配置########################
|
||||
STREAM_OPTIMIZER_ENABLED=false
|
||||
@@ -59,6 +67,8 @@ STREAM_CHUNK_SIZE=5
|
||||
######################### 日志配置 #######################################
|
||||
# 日志级别 (debug, info, warning, error, critical),默认为 info
|
||||
LOG_LEVEL=info
|
||||
# 是否记录错误日志的请求体(可能包含敏感信息),默认 false
|
||||
ERROR_LOG_RECORD_REQUEST_BODY=false
|
||||
# 是否开启自动删除错误日志
|
||||
AUTO_DELETE_ERROR_LOGS_ENABLED=true
|
||||
# 自动删除多少天前的错误日志 (1, 7, 30)
|
||||
|
||||
@@ -8,6 +8,9 @@ COPY ./VERSION /app
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY ./app /app/app
|
||||
ENV API_KEYS='["your_api_key_1"]'
|
||||
ENV ALLOWED_TOKENS='["your_token_1"]'
|
||||
ENV TZ='Asia/Shanghai'
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
22
README.md
22
README.md
@@ -137,6 +137,8 @@ app/
|
||||
|
||||
### Gemini API Format (`/gemini/v1beta`)
|
||||
|
||||
This endpoint is directly forwarded to official Gemini API format endpoint, without advanced features.
|
||||
|
||||
* `GET /models`: List available Gemini models.
|
||||
* `POST /models/{model_name}:generateContent`: Generate content.
|
||||
* `POST /models/{model_name}:streamGenerateContent`: Stream content generation.
|
||||
@@ -145,6 +147,8 @@ app/
|
||||
|
||||
#### Hugging Face (HF) Compatible
|
||||
|
||||
If you want to use advanced features, like fake streaming, please use this endpoint.
|
||||
|
||||
* `GET /hf/v1/models`: List models.
|
||||
* `POST /hf/v1/chat/completions`: Chat completion.
|
||||
* `POST /hf/v1/embeddings`: Create text embeddings.
|
||||
@@ -152,6 +156,8 @@ app/
|
||||
|
||||
#### Standard OpenAI
|
||||
|
||||
This endpoint is directly forwarded to official OpenAI Compatible API format endpoint, without advanced features.
|
||||
|
||||
* `GET /openai/v1/models`: List models.
|
||||
* `POST /openai/v1/chat/completions`: Chat completion (Recommended).
|
||||
* `POST /openai/v1/embeddings`: Create text embeddings.
|
||||
@@ -178,9 +184,9 @@ app/
|
||||
| `ALLOWED_TOKENS` | **Required**, list of access tokens | `[]` |
|
||||
| `AUTH_TOKEN` | Super admin token, defaults to the first of `ALLOWED_TOKENS` | `sk-123456` |
|
||||
| `ADMIN_SESSION_EXPIRE` | Admin session expiration time in seconds (5 minutes to 24 hours) | `3600` |
|
||||
| `TEST_MODEL` | Model for testing key validity | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | Models supporting image generation | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | Models supporting web search | `["gemini-2.0-flash-exp"]` |
|
||||
| `TEST_MODEL` | Model for testing key validity | `gemini-2.5-flash-lite` |
|
||||
| `IMAGE_MODELS` | Models supporting image generation | `["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]` |
|
||||
| `SEARCH_MODELS` | Models supporting web search | `["gemini-2.5-flash","gemini-2.5-pro"]` |
|
||||
| `FILTERED_MODELS` | Disabled models | `[]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | Enable code execution tool | `false` |
|
||||
| `SHOW_SEARCH_LINK` | Display search result links in response | `true` |
|
||||
@@ -199,6 +205,7 @@ app/
|
||||
| `PROXIES` | List of proxy servers | `[]` |
|
||||
| **Logging & Security** | | |
|
||||
| `LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
|
||||
| `ERROR_LOG_RECORD_REQUEST_BODY` | Record request body in error logs (may contain sensitive information) | `false` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | Auto-delete error logs | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | Error log retention period (days) | `7` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Auto-delete request logs | `false` |
|
||||
@@ -211,9 +218,16 @@ app/
|
||||
| **Image Generation** | | |
|
||||
| `PAID_KEY` | Paid API Key for advanced features | `your-paid-api-key` |
|
||||
| `CREATE_IMAGE_MODEL` | Image generation model | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | Image upload provider: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `UPLOAD_PROVIDER` | Image upload provider: `smms`, `picgo`, `cloudflare_imgbed`, `aliyun_oss` | `smms` |
|
||||
| `OSS_ENDPOINT` | Aliyun OSS public endpoint | `oss-cn-shanghai.aliyuncs.com` |
|
||||
| `OSS_ENDPOINT_INNER` | Aliyun OSS internal endpoint (intra-VPC) | `oss-cn-shanghai-internal.aliyuncs.com` |
|
||||
| `OSS_ACCESS_KEY` | Aliyun AccessKey ID | `LTAI5txxxxxxxxxxxxxxxx` |
|
||||
| `OSS_ACCESS_KEY_SECRET` | Aliyun AccessKey Secret | `yXxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| `OSS_BUCKET_NAME` | Aliyun OSS bucket name | `your-bucket-name` |
|
||||
| `OSS_REGION` | Aliyun OSS region | `cn-shanghai` |
|
||||
| `SMMS_SECRET_TOKEN` | SM.MS API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | PicoGo API Key | `your-picogo-apikey` |
|
||||
| `PICGO_API_URL` | PicoGo API Server URL | `https://www.picgo.net/api/1/upload` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | CloudFlare ImgBed upload URL | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare ImgBed auth key | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare ImgBed upload folder | `""` |
|
||||
|
||||
22
README_ZH.md
22
README_ZH.md
@@ -138,6 +138,8 @@ app/
|
||||
|
||||
### Gemini API 格式 (`/gemini/v1beta`)
|
||||
|
||||
此端点将请求直接转发到官方 Gemini API 格式的端点,不包含高级功能。
|
||||
|
||||
* `GET /models`: 列出可用的 Gemini 模型。
|
||||
* `POST /models/{model_name}:generateContent`: 生成内容。
|
||||
* `POST /models/{model_name}:streamGenerateContent`: 流式生成内容。
|
||||
@@ -146,6 +148,8 @@ app/
|
||||
|
||||
#### 兼容 huggingface (HF) 格式
|
||||
|
||||
如果您需要使用高级功能(例如假流式输出),请使用此端点。
|
||||
|
||||
* `GET /hf/v1/models`: 列出模型。
|
||||
* `POST /hf/v1/chat/completions`: 聊天补全。
|
||||
* `POST /hf/v1/embeddings`: 创建文本嵌入。
|
||||
@@ -153,6 +157,8 @@ app/
|
||||
|
||||
#### 标准 OpenAI 格式
|
||||
|
||||
此端点直接转发至官方的 OpenAI 兼容 API 格式端点,不包含高级功能。
|
||||
|
||||
* `GET /openai/v1/models`: 列出模型。
|
||||
* `POST /openai/v1/chat/completions`: 聊天补全 (推荐,速度更快,防截断)。
|
||||
* `POST /openai/v1/embeddings`: 创建文本嵌入。
|
||||
@@ -178,9 +184,9 @@ app/
|
||||
| `API_KEYS` | **必填**, Gemini API 密钥列表,用于负载均衡 | `[]` |
|
||||
| `ALLOWED_TOKENS` | **必填**, 允许访问的 Token 列表 | `[]` |
|
||||
| `AUTH_TOKEN` | 超级管理员 Token,不填则使用 `ALLOWED_TOKENS` 的第一个 | `sk-123456` |
|
||||
| `TEST_MODEL` | 用于测试密钥可用性的模型 | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | 支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | 支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `TEST_MODEL` | 用于测试密钥可用性的模型 | `gemini-2.5-flash-lite` |
|
||||
| `IMAGE_MODELS` | 支持绘图功能的模型列表 | `["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]` |
|
||||
| `SEARCH_MODELS` | 支持搜索功能的模型列表 | `["gemini-2.5-flash","gemini-2.5-pro"]` |
|
||||
| `FILTERED_MODELS` | 被禁用的模型列表 | `[]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | 是否启用代码执行工具 | `false` |
|
||||
| `SHOW_SEARCH_LINK` | 是否在响应中显示搜索结果链接 | `true` |
|
||||
@@ -199,6 +205,7 @@ app/
|
||||
| `PROXIES` | 代理服务器列表 (例如 `http://user:pass@host:port`) | `[]` |
|
||||
| **日志与安全** | | |
|
||||
| `LOG_LEVEL` | 日志级别: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
|
||||
| `ERROR_LOG_RECORD_REQUEST_BODY` | 是否记录错误日志的请求体(可能包含敏感信息) | `false` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 是否自动删除错误日志 | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 错误日志保留天数 | `7` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 是否自动删除请求日志 | `false` |
|
||||
@@ -211,9 +218,16 @@ app/
|
||||
| **图像生成相关** | | |
|
||||
| `PAID_KEY` | 付费版API Key,用于图片生成等高级功能 | `your-paid-api-key` |
|
||||
| `CREATE_IMAGE_MODEL` | 图片生成模型 | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | 图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `UPLOAD_PROVIDER` | 图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed`, `aliyun_oss` | `smms` |
|
||||
| `OSS_ENDPOINT` | 阿里云 OSS 公网 Endpoint | `oss-cn-shanghai.aliyuncs.com` |
|
||||
| `OSS_ENDPOINT_INNER` | 阿里云 OSS 内网 Endpoint(同 VPC 内网访问) | `oss-cn-shanghai-internal.aliyuncs.com` |
|
||||
| `OSS_ACCESS_KEY` | 阿里云 AccessKey ID | `LTAI5txxxxxxxxxxxxxxxx` |
|
||||
| `OSS_ACCESS_KEY_SECRET` | 阿里云 AccessKey Secret | `yXxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| `OSS_BUCKET_NAME` | 阿里云 OSS Bucket 名称 | `your-bucket-name` |
|
||||
| `OSS_REGION` | 阿里云 OSS 区域 Region | `cn-shanghai` |
|
||||
| `SMMS_SECRET_TOKEN` | SM.MS图床的API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | [PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
|
||||
| `PICGO_API_URL` | [PicoGo](https://www.picgo.net/)图床的API服务器地址 | `https://www.picgo.net/api/1/upload` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare图床的上传文件夹路径 | `""` |
|
||||
|
||||
@@ -6,7 +6,7 @@ import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List, Type, get_args, get_origin
|
||||
|
||||
from pydantic import ValidationError, ValidationInfo, field_validator, Field
|
||||
from pydantic import Field, ValidationError, ValidationInfo, field_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy import insert, select, update
|
||||
|
||||
@@ -51,8 +51,8 @@ class Settings(BaseSettings):
|
||||
return v
|
||||
|
||||
# API相关配置
|
||||
API_KEYS: List[str]=[]
|
||||
ALLOWED_TOKENS: List[str]=[]
|
||||
API_KEYS: List[str] = []
|
||||
ALLOWED_TOKENS: List[str] = []
|
||||
BASE_URL: str = f"https://generativelanguage.googleapis.com/{API_VERSION}"
|
||||
AUTH_TOKEN: str = ""
|
||||
MAX_FAILURES: int = 3
|
||||
@@ -62,7 +62,9 @@ class Settings(BaseSettings):
|
||||
PROXIES: List[str] = []
|
||||
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: bool = True # 是否使用一致性哈希来选择代理
|
||||
VERTEX_API_KEYS: List[str] = []
|
||||
VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
|
||||
VERTEX_EXPRESS_BASE_URL: str = (
|
||||
"https://aiplatform.googleapis.com/v1beta1/publishers/google"
|
||||
)
|
||||
|
||||
# 智能路由配置
|
||||
URL_NORMALIZATION_ENABLED: bool = False # 是否启用智能路由映射功能
|
||||
@@ -71,13 +73,19 @@ class Settings(BaseSettings):
|
||||
CUSTOM_HEADERS: Dict[str, str] = {}
|
||||
|
||||
# 模型相关配置
|
||||
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
|
||||
SEARCH_MODELS: List[str] = ["gemini-2.5-flash", "gemini-2.5-pro"]
|
||||
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp", "gemini-2.5-flash-image-preview"]
|
||||
FILTERED_MODELS: List[str] = DEFAULT_FILTER_MODELS
|
||||
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
||||
# 是否启用网址上下文
|
||||
URL_CONTEXT_ENABLED: bool = False
|
||||
URL_CONTEXT_MODELS: List[str] = ["gemini-2.5-pro","gemini-2.5-flash","gemini-2.5-flash-lite","gemini-2.0-flash","gemini-2.0-flash-live-001"]
|
||||
URL_CONTEXT_MODELS: List[str] = [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-live-001",
|
||||
]
|
||||
SHOW_SEARCH_LINK: bool = True
|
||||
SHOW_THINKING_PROCESS: bool = True
|
||||
THINKING_MODELS: List[str] = []
|
||||
@@ -94,9 +102,17 @@ class Settings(BaseSettings):
|
||||
UPLOAD_PROVIDER: str = "smms"
|
||||
SMMS_SECRET_TOKEN: str = ""
|
||||
PICGO_API_KEY: str = ""
|
||||
PICGO_API_URL: str = "https://www.picgo.net/api/1/upload"
|
||||
CLOUDFLARE_IMGBED_URL: str = ""
|
||||
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
|
||||
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
|
||||
# 阿里云OSS配置
|
||||
OSS_ENDPOINT: str = ""
|
||||
OSS_ENDPOINT_INNER: str = ""
|
||||
OSS_ACCESS_KEY: str = ""
|
||||
OSS_ACCESS_KEY_SECRET: str = ""
|
||||
OSS_BUCKET_NAME: str = ""
|
||||
OSS_REGION: str = ""
|
||||
|
||||
# 流式输出优化器配置
|
||||
STREAM_OPTIMIZER_ENABLED: bool = False
|
||||
@@ -120,6 +136,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
ERROR_LOG_RECORD_REQUEST_BODY: bool = False
|
||||
AUTO_DELETE_ERROR_LOGS_ENABLED: bool = True
|
||||
AUTO_DELETE_ERROR_LOGS_DAYS: int = 7
|
||||
AUTO_DELETE_REQUEST_LOGS_ENABLED: bool = False
|
||||
@@ -136,7 +153,7 @@ class Settings(BaseSettings):
|
||||
default=3600,
|
||||
ge=300,
|
||||
le=86400,
|
||||
description="Admin session expiration time in seconds (5 minutes to 24 hours)"
|
||||
description="Admin session expiration time in seconds (5 minutes to 24 hours)",
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -168,7 +185,9 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed]
|
||||
except json.JSONDecodeError:
|
||||
return [item.strip() for item in db_value.split(",") if item.strip()]
|
||||
return [
|
||||
item.strip() for item in db_value.split(",") if item.strip()
|
||||
]
|
||||
logger.warning(
|
||||
f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list."
|
||||
)
|
||||
@@ -220,7 +239,9 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Could not parse '{db_value}' as Dict[str, str] for key '{key}'. Returning empty dict.")
|
||||
logger.error(
|
||||
f"Could not parse '{db_value}' as Dict[str, str] for key '{key}'. Returning empty dict."
|
||||
)
|
||||
return parsed_dict
|
||||
# 处理 Dict[str, float]
|
||||
elif args and args == (str, float):
|
||||
@@ -242,7 +263,9 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
|
||||
corrected_db_value = db_value.replace("'", '"')
|
||||
parsed = json.loads(corrected_db_value)
|
||||
if isinstance(parsed, dict):
|
||||
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
|
||||
parsed_dict = {
|
||||
str(k): float(v) for k, v in parsed.items()
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}"
|
||||
@@ -403,9 +426,7 @@ async def sync_initial_settings():
|
||||
|
||||
# 序列化值为字符串或 JSON 字符串
|
||||
if isinstance(value, (list, dict)):
|
||||
db_value = json.dumps(
|
||||
value, ensure_ascii=False
|
||||
)
|
||||
db_value = json.dumps(value, ensure_ascii=False)
|
||||
elif isinstance(value, bool):
|
||||
db_value = str(value).lower()
|
||||
elif value is None:
|
||||
|
||||
@@ -9,7 +9,7 @@ MAX_RETRIES = 3 # 最大重试次数
|
||||
|
||||
# 模型相关常量
|
||||
SUPPORTED_ROLES = ["user", "model", "system"]
|
||||
DEFAULT_MODEL = "gemini-1.5-flash"
|
||||
DEFAULT_MODEL = "gemini-2.5-flash-lite"
|
||||
DEFAULT_TEMPERATURE = 0.7
|
||||
DEFAULT_MAX_TOKENS = 8192
|
||||
DEFAULT_TOP_P = 0.9
|
||||
@@ -27,7 +27,7 @@ DEFAULT_CREATE_IMAGE_MODEL = "imagen-3.0-generate-002"
|
||||
VALID_IMAGE_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"]
|
||||
|
||||
# 上传提供商
|
||||
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed"]
|
||||
UPLOAD_PROVIDERS = ["smms", "picgo", "cloudflare_imgbed", "aliyun_oss"]
|
||||
DEFAULT_UPLOAD_PROVIDER = "smms"
|
||||
|
||||
# 流式输出相关常量
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"""
|
||||
数据库服务模块
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import func, desc, asc, select, insert, update, delete
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from sqlalchemy import asc, delete, desc, func, insert, select, update
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings, ErrorLog, RequestLog, FileRecord, FileState
|
||||
from app.database.models import ErrorLog, FileRecord, FileState, RequestLog, Settings
|
||||
from app.log.logger import get_database_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
@@ -16,7 +20,7 @@ logger = get_database_logger()
|
||||
async def get_all_settings() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有设置
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 设置列表
|
||||
"""
|
||||
@@ -32,10 +36,10 @@ async def get_all_settings() -> List[Dict[str, Any]]:
|
||||
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定键的设置
|
||||
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 设置信息,如果不存在则返回None
|
||||
"""
|
||||
@@ -48,22 +52,24 @@ async def get_setting(key: str) -> Optional[Dict[str, Any]]:
|
||||
raise
|
||||
|
||||
|
||||
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
|
||||
async def update_setting(
|
||||
key: str, value: str, description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
更新设置
|
||||
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
value: 设置值
|
||||
description: 设置描述
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
try:
|
||||
# 检查设置是否存在
|
||||
setting = await get_setting(key)
|
||||
|
||||
|
||||
if setting:
|
||||
# 更新设置
|
||||
query = (
|
||||
@@ -72,7 +78,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
.values(
|
||||
value=value,
|
||||
description=description if description else setting["description"],
|
||||
updated_at=datetime.now()
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
@@ -80,15 +86,12 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
return True
|
||||
else:
|
||||
# 插入设置
|
||||
query = (
|
||||
insert(Settings)
|
||||
.values(
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
query = insert(Settings).values(
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Inserted setting: {key}")
|
||||
@@ -104,44 +107,45 @@ async def add_error_log(
|
||||
error_type: Optional[str] = None,
|
||||
error_log: Optional[str] = None,
|
||||
error_code: Optional[int] = None,
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None,
|
||||
request_datetime: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加错误日志
|
||||
|
||||
|
||||
Args:
|
||||
gemini_key: Gemini API密钥
|
||||
error_log: 错误日志
|
||||
error_code: 错误代码 (例如 HTTP 状态码)
|
||||
request_msg: 请求消息
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否添加成功
|
||||
"""
|
||||
try:
|
||||
# 如果request_msg是字典,则转换为JSON字符串
|
||||
if isinstance(request_msg, dict):
|
||||
request_msg_json = request_msg
|
||||
elif isinstance(request_msg, str):
|
||||
try:
|
||||
request_msg_json = json.loads(request_msg)
|
||||
except json.JSONDecodeError:
|
||||
request_msg_json = {"message": request_msg}
|
||||
else:
|
||||
if request_msg is None:
|
||||
request_msg_json = None
|
||||
|
||||
else:
|
||||
# 如果request_msg是字典,则转换为JSON字符串
|
||||
if isinstance(request_msg, dict):
|
||||
request_msg_json = request_msg
|
||||
elif isinstance(request_msg, str):
|
||||
try:
|
||||
request_msg_json = json.loads(request_msg)
|
||||
except json.JSONDecodeError:
|
||||
request_msg_json = {"message": request_msg}
|
||||
else:
|
||||
request_msg_json = None
|
||||
|
||||
# 插入错误日志
|
||||
query = (
|
||||
insert(ErrorLog)
|
||||
.values(
|
||||
gemini_key=gemini_key,
|
||||
error_type=error_type,
|
||||
error_log=error_log,
|
||||
model_name=model_name,
|
||||
error_code=error_code,
|
||||
request_msg=request_msg_json,
|
||||
request_time=datetime.now()
|
||||
)
|
||||
query = insert(ErrorLog).values(
|
||||
gemini_key=gemini_key,
|
||||
error_type=error_type,
|
||||
error_log=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()),
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
|
||||
@@ -159,8 +163,8 @@ async def get_error_logs(
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
sort_by: str = 'id',
|
||||
sort_order: str = 'desc'
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索、日期过滤和排序
|
||||
@@ -187,15 +191,15 @@ async def get_error_logs(
|
||||
ErrorLog.error_type,
|
||||
ErrorLog.error_log,
|
||||
ErrorLog.error_code,
|
||||
ErrorLog.request_time
|
||||
ErrorLog.request_time,
|
||||
)
|
||||
|
||||
|
||||
if key_search:
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%"))
|
||||
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
@@ -206,10 +210,12 @@ async def get_error_logs(
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
logger.warning(
|
||||
f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter."
|
||||
)
|
||||
|
||||
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
|
||||
if sort_order.lower() == 'asc':
|
||||
if sort_order.lower() == "asc":
|
||||
query = query.order_by(asc(sort_column))
|
||||
else:
|
||||
query = query.order_by(desc(sort_column))
|
||||
@@ -228,7 +234,7 @@ async def get_error_logs_count(
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""
|
||||
获取符合条件的错误日志总数
|
||||
@@ -250,8 +256,8 @@ async def get_error_logs_count(
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%"))
|
||||
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
@@ -262,8 +268,9 @@ async def get_error_logs_count(
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
|
||||
logger.warning(
|
||||
f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter."
|
||||
)
|
||||
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
@@ -289,12 +296,14 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
if result:
|
||||
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
|
||||
log_dict = dict(result)
|
||||
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
|
||||
if "request_msg" in log_dict and log_dict["request_msg"] is not None:
|
||||
# 确保即使是 None 或非 JSON 数据也能处理
|
||||
try:
|
||||
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
|
||||
log_dict["request_msg"] = json.dumps(
|
||||
log_dict["request_msg"], ensure_ascii=False, indent=2
|
||||
)
|
||||
except TypeError:
|
||||
log_dict['request_msg'] = str(log_dict['request_msg'])
|
||||
log_dict["request_msg"] = str(log_dict["request_msg"])
|
||||
return log_dict
|
||||
else:
|
||||
return None
|
||||
@@ -303,6 +312,78 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
raise
|
||||
|
||||
|
||||
# 新增函数:通过 gemini_key / error_code / 时间窗口 查找最接近的错误日志
|
||||
async def find_error_log_by_info(
|
||||
gemini_key: str,
|
||||
timestamp: datetime,
|
||||
status_code: Optional[int] = None,
|
||||
window_seconds: int = 1,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
在给定时间窗口内,根据 gemini_key(精确匹配)及可选的 status_code 查找最接近 timestamp 的错误日志。
|
||||
|
||||
假设错误日志的 error_code 存储的是 HTTP 状态码或等价错误码。
|
||||
|
||||
Args:
|
||||
gemini_key: 完整的 Gemini key 字符串。
|
||||
timestamp: 目标时间(UTC 或本地,与存储一致)。
|
||||
status_code: 可选的错误码,若提供则优先匹配该错误码。
|
||||
window_seconds: 允许的时间偏差窗口,单位秒,默认为 1 秒。
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 最匹配的一条错误日志的完整详情(字段与 get_error_log_details 一致),若未找到则返回 None。
|
||||
"""
|
||||
try:
|
||||
start_time = timestamp - timedelta(seconds=window_seconds)
|
||||
end_time = timestamp + timedelta(seconds=window_seconds)
|
||||
|
||||
base_query = select(ErrorLog).where(
|
||||
ErrorLog.gemini_key == gemini_key,
|
||||
ErrorLog.request_time >= start_time,
|
||||
ErrorLog.request_time <= end_time,
|
||||
)
|
||||
|
||||
# 若提供了状态码,先尝试按状态码过滤
|
||||
if status_code is not None:
|
||||
query = base_query.where(ErrorLog.error_code == status_code).order_by(
|
||||
ErrorLog.request_time.desc()
|
||||
)
|
||||
candidates = await database.fetch_all(query)
|
||||
if not candidates:
|
||||
# 回退:不按状态码,仅按时间窗口
|
||||
query2 = base_query.order_by(ErrorLog.request_time.desc())
|
||||
candidates = await database.fetch_all(query2)
|
||||
else:
|
||||
query = base_query.order_by(ErrorLog.request_time.desc())
|
||||
candidates = await database.fetch_all(query)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# 在 Python 中选择与 timestamp 最接近的一条
|
||||
def _to_dict(row: Any) -> Dict[str, Any]:
|
||||
d = dict(row)
|
||||
if "request_msg" in d and d["request_msg"] is not None:
|
||||
try:
|
||||
d["request_msg"] = json.dumps(
|
||||
d["request_msg"], ensure_ascii=False, indent=2
|
||||
)
|
||||
except TypeError:
|
||||
d["request_msg"] = str(d["request_msg"])
|
||||
return d
|
||||
|
||||
best = min(
|
||||
candidates,
|
||||
key=lambda r: abs((r["request_time"] - timestamp).total_seconds()),
|
||||
)
|
||||
return _to_dict(best)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to find error log by info (key=***{gemini_key[-4:] if gemini_key else ''}, code={status_code}, ts={timestamp}, window={window_seconds}s): {str(e)}"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||
"""
|
||||
根据提供的 ID 列表批量删除错误日志 (异步)。
|
||||
@@ -327,12 +408,15 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||
# 注意:databases 的 execute 不返回 rowcount,所以我们不能直接返回删除的数量
|
||||
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
|
||||
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
except Exception as e:
|
||||
# 数据库连接或执行错误
|
||||
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
"""
|
||||
根据 ID 删除单个错误日志 (异步)。
|
||||
@@ -349,7 +433,9 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
exists = await database.fetch_one(check_query)
|
||||
|
||||
if not exists:
|
||||
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
||||
logger.warning(
|
||||
f"Attempted to delete non-existent error log with ID: {log_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 执行删除
|
||||
@@ -360,35 +446,57 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
async def delete_all_error_logs() -> int:
|
||||
"""
|
||||
删除所有错误日志条目。
|
||||
|
||||
分批删除所有错误日志,以避免大数据量下的超时和性能问题。
|
||||
|
||||
Returns:
|
||||
int: 被删除的错误日志数量。
|
||||
int: 被删除的错误日志总数。
|
||||
"""
|
||||
total_deleted_count = 0
|
||||
# SQLite 对 SQL 参数数量有上限(常见为 999),IN 子句中过多参数会报错
|
||||
# 统一使用 500,兼容 SQLite/MySQL,必要时可在配置中暴露该值
|
||||
batch_size = 200
|
||||
|
||||
try:
|
||||
# 1. 获取删除前的总数
|
||||
count_query = select(func.count()).select_from(ErrorLog)
|
||||
total_to_delete = await database.fetch_val(count_query)
|
||||
|
||||
if total_to_delete == 0:
|
||||
logger.info("No error logs found to delete.")
|
||||
return 0
|
||||
|
||||
# 2. 执行删除操作
|
||||
delete_query = delete(ErrorLog)
|
||||
await database.execute(delete_query)
|
||||
|
||||
logger.info(f"Successfully deleted all {total_to_delete} error logs.")
|
||||
return total_to_delete
|
||||
while True:
|
||||
# 1) 读取一批待删除的ID,仅选择ID列以提升效率
|
||||
id_query = select(ErrorLog.id).order_by(ErrorLog.id).limit(batch_size)
|
||||
rows = await database.fetch_all(id_query)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
ids = [row["id"] for row in rows]
|
||||
|
||||
# 2) 按ID批量删除
|
||||
delete_query = delete(ErrorLog).where(ErrorLog.id.in_(ids))
|
||||
await database.execute(delete_query)
|
||||
|
||||
deleted_in_batch = len(ids)
|
||||
total_deleted_count += deleted_in_batch
|
||||
|
||||
logger.debug(f"Deleted a batch of {deleted_in_batch} error logs.")
|
||||
|
||||
# 若不足一个批次,说明已删除完成
|
||||
if deleted_in_batch < batch_size:
|
||||
break
|
||||
|
||||
# 3) 将控制权交还事件循环,缓解长时间占用
|
||||
await asyncio.sleep(0)
|
||||
|
||||
logger.info(
|
||||
f"Successfully deleted all error logs in batches. Total deleted: {total_deleted_count}"
|
||||
)
|
||||
return total_deleted_count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True)
|
||||
logger.error(
|
||||
f"Failed to delete all error logs in batches: {str(e)}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
model_name: Optional[str],
|
||||
@@ -396,7 +504,7 @@ async def add_request_log(
|
||||
is_success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
request_time: Optional[datetime] = None
|
||||
request_time: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加 API 请求日志
|
||||
@@ -421,7 +529,7 @@ async def add_request_log(
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
await database.execute(query)
|
||||
return True
|
||||
@@ -432,6 +540,7 @@ async def add_request_log(
|
||||
|
||||
# ==================== 文件记录相关函数 ====================
|
||||
|
||||
|
||||
async def create_file_record(
|
||||
name: str,
|
||||
mime_type: str,
|
||||
@@ -445,11 +554,11 @@ async def create_file_record(
|
||||
display_name: Optional[str] = None,
|
||||
sha256_hash: Optional[str] = None,
|
||||
upload_url: Optional[str] = None,
|
||||
user_token: Optional[str] = None
|
||||
user_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
创建文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称(格式: files/{file_id})
|
||||
mime_type: MIME 类型
|
||||
@@ -463,7 +572,7 @@ async def create_file_record(
|
||||
sha256_hash: SHA256 哈希值
|
||||
upload_url: 临时上传 URL
|
||||
user_token: 上传用户的 token
|
||||
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 创建的文件记录
|
||||
"""
|
||||
@@ -481,10 +590,10 @@ async def create_file_record(
|
||||
uri=uri,
|
||||
api_key=api_key,
|
||||
upload_url=upload_url,
|
||||
user_token=user_token
|
||||
user_token=user_token,
|
||||
)
|
||||
await database.execute(query)
|
||||
|
||||
|
||||
# 返回创建的记录
|
||||
return await get_file_record_by_name(name)
|
||||
except Exception as e:
|
||||
@@ -495,10 +604,10 @@ async def create_file_record(
|
||||
async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据文件名获取文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称(格式: files/{file_id})
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 文件记录,如果不存在则返回 None
|
||||
"""
|
||||
@@ -511,24 +620,23 @@ async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def update_file_record_state(
|
||||
file_name: str,
|
||||
state: FileState,
|
||||
update_time: Optional[datetime] = None,
|
||||
upload_completed: Optional[datetime] = None,
|
||||
sha256_hash: Optional[str] = None
|
||||
sha256_hash: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
更新文件记录状态
|
||||
|
||||
|
||||
Args:
|
||||
file_name: 文件名
|
||||
state: 新状态
|
||||
update_time: 更新时间
|
||||
upload_completed: 上传完成时间
|
||||
sha256_hash: SHA256 哈希值
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
@@ -540,14 +648,14 @@ async def update_file_record_state(
|
||||
values["upload_completed"] = upload_completed
|
||||
if sha256_hash:
|
||||
values["sha256_hash"] = sha256_hash
|
||||
|
||||
|
||||
query = update(FileRecord).where(FileRecord.name == file_name).values(**values)
|
||||
result = await database.execute(query)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"Updated file record state for {file_name} to {state}")
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"File record not found for update: {file_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -559,31 +667,33 @@ async def list_file_records(
|
||||
user_token: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
page_size: int = 10,
|
||||
page_token: Optional[str] = None
|
||||
page_token: Optional[str] = None,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
列出文件记录
|
||||
|
||||
|
||||
Args:
|
||||
user_token: 用户 token(如果提供,只返回该用户的文件)
|
||||
api_key: API Key(如果提供,只返回使用该 key 的文件)
|
||||
page_size: 每页大小
|
||||
page_token: 分页标记(偏移量)
|
||||
|
||||
|
||||
Returns:
|
||||
tuple[List[Dict[str, Any]], Optional[str]]: (文件列表, 下一页标记)
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"list_file_records called with page_size={page_size}, page_token={page_token}")
|
||||
logger.debug(
|
||||
f"list_file_records called with page_size={page_size}, page_token={page_token}"
|
||||
)
|
||||
query = select(FileRecord).where(
|
||||
FileRecord.expiration_time > datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
if user_token:
|
||||
query = query.where(FileRecord.user_token == user_token)
|
||||
if api_key:
|
||||
query = query.where(FileRecord.api_key == api_key)
|
||||
|
||||
|
||||
# 使用偏移量进行分页
|
||||
offset = 0
|
||||
if page_token:
|
||||
@@ -592,16 +702,18 @@ async def list_file_records(
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid page token: {page_token}")
|
||||
offset = 0
|
||||
|
||||
|
||||
# 按ID升序排列,使用 OFFSET 和 LIMIT
|
||||
query = query.order_by(FileRecord.id).offset(offset).limit(page_size + 1)
|
||||
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
|
||||
logger.debug(f"Query returned {len(results)} records")
|
||||
if results:
|
||||
logger.debug(f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}")
|
||||
|
||||
logger.debug(
|
||||
f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}"
|
||||
)
|
||||
|
||||
# 处理分页
|
||||
has_next = len(results) > page_size
|
||||
if has_next:
|
||||
@@ -609,11 +721,13 @@ async def list_file_records(
|
||||
# 下一页的偏移量是当前偏移量加上本页返回的记录数
|
||||
next_offset = offset + page_size
|
||||
next_page_token = str(next_offset)
|
||||
logger.debug(f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}")
|
||||
logger.debug(
|
||||
f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}"
|
||||
)
|
||||
else:
|
||||
next_page_token = None
|
||||
logger.debug(f"No next page, returning {len(results)} results")
|
||||
|
||||
|
||||
return [dict(row) for row in results], next_page_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list file records: {str(e)}")
|
||||
@@ -623,10 +737,10 @@ async def list_file_records(
|
||||
async def delete_file_record(name: str) -> bool:
|
||||
"""
|
||||
删除文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
@@ -642,7 +756,7 @@ async def delete_file_record(name: str) -> bool:
|
||||
async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
删除已过期的文件记录
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 删除的记录列表
|
||||
"""
|
||||
@@ -652,16 +766,16 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
FileRecord.expiration_time <= datetime.now(timezone.utc)
|
||||
)
|
||||
expired_records = await database.fetch_all(query)
|
||||
|
||||
|
||||
if not expired_records:
|
||||
return []
|
||||
|
||||
|
||||
# 执行删除
|
||||
delete_query = delete(FileRecord).where(
|
||||
FileRecord.expiration_time <= datetime.now(timezone.utc)
|
||||
)
|
||||
await database.execute(delete_query)
|
||||
|
||||
|
||||
logger.info(f"Deleted {len(expired_records)} expired file records")
|
||||
return [dict(record) for record in expired_records]
|
||||
except Exception as e:
|
||||
@@ -672,17 +786,17 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
async def get_file_api_key(name: str) -> Optional[str]:
|
||||
"""
|
||||
获取文件对应的 API Key
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[str]: API Key,如果文件不存在或已过期则返回 None
|
||||
"""
|
||||
try:
|
||||
query = select(FileRecord.api_key).where(
|
||||
(FileRecord.name == name) &
|
||||
(FileRecord.expiration_time > datetime.now(timezone.utc))
|
||||
(FileRecord.name == name)
|
||||
& (FileRecord.expiration_time > datetime.now(timezone.utc))
|
||||
)
|
||||
result = await database.fetch_one(query)
|
||||
return result["api_key"] if result else None
|
||||
|
||||
@@ -80,3 +80,36 @@ class ResetSelectedKeysRequest(BaseModel):
|
||||
|
||||
class VerifySelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
|
||||
|
||||
class GeminiEmbedContent(BaseModel):
|
||||
"""嵌入内容模型"""
|
||||
|
||||
parts: List[Dict[str, str]]
|
||||
|
||||
|
||||
class GeminiEmbedRequest(BaseModel):
|
||||
"""单一嵌入请求模型"""
|
||||
|
||||
content: GeminiEmbedContent
|
||||
taskType: Optional[
|
||||
Literal[
|
||||
"TASK_TYPE_UNSPECIFIED",
|
||||
"RETRIEVAL_QUERY",
|
||||
"RETRIEVAL_DOCUMENT",
|
||||
"SEMANTIC_SIMILARITY",
|
||||
"CLASSIFICATION",
|
||||
"CLUSTERING",
|
||||
"QUESTION_ANSWERING",
|
||||
"FACT_VERIFICATION",
|
||||
"CODE_RETRIEVAL_QUERY",
|
||||
]
|
||||
] = None
|
||||
title: Optional[str] = None
|
||||
outputDimensionality: Optional[int] = None
|
||||
|
||||
|
||||
class GeminiBatchEmbedRequest(BaseModel):
|
||||
"""批量嵌入请求模型"""
|
||||
|
||||
requests: List[GeminiEmbedRequest]
|
||||
|
||||
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
||||
max_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = DEFAULT_TOP_P
|
||||
top_k: Optional[int] = DEFAULT_TOP_K
|
||||
n: Optional[int] = 1
|
||||
stop: Optional[Union[List[str],str]] = None
|
||||
reasoning_effort: Optional[str] = None
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
|
||||
@@ -131,10 +131,5 @@ def setup_exception_handlers(app: FastAPI) -> None:
|
||||
logger.exception(f"Unhandled Exception: {str(exc)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": {
|
||||
"code": "internal_server_error",
|
||||
"message": "An unexpected error occurred",
|
||||
}
|
||||
},
|
||||
content=str(exc),
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ class MessageConverter(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def convert(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
self, messages: List[Dict[str, Any]], model: str
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
pass
|
||||
|
||||
@@ -84,7 +84,7 @@ def _convert_image_to_base64(url: str) -> str:
|
||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||
|
||||
|
||||
def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
||||
def _process_text_with_image(text: str, model: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
处理可能包含图片URL的文本,提取图片并转换为base64
|
||||
|
||||
@@ -94,17 +94,31 @@ def _process_text_with_image(text: str) -> List[Dict[str, Any]]:
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含文本和图片的部分列表
|
||||
"""
|
||||
# 如果模型名中没有包含image,当作普通文本处理
|
||||
if "image" not in model:
|
||||
return [{"text": text}]
|
||||
parts = []
|
||||
img_url_match = re.search(IMAGE_URL_PATTERN, text)
|
||||
if img_url_match:
|
||||
# 提取URL
|
||||
img_url = img_url_match.group(2)
|
||||
# 将URL对应的图片转换为base64
|
||||
# 先判断是否是base64url如果是,直接用,不过不是,再将URL对应的图片转换为base64
|
||||
try:
|
||||
base64_data = _convert_image_to_base64(img_url)
|
||||
parts.append(
|
||||
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
||||
)
|
||||
base64_url_match = re.search(DATA_URL_PATTERN, img_url)
|
||||
if base64_url_match:
|
||||
parts.append(
|
||||
{
|
||||
"inline_data": {
|
||||
"mimeType": base64_url_match.group(1),
|
||||
"data": base64_url_match.group(2),
|
||||
}
|
||||
}
|
||||
)
|
||||
else:
|
||||
base64_data = _convert_image_to_base64(img_url)
|
||||
parts.append(
|
||||
{"inline_data": {"mimeType": "image/png", "data": base64_data}}
|
||||
)
|
||||
except Exception:
|
||||
# 如果转换失败,回退到文本模式
|
||||
parts.append({"text": text})
|
||||
@@ -145,7 +159,7 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
raise
|
||||
|
||||
def convert(
|
||||
self, messages: List[Dict[str, Any]]
|
||||
self, messages: List[Dict[str, Any]], model: str
|
||||
) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||
converted_messages = []
|
||||
system_instruction_parts = []
|
||||
@@ -296,7 +310,7 @@ class OpenAIMessageConverter(MessageConverter):
|
||||
elif (
|
||||
"content" in msg and isinstance(msg["content"], str) and msg["content"]
|
||||
):
|
||||
parts.extend(_process_text_with_image(msg["content"]))
|
||||
parts.extend(_process_text_with_image(msg["content"], model))
|
||||
elif "tool_calls" in msg and isinstance(msg["tool_calls"], list):
|
||||
# Keep existing tool call processing
|
||||
for tool_call in msg["tool_calls"]:
|
||||
|
||||
@@ -8,8 +8,9 @@ from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.config.config import settings
|
||||
from app.utils.uploader import ImageUploaderFactory
|
||||
from app.log.logger import get_openai_logger
|
||||
from app.utils.helpers import is_image_upload_configured
|
||||
from app.utils.uploader import ImageUploaderFactory
|
||||
|
||||
logger = get_openai_logger()
|
||||
|
||||
@@ -32,7 +33,11 @@ class GeminiResponseHandler(ResponseHandler):
|
||||
self.thinking_status = False
|
||||
|
||||
def handle_response(
|
||||
self, response: Dict[str, Any], model: str, stream: bool = False, usage_metadata: Optional[Dict[str, Any]] = None
|
||||
self,
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
stream: bool = False,
|
||||
usage_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if stream:
|
||||
return _handle_gemini_stream_response(response, model, stream)
|
||||
@@ -40,53 +45,86 @@ class GeminiResponseHandler(ResponseHandler):
|
||||
|
||||
|
||||
def _handle_openai_stream_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
finish_reason: str,
|
||||
usage_metadata: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
response, model, stream=True, gemini_format=False
|
||||
)
|
||||
if not text and not tool_calls and not reasoning_content:
|
||||
delta = {}
|
||||
else:
|
||||
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
|
||||
if tool_calls:
|
||||
delta["tool_calls"] = tool_calls
|
||||
choices = []
|
||||
candidates = response.get("candidates", [])
|
||||
|
||||
for candidate in candidates:
|
||||
index = candidate.get("index", 0)
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
{"candidates": [candidate]}, model, stream=True, gemini_format=False
|
||||
)
|
||||
|
||||
if not text and not tool_calls and not reasoning_content:
|
||||
delta = {}
|
||||
else:
|
||||
delta = {
|
||||
"content": text,
|
||||
"reasoning_content": reasoning_content,
|
||||
"role": "assistant",
|
||||
}
|
||||
if tool_calls:
|
||||
delta["tool_calls"] = tool_calls
|
||||
|
||||
choice = {"index": index, "delta": delta, "finish_reason": finish_reason}
|
||||
choices.append(choice)
|
||||
|
||||
template_chunk = {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||
"choices": choices,
|
||||
}
|
||||
if usage_metadata:
|
||||
template_chunk["usage"] = {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)}
|
||||
template_chunk["usage"] = {
|
||||
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
|
||||
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
||||
"total_tokens": usage_metadata.get("totalTokenCount", 0),
|
||||
}
|
||||
return template_chunk
|
||||
|
||||
|
||||
def _handle_openai_normal_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||
response: Dict[str, Any],
|
||||
model: str,
|
||||
finish_reason: str,
|
||||
usage_metadata: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
response, model, stream=False, gemini_format=False
|
||||
)
|
||||
choices = []
|
||||
candidates = response.get("candidates", [])
|
||||
|
||||
for i, candidate in enumerate(candidates):
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
{"candidates": [candidate]}, model, stream=False, gemini_format=False
|
||||
)
|
||||
choice = {
|
||||
"index": i,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
"reasoning_content": reasoning_content,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
choices.append(choice)
|
||||
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
"reasoning_content": reasoning_content,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
|
||||
"choices": choices,
|
||||
"usage": {
|
||||
"prompt_tokens": usage_metadata.get("promptTokenCount", 0),
|
||||
"completion_tokens": usage_metadata.get("candidatesTokenCount", 0),
|
||||
"total_tokens": usage_metadata.get("totalTokenCount", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +145,12 @@ class OpenAIResponseHandler(ResponseHandler):
|
||||
usage_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if stream:
|
||||
return _handle_openai_stream_response(response, model, finish_reason, usage_metadata)
|
||||
return _handle_openai_normal_response(response, model, finish_reason, usage_metadata)
|
||||
return _handle_openai_stream_response(
|
||||
response, model, finish_reason, usage_metadata
|
||||
)
|
||||
return _handle_openai_normal_response(
|
||||
response, model, finish_reason, usage_metadata
|
||||
)
|
||||
|
||||
def handle_image_chat_response(
|
||||
self, image_str: str, model: str, stream=False, finish_reason="stop"
|
||||
@@ -162,7 +204,7 @@ def _extract_result(
|
||||
gemini_format: bool = False,
|
||||
) -> tuple[str, Optional[str], List[Dict[str, Any]], Optional[bool]]:
|
||||
text, reasoning_content, tool_calls, thought = "", "", [], None
|
||||
|
||||
|
||||
if stream:
|
||||
if response.get("candidates"):
|
||||
candidate = response["candidates"][0]
|
||||
@@ -171,7 +213,7 @@ def _extract_result(
|
||||
if not parts:
|
||||
logger.warning("No parts found in stream response")
|
||||
return "", None, [], None
|
||||
|
||||
|
||||
if "text" in parts[0]:
|
||||
text = parts[0].get("text")
|
||||
if "thought" in parts[0]:
|
||||
@@ -197,13 +239,13 @@ def _extract_result(
|
||||
if response.get("candidates"):
|
||||
candidate = response["candidates"][0]
|
||||
text, reasoning_content = "", ""
|
||||
|
||||
|
||||
# 使用安全的访问方式
|
||||
content = candidate.get("content", {})
|
||||
|
||||
|
||||
if content and isinstance(content, dict):
|
||||
parts = content.get("parts", [])
|
||||
|
||||
|
||||
if parts:
|
||||
for part in parts:
|
||||
if "text" in part:
|
||||
@@ -221,17 +263,28 @@ def _extract_result(
|
||||
logger.error(f"Invalid content structure for model: {model}")
|
||||
|
||||
text = _add_search_link_text(model, candidate, text)
|
||||
|
||||
|
||||
# 安全地获取 parts 用于工具调用提取
|
||||
parts = candidate.get("content", {}).get("parts", [])
|
||||
tool_calls = _extract_tool_calls(parts, gemini_format)
|
||||
else:
|
||||
logger.warning(f"No candidates found in response for model: {model}")
|
||||
text = "暂无返回"
|
||||
|
||||
|
||||
return text, reasoning_content, tool_calls, thought
|
||||
|
||||
|
||||
def _has_inline_image_part(response: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
for c in response.get("candidates", []):
|
||||
for p in c.get("content", {}).get("parts", []):
|
||||
if isinstance(p, dict) and ("inlineData" in p):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _extract_image_data(part: dict) -> str:
|
||||
image_uploader = None
|
||||
if settings.UPLOAD_PROVIDER == "smms":
|
||||
@@ -240,7 +293,9 @@ def _extract_image_data(part: dict) -> str:
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "picgo":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER, api_key=settings.PICGO_API_KEY
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
api_key=settings.PICGO_API_KEY,
|
||||
api_url=settings.PICGO_API_URL
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
@@ -249,16 +304,30 @@ def _extract_image_data(part: dict) -> str:
|
||||
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
|
||||
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
access_key=settings.OSS_ACCESS_KEY,
|
||||
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
|
||||
bucket_name=settings.OSS_BUCKET_NAME,
|
||||
endpoint=settings.OSS_ENDPOINT,
|
||||
region=settings.OSS_REGION,
|
||||
use_internal=False
|
||||
)
|
||||
current_date = time.strftime("%Y/%m/%d")
|
||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||
base64_data = part["inlineData"]["data"]
|
||||
mime_type = part["inlineData"]["mimeType"]
|
||||
# 将base64_data转成bytes数组
|
||||
# Return empty string if no uploader is configured
|
||||
if not is_image_upload_configured(settings):
|
||||
return f"\n\n\n\n"
|
||||
bytes_data = base64.b64decode(base64_data)
|
||||
upload_response = image_uploader.upload(bytes_data, filename)
|
||||
if upload_response.success:
|
||||
text = f"\n\n\n\n"
|
||||
else:
|
||||
text = ""
|
||||
text = f"\n\n\n\n"
|
||||
return text
|
||||
|
||||
|
||||
@@ -271,7 +340,7 @@ def _extract_tool_calls(
|
||||
|
||||
letters = string.ascii_lowercase + string.digits
|
||||
tool_calls = list()
|
||||
|
||||
|
||||
for i in range(len(parts)):
|
||||
part = parts[i]
|
||||
if not part or not isinstance(part, dict):
|
||||
@@ -280,7 +349,7 @@ def _extract_tool_calls(
|
||||
item = part.get("functionCall", {})
|
||||
if not item or not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
|
||||
if gemini_format:
|
||||
tool_calls.append(part)
|
||||
else:
|
||||
@@ -303,6 +372,10 @@ def _extract_tool_calls(
|
||||
def _handle_gemini_stream_response(
|
||||
response: Dict[str, Any], model: str, stream: bool
|
||||
) -> Dict[str, Any]:
|
||||
# Early return raw Gemini response if no uploader configured and contains inline images
|
||||
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
|
||||
return response
|
||||
|
||||
text, reasoning_content, tool_calls, thought = _extract_result(
|
||||
response, model, stream=stream, gemini_format=True
|
||||
)
|
||||
@@ -320,6 +393,10 @@ def _handle_gemini_stream_response(
|
||||
def _handle_gemini_normal_response(
|
||||
response: Dict[str, Any], model: str, stream: bool
|
||||
) -> Dict[str, Any]:
|
||||
# Early return raw Gemini response if no uploader configured and contains inline images
|
||||
if not is_image_upload_configured(settings) and _has_inline_image_part(response):
|
||||
return response
|
||||
|
||||
text, reasoning_content, tool_calls, thought = _extract_result(
|
||||
response, model, stream=stream, gemini_format=True
|
||||
)
|
||||
@@ -328,7 +405,7 @@ def _handle_gemini_normal_response(
|
||||
parts = tool_calls
|
||||
else:
|
||||
if thought is not None:
|
||||
parts.append({"text": reasoning_content,"thought": thought})
|
||||
parts.append({"text": reasoning_content, "thought": thought})
|
||||
part = {"text": text}
|
||||
parts.append(part)
|
||||
content = {"parts": parts, "role": "model"}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
import re
|
||||
import sys
|
||||
from typing import Dict, Optional
|
||||
from app.utils.helpers import redact_key_for_logging as _redact_key_for_logging
|
||||
|
||||
# ANSI转义序列颜色代码
|
||||
COLORS = {
|
||||
@@ -15,7 +14,6 @@ COLORS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Windows系统启用ANSI支持
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
@@ -46,14 +44,16 @@ class AccessLogFormatter(logging.Formatter):
|
||||
|
||||
# API key patterns to match in URLs
|
||||
API_KEY_PATTERNS = [
|
||||
r'\bAIza[0-9A-Za-z_-]{35}', # Google API keys (like Gemini)
|
||||
r'\bsk-[0-9A-Za-z_-]{20,}', # OpenAI and general sk- prefixed keys
|
||||
r"\bAIza[0-9A-Za-z_-]{35}", # Google API keys (like Gemini)
|
||||
r"\bsk-[0-9A-Za-z_-]{20,}", # OpenAI and general sk- prefixed keys
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Compile regex patterns for better performance
|
||||
self.compiled_patterns = [re.compile(pattern) for pattern in self.API_KEY_PATTERNS]
|
||||
self.compiled_patterns = [
|
||||
re.compile(pattern) for pattern in self.API_KEY_PATTERNS
|
||||
]
|
||||
|
||||
def format(self, record):
|
||||
# Format the record normally first
|
||||
@@ -68,9 +68,10 @@ class AccessLogFormatter(logging.Formatter):
|
||||
"""
|
||||
try:
|
||||
for pattern in self.compiled_patterns:
|
||||
|
||||
def replace_key(match):
|
||||
key = match.group(0)
|
||||
return _redact_key_for_logging(key)
|
||||
return redact_key_for_logging(key)
|
||||
|
||||
message = pattern.sub(replace_key, message)
|
||||
|
||||
@@ -78,11 +79,31 @@ class AccessLogFormatter(logging.Formatter):
|
||||
except Exception as e:
|
||||
# Log the error but don't expose the original message in case it contains keys
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error redacting API keys in access log: {e}")
|
||||
return "[LOG_REDACTION_ERROR]"
|
||||
|
||||
|
||||
def redact_key_for_logging(key: str) -> str:
|
||||
"""
|
||||
Redacts API key for secure logging by showing only first and last 6 characters.
|
||||
|
||||
Args:
|
||||
key: API key to redact
|
||||
|
||||
Returns:
|
||||
str: Redacted key in format "first6...last6" or descriptive placeholder for edge cases
|
||||
"""
|
||||
if not key:
|
||||
return key
|
||||
|
||||
if len(key) <= 12:
|
||||
return f"{key[:3]}...{key[-3:]}"
|
||||
else:
|
||||
return f"{key[:6]}...{key[-6:]}"
|
||||
|
||||
|
||||
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
||||
FORMATTER = ColoredFormatter(
|
||||
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
||||
@@ -284,6 +305,10 @@ def get_vertex_express_logger():
|
||||
return Logger.setup_logger("vertex_express")
|
||||
|
||||
|
||||
def get_gemini_embedding_logger():
|
||||
return Logger.setup_logger("gemini_embedding")
|
||||
|
||||
|
||||
def setup_access_logging():
|
||||
"""
|
||||
Configure uvicorn access logging with API key redaction
|
||||
@@ -322,4 +347,3 @@ def setup_access_logging():
|
||||
access_logger.propagate = False
|
||||
|
||||
return access_logger
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class ErrorLogDetailResponse(BaseModel):
|
||||
request_msg: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
request_time: Optional[datetime] = None
|
||||
error_code: Optional[int] = None
|
||||
|
||||
|
||||
@router.get("/errors/{log_id}/details", response_model=ErrorLogDetailResponse)
|
||||
@@ -151,6 +152,43 @@ async def get_error_log_detail_api(request: Request, log_id: int = Path(..., ge=
|
||||
)
|
||||
|
||||
|
||||
@router.get("/errors/lookup", response_model=ErrorLogDetailResponse)
|
||||
async def lookup_error_log_by_info(
|
||||
request: Request,
|
||||
gemini_key: str = Query(..., description="完整的 Gemini key"),
|
||||
timestamp: datetime = Query(..., description="请求时间 (ISO8601)"),
|
||||
status_code: Optional[int] = Query(None, description="错误码 (可选)"),
|
||||
window_seconds: int = Query(
|
||||
100, ge=1, le=300, description="时间窗口(秒), 默认100秒"
|
||||
),
|
||||
):
|
||||
"""
|
||||
通过 key / 错误码 / 时间窗口 查找最匹配的一条错误日志详情。
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to lookup error log by info")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
try:
|
||||
detail = await error_log_service.process_find_error_log_by_info(
|
||||
gemini_key=gemini_key,
|
||||
timestamp=timestamp,
|
||||
status_code=status_code,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
if not detail:
|
||||
raise HTTPException(status_code=404, detail="No matching error log found")
|
||||
return ErrorLogDetailResponse(**detail)
|
||||
except HTTPException as http_exc:
|
||||
raise http_exc
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to lookup error log by info for key=***{gemini_key[-4:] if gemini_key else ''}: {str(e)}"
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.delete("/errors", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_logs_bulk_api(
|
||||
request: Request, payload: Dict[str, List[int]] = Body(...)
|
||||
@@ -192,10 +230,10 @@ async def delete_all_error_logs_api(request: Request):
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to delete all error logs")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
try:
|
||||
deleted_count = await error_log_service.process_delete_all_error_logs()
|
||||
logger.info(f"Successfully deleted all {deleted_count} error logs.")
|
||||
await error_log_service.process_delete_all_error_logs()
|
||||
logger.info("Successfully deleted all error logs.")
|
||||
# No body needed for 204 response
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
@@ -203,8 +241,8 @@ async def delete_all_error_logs_api(request: Request):
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error during deletion of all logs"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
"""
|
||||
@@ -214,7 +252,7 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
try:
|
||||
success = await error_log_service.process_delete_error_log_by_id(log_id)
|
||||
if not success:
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse, JSONResponse
|
||||
from copy import deepcopy
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.tts.native.tts_routes import get_tts_chat_service
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.core.constants import API_VERSION
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import (
|
||||
GeminiBatchEmbedRequest,
|
||||
GeminiContent,
|
||||
GeminiEmbedRequest,
|
||||
GeminiRequest,
|
||||
ResetSelectedKeysRequest,
|
||||
VerifySelectedKeysRequest,
|
||||
)
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.embedding.gemini_embedding_service import GeminiEmbeddingService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.service.tts.native.tts_routes import get_tts_chat_service
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
|
||||
@@ -38,11 +48,16 @@ async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
return GeminiChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
async def get_embedding_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""获取Gemini嵌入服务实例"""
|
||||
return GeminiEmbeddingService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
@router_v1beta.get("/models")
|
||||
async def list_models(
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
|
||||
operation_name = "list_gemini_models"
|
||||
@@ -52,20 +67,30 @@ async def list_models(
|
||||
try:
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||
raise HTTPException(
|
||||
status_code=503, detail="No valid API keys available to fetch models."
|
||||
)
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
models_data = await model_service.get_gemini_models(api_key)
|
||||
if not models_data or "models" not in models_data:
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to fetch base models list."
|
||||
)
|
||||
|
||||
models_json = deepcopy(models_data)
|
||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
|
||||
model_mapping = {
|
||||
x.get("name", "").split("/", maxsplit=1)[-1]: x
|
||||
for x in models_json.get("models", [])
|
||||
}
|
||||
|
||||
def add_derived_model(base_name, suffix, display_suffix):
|
||||
model = model_mapping.get(base_name)
|
||||
if not model:
|
||||
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
|
||||
logger.warning(
|
||||
f"Base model '{base_name}' not found for derived model '{suffix}'."
|
||||
)
|
||||
return
|
||||
item = deepcopy(model)
|
||||
item["name"] = f"models/{base_name}{suffix}"
|
||||
@@ -79,7 +104,7 @@ async def list_models(
|
||||
add_derived_model(name, "-search", " For Search")
|
||||
if settings.IMAGE_MODELS:
|
||||
for name in settings.IMAGE_MODELS:
|
||||
add_derived_model(name, "-image", " For Image")
|
||||
add_derived_model(name, "-image", " For Image")
|
||||
if settings.THINKING_MODELS:
|
||||
for name in settings.THINKING_MODELS:
|
||||
add_derived_model(name, "-non-thinking", " Non Thinking")
|
||||
@@ -91,7 +116,8 @@ async def list_models(
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Gemini models list: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error while fetching Gemini models list"
|
||||
status_code=500,
|
||||
detail="Internal server error while fetching Gemini models list",
|
||||
) from e
|
||||
|
||||
|
||||
@@ -101,15 +127,19 @@ async def list_models(
|
||||
async def generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
):
|
||||
"""处理 Gemini 非流式内容生成请求。"""
|
||||
operation_name = "gemini_generate_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
|
||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Content generation failed"
|
||||
):
|
||||
logger.info(
|
||||
f"Handling Gemini content generation request for model: {model_name}"
|
||||
)
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
|
||||
# 检测是否为原生Gemini TTS请求
|
||||
@@ -126,10 +156,13 @@ async def generate_content(
|
||||
logger.info(f"TTS responseModalities: {response_modalities}")
|
||||
logger.info(f"TTS speechConfig: {speech_config}")
|
||||
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
# 所有原生TTS请求都使用TTS增强服务
|
||||
if is_native_tts:
|
||||
@@ -137,19 +170,17 @@ async def generate_content(
|
||||
logger.info("Using native TTS enhanced service")
|
||||
tts_service = await get_tts_chat_service(key_manager)
|
||||
response = await tts_service.generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.warning(f"Native TTS processing failed, falling back to standard service: {e}")
|
||||
logger.warning(
|
||||
f"Native TTS processing failed, falling back to standard service: {e}"
|
||||
)
|
||||
|
||||
# 使用标准服务处理所有其他请求(非TTS)
|
||||
response = await chat_service.generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -160,27 +191,53 @@ async def generate_content(
|
||||
async def stream_generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
):
|
||||
"""处理 Gemini 流式内容生成请求。"""
|
||||
operation_name = "gemini_stream_generate_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Streaming request initiation failed"
|
||||
):
|
||||
logger.info(
|
||||
f"Handling Gemini streaming content generation for model: {model_name}"
|
||||
)
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response_stream = chat_service.stream_generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
raw_stream = chat_service.stream_generate_content(
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return StreamingResponse(response_stream, media_type="text/event-stream")
|
||||
try:
|
||||
# 尝试获取第一条数据,判断是正常 SSE(data: 前缀)还是错误 JSON
|
||||
first_chunk = await raw_stream.__anext__()
|
||||
except StopAsyncIteration:
|
||||
# 如果流直接结束,退回标准 SSE 输出
|
||||
return StreamingResponse(raw_stream, media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
# 初始化流异常,直接返回 500 错误
|
||||
return JSONResponse(
|
||||
content={"error": {"code": e.args[0], "message": e.args[1]}},
|
||||
status_code=e.args[0],
|
||||
)
|
||||
|
||||
# 如果以 "data:" 开头,代表正常 SSE,将首块和后续块一起发送
|
||||
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
|
||||
|
||||
async def combined():
|
||||
yield first_chunk
|
||||
async for chunk in raw_stream:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(combined(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:countTokens")
|
||||
@@ -189,41 +246,112 @@ async def stream_generate_content(
|
||||
async def count_tokens(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
):
|
||||
"""处理 Gemini token 计数请求。"""
|
||||
operation_name = "gemini_count_tokens"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Token counting failed"):
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Token counting failed"
|
||||
):
|
||||
logger.info(f"Handling Gemini token count request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response = await chat_service.count_tokens(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:embedContent")
|
||||
@router_v1beta.post("/models/{model_name}:embedContent")
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def embed_content(
|
||||
model_name: str,
|
||||
request: GeminiEmbedRequest,
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service),
|
||||
):
|
||||
"""处理 Gemini 单一嵌入请求"""
|
||||
operation_name = "gemini_embed_content"
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Embedding content generation failed"
|
||||
):
|
||||
logger.info(f"Handling Gemini embedding request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response = await embedding_service.embed_content(
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:batchEmbedContents")
|
||||
@router_v1beta.post("/models/{model_name}:batchEmbedContents")
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def batch_embed_contents(
|
||||
model_name: str,
|
||||
request: GeminiBatchEmbedRequest,
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service),
|
||||
):
|
||||
"""处理 Gemini 批量嵌入请求"""
|
||||
operation_name = "gemini_batch_embed_contents"
|
||||
async with handle_route_errors(
|
||||
logger,
|
||||
operation_name,
|
||||
failure_message="Batch embedding content generation failed",
|
||||
):
|
||||
logger.info(f"Handling Gemini batch embedding request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response = await embedding_service.batch_embed_contents(
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/reset-all-fail-counts")
|
||||
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
async def reset_all_key_fail_counts(
|
||||
key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""批量重置Gemini API密钥的失败计数,可选择性地仅重置有效或无效密钥"""
|
||||
logger.info("-" * 50 + "reset_all_gemini_key_fail_counts" + "-" * 50)
|
||||
logger.info(f"Received reset request with key_type: {key_type}")
|
||||
|
||||
|
||||
try:
|
||||
# 获取分类后的密钥
|
||||
keys_by_status = await key_manager.get_keys_by_status()
|
||||
valid_keys = keys_by_status.get("valid_keys", {})
|
||||
invalid_keys = keys_by_status.get("invalid_keys", {})
|
||||
|
||||
|
||||
# 根据类型选择要重置的密钥
|
||||
keys_to_reset = []
|
||||
if key_type == "valid":
|
||||
@@ -235,35 +363,45 @@ async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManage
|
||||
else:
|
||||
# 重置所有密钥
|
||||
await key_manager.reset_failure_counts()
|
||||
return JSONResponse({"success": True, "message": "所有密钥的失败计数已重置"})
|
||||
|
||||
return JSONResponse(
|
||||
{"success": True, "message": "所有密钥的失败计数已重置"}
|
||||
)
|
||||
|
||||
# 批量重置指定类型的密钥
|
||||
for key in keys_to_reset:
|
||||
await key_manager.reset_key_failure_count(key)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"{key_type}密钥的失败计数已重置",
|
||||
"reset_count": len(keys_to_reset)
|
||||
})
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"{key_type}密钥的失败计数已重置",
|
||||
"reset_count": len(keys_to_reset),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset key failure counts: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500)
|
||||
|
||||
|
||||
return JSONResponse(
|
||||
{"success": False, "message": f"批量重置失败: {str(e)}"}, status_code=500
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-selected-fail-counts")
|
||||
async def reset_selected_key_fail_counts(
|
||||
request: ResetSelectedKeysRequest,
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""批量重置选定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_selected_gemini_key_fail_counts" + "-" * 50)
|
||||
keys_to_reset = request.keys
|
||||
key_type = request.key_type
|
||||
logger.info(f"Received reset request for {len(keys_to_reset)} selected {key_type} keys.")
|
||||
logger.info(
|
||||
f"Received reset request for {len(keys_to_reset)} selected {key_type} keys."
|
||||
)
|
||||
|
||||
if not keys_to_reset:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要重置的密钥"}, status_code=400)
|
||||
return JSONResponse(
|
||||
{"success": False, "message": "没有提供需要重置的密钥"}, status_code=400
|
||||
)
|
||||
|
||||
reset_count = 0
|
||||
errors = []
|
||||
@@ -275,53 +413,79 @@ async def reset_selected_key_fail_counts(
|
||||
if result:
|
||||
reset_count += 1
|
||||
else:
|
||||
logger.warning(f"Key not found during selective reset: {redact_key_for_logging(key)}")
|
||||
logger.warning(
|
||||
f"Key not found during selective reset: {redact_key_for_logging(key)}"
|
||||
)
|
||||
except Exception as key_error:
|
||||
logger.error(f"Error resetting key {redact_key_for_logging(key)}: {str(key_error)}")
|
||||
logger.error(
|
||||
f"Error resetting key {redact_key_for_logging(key)}: {str(key_error)}"
|
||||
)
|
||||
errors.append(f"Key {key}: {str(key_error)}")
|
||||
|
||||
if errors:
|
||||
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
||||
final_success = reset_count > 0
|
||||
status_code = 207 if final_success and errors else 500
|
||||
return JSONResponse({
|
||||
"success": final_success,
|
||||
"message": error_message,
|
||||
"reset_count": reset_count
|
||||
}, status_code=status_code)
|
||||
error_message = f"批量重置完成,但出现错误: {'; '.join(errors)}"
|
||||
final_success = reset_count > 0
|
||||
status_code = 207 if final_success and errors else 500
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": final_success,
|
||||
"message": error_message,
|
||||
"reset_count": reset_count,
|
||||
},
|
||||
status_code=status_code,
|
||||
)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
||||
"reset_count": reset_count
|
||||
})
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": f"成功重置 {reset_count} 个选定 {key_type} 密钥的失败计数",
|
||||
"reset_count": reset_count,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process reset selected key failure counts request: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"批量重置处理失败: {str(e)}"}, status_code=500)
|
||||
logger.error(
|
||||
f"Failed to process reset selected key failure counts request: {str(e)}"
|
||||
)
|
||||
return JSONResponse(
|
||||
{"success": False, "message": f"批量重置处理失败: {str(e)}"},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-fail-count/{api_key}")
|
||||
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
async def reset_key_fail_count(
|
||||
api_key: str, key_manager: KeyManager = Depends(get_key_manager)
|
||||
):
|
||||
"""重置指定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50)
|
||||
logger.info(f"Resetting failure count for API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
logger.info(
|
||||
f"Resetting failure count for API key: {redact_key_for_logging(api_key)}"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await key_manager.reset_key_failure_count(api_key)
|
||||
if result:
|
||||
return JSONResponse({"success": True, "message": "失败计数已重置"})
|
||||
return JSONResponse({"success": False, "message": "未找到指定密钥"}, status_code=404)
|
||||
return JSONResponse(
|
||||
{"success": False, "message": "未找到指定密钥"}, status_code=404
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reset key failure count: {str(e)}")
|
||||
return JSONResponse({"success": False, "message": f"重置失败: {str(e)}"}, status_code=500)
|
||||
return JSONResponse(
|
||||
{"success": False, "message": f"重置失败: {str(e)}"}, status_code=500
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-key/{api_key}")
|
||||
async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get_chat_service), key_manager: KeyManager = Depends(get_key_manager)):
|
||||
async def verify_key(
|
||||
api_key: str,
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""验证Gemini API密钥的有效性"""
|
||||
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
|
||||
logger.info("Verifying API key validity")
|
||||
|
||||
|
||||
try:
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[
|
||||
@@ -330,27 +494,27 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
||||
parts=[{"text": "hi"}],
|
||||
)
|
||||
],
|
||||
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10}
|
||||
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10},
|
||||
)
|
||||
|
||||
|
||||
response = await chat_service.generate_content(
|
||||
settings.TEST_MODEL,
|
||||
gemini_request,
|
||||
api_key
|
||||
settings.TEST_MODEL, gemini_request, api_key
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
# 如果密钥验证成功,则重置其失败计数
|
||||
await key_manager.reset_key_failure_count(api_key)
|
||||
return JSONResponse({"status": "valid"})
|
||||
except Exception as e:
|
||||
logger.error(f"Key verification failed: {str(e)}")
|
||||
|
||||
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count")
|
||||
|
||||
logger.warning(
|
||||
f"Verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count"
|
||||
)
|
||||
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
|
||||
|
||||
@@ -358,15 +522,19 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
||||
async def verify_selected_keys(
|
||||
request: VerifySelectedKeysRequest,
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""批量验证选定Gemini API密钥的有效性"""
|
||||
logger.info("-" * 50 + "verify_selected_gemini_keys" + "-" * 50)
|
||||
keys_to_verify = request.keys
|
||||
logger.info(f"Received verification request for {len(keys_to_verify)} selected keys.")
|
||||
logger.info(
|
||||
f"Received verification request for {len(keys_to_verify)} selected keys."
|
||||
)
|
||||
|
||||
if not keys_to_verify:
|
||||
return JSONResponse({"success": False, "message": "没有提供需要验证的密钥"}, status_code=400)
|
||||
return JSONResponse(
|
||||
{"success": False, "message": "没有提供需要验证的密钥"}, status_code=400
|
||||
)
|
||||
|
||||
successful_keys = []
|
||||
failed_keys = {}
|
||||
@@ -377,12 +545,14 @@ async def verify_selected_keys(
|
||||
try:
|
||||
gemini_request = GeminiRequest(
|
||||
contents=[GeminiContent(role="user", parts=[{"text": "hi"}])],
|
||||
generation_config={"temperature": 0.7, "topP": 1.0, "maxOutputTokens": 10}
|
||||
generation_config={
|
||||
"temperature": 0.7,
|
||||
"topP": 1.0,
|
||||
"maxOutputTokens": 10,
|
||||
},
|
||||
)
|
||||
await chat_service.generate_content(
|
||||
settings.TEST_MODEL,
|
||||
gemini_request,
|
||||
api_key
|
||||
settings.TEST_MODEL, gemini_request, api_key
|
||||
)
|
||||
successful_keys.append(api_key)
|
||||
# 如果密钥验证成功,则重置其失败计数
|
||||
@@ -390,14 +560,20 @@ async def verify_selected_keys(
|
||||
return api_key, "valid", None
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Key verification failed for {redact_key_for_logging(api_key)}: {error_message}")
|
||||
logger.warning(
|
||||
f"Key verification failed for {redact_key_for_logging(api_key)}: {error_message}"
|
||||
)
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count")
|
||||
logger.warning(
|
||||
f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count"
|
||||
)
|
||||
else:
|
||||
key_manager.key_failure_counts[api_key] = 1
|
||||
logger.warning(f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, initializing failure count to 1")
|
||||
key_manager.key_failure_counts[api_key] = 1
|
||||
logger.warning(
|
||||
f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, initializing failure count to 1"
|
||||
)
|
||||
failed_keys[api_key] = error_message
|
||||
return api_key, "invalid", error_message
|
||||
|
||||
@@ -406,34 +582,42 @@ async def verify_selected_keys(
|
||||
|
||||
for result in results:
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"An unexpected error occurred during bulk verification task: {result}")
|
||||
logger.error(
|
||||
f"An unexpected error occurred during bulk verification task: {result}"
|
||||
)
|
||||
elif result:
|
||||
if not isinstance(result, Exception) and result:
|
||||
key, status, error = result
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(f"Task execution error during bulk verification: {result}")
|
||||
if not isinstance(result, Exception) and result:
|
||||
key, status, error = result
|
||||
elif isinstance(result, Exception):
|
||||
logger.error(f"Task execution error during bulk verification: {result}")
|
||||
|
||||
valid_count = len(successful_keys)
|
||||
invalid_count = len(failed_keys)
|
||||
logger.info(f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}")
|
||||
logger.info(
|
||||
f"Bulk verification finished. Valid: {valid_count}, Invalid: {invalid_count}"
|
||||
)
|
||||
|
||||
if failed_keys:
|
||||
message = f"批量验证完成。成功: {valid_count}, 失败: {invalid_count}。"
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": failed_keys,
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count
|
||||
})
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": failed_keys,
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": invalid_count,
|
||||
}
|
||||
)
|
||||
else:
|
||||
message = f"批量验证成功完成。所有 {valid_count} 个密钥均有效。"
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": {},
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": 0
|
||||
})
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"message": message,
|
||||
"successful_keys": successful_keys,
|
||||
"failed_keys": {},
|
||||
"valid_count": valid_count,
|
||||
"invalid_count": 0,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.core.security import SecurityService
|
||||
@@ -8,19 +8,21 @@ from app.domain.openai_models import (
|
||||
EmbeddingRequest,
|
||||
ImageGenerationRequest,
|
||||
)
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.log.logger import get_openai_compatible_logger
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.openai_compatiable.openai_compatiable_service import OpenAICompatiableService
|
||||
from app.service.openai_compatiable.openai_compatiable_service import (
|
||||
OpenAICompatiableService,
|
||||
)
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_openai_compatible_logger()
|
||||
|
||||
security_service = SecurityService()
|
||||
|
||||
|
||||
async def get_key_manager():
|
||||
return await get_key_manager_instance()
|
||||
|
||||
@@ -38,7 +40,7 @@ async def get_openai_service(key_manager: KeyManager = Depends(get_key_manager))
|
||||
|
||||
@router.get("/openai/v1/models")
|
||||
async def list_models(
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
@@ -47,6 +49,7 @@ async def list_models(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await openai_service.get_models(api_key)
|
||||
|
||||
@@ -55,7 +58,7 @@ async def list_models(
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def chat_completion(
|
||||
request: ChatRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
api_key: str = Depends(get_next_working_key_wrapper),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
@@ -70,28 +73,56 @@ async def chat_completion(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(current_api_key)}")
|
||||
|
||||
raw_response = None
|
||||
if is_image_chat:
|
||||
response = await openai_service.create_image_chat_completion(request, current_api_key)
|
||||
return response
|
||||
raw_response = await openai_service.create_image_chat_completion(
|
||||
request, current_api_key
|
||||
)
|
||||
else:
|
||||
response = await openai_service.create_chat_completion(request, current_api_key)
|
||||
if request.stream:
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
return response
|
||||
raw_response = await openai_service.create_chat_completion(
|
||||
request, current_api_key
|
||||
)
|
||||
if request.stream:
|
||||
try:
|
||||
# 尝试获取第一条数据,判断是正常 SSE(data: 前缀)还是错误 JSON
|
||||
first_chunk = await raw_response.__anext__()
|
||||
except StopAsyncIteration:
|
||||
# 如果流直接结束,退回标准 SSE 输出
|
||||
return StreamingResponse(raw_response, media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
# 初始化流异常,直接返回 500 错误
|
||||
return JSONResponse(
|
||||
content={"error": {"code": e.args[0], "message": e.args[1]}},
|
||||
status_code=e.args[0],
|
||||
)
|
||||
|
||||
# 如果以 "data:" 开头,代表正常 SSE,将首块和后续块一起发送
|
||||
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
|
||||
|
||||
async def combined():
|
||||
yield first_chunk
|
||||
async for chunk in raw_response:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(combined(), media_type="text/event-stream")
|
||||
else:
|
||||
return raw_response
|
||||
|
||||
|
||||
@router.post("/openai/v1/images/generations")
|
||||
async def generate_image(
|
||||
request: ImageGenerationRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
"""处理图像生成请求。"""
|
||||
operation_name = "generate_image"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
request.model = settings.CREATE_IMAGE_MODEL
|
||||
return await openai_service.generate_images(request)
|
||||
|
||||
@@ -99,7 +130,7 @@ async def generate_image(
|
||||
@router.post("/openai/v1/embeddings")
|
||||
async def embedding(
|
||||
request: EmbeddingRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
openai_service: OpenAICompatiableService = Depends(get_openai_service),
|
||||
):
|
||||
@@ -108,6 +139,7 @@ async def embedding(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await openai_service.create_embeddings(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.core.security import SecurityService
|
||||
@@ -9,15 +9,15 @@ from app.domain.openai_models import (
|
||||
ImageGenerationRequest,
|
||||
TTSRequest,
|
||||
)
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.log.logger import get_openai_logger
|
||||
from app.service.chat.openai_chat_service import OpenAIChatService
|
||||
from app.service.embedding.embedding_service import EmbeddingService
|
||||
from app.service.image.image_create_service import ImageCreateService
|
||||
from app.service.tts.tts_service import TTSService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.service.tts.tts_service import TTSService
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter()
|
||||
@@ -53,7 +53,7 @@ async def get_tts_service():
|
||||
@router.get("/v1/models")
|
||||
@router.get("/hf/v1/models")
|
||||
async def list_models(
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""获取可用的 OpenAI 模型列表 (兼容 Gemini 和 OpenAI)。"""
|
||||
@@ -61,6 +61,7 @@ async def list_models(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await model_service.get_gemini_openai_models(api_key)
|
||||
|
||||
@@ -70,7 +71,7 @@ async def list_models(
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def chat_completion(
|
||||
request: ChatRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
api_key: str = Depends(get_next_working_key_wrapper),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: OpenAIChatService = Depends(get_openai_chat_service),
|
||||
@@ -92,23 +93,48 @@ async def chat_completion(
|
||||
status_code=400, detail=f"Model {request.model} is not supported"
|
||||
)
|
||||
|
||||
raw_response = None
|
||||
if is_image_chat:
|
||||
response = await chat_service.create_image_chat_completion(request, current_api_key)
|
||||
if request.stream:
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
return response
|
||||
raw_response = await chat_service.create_image_chat_completion(
|
||||
request, current_api_key
|
||||
)
|
||||
else:
|
||||
response = await chat_service.create_chat_completion(request, current_api_key)
|
||||
if request.stream:
|
||||
return StreamingResponse(response, media_type="text/event-stream")
|
||||
return response
|
||||
raw_response = await chat_service.create_chat_completion(
|
||||
request, current_api_key
|
||||
)
|
||||
|
||||
if request.stream:
|
||||
try:
|
||||
# 尝试获取第一条数据,判断是正常 SSE(data: 前缀)还是错误 JSON
|
||||
first_chunk = await raw_response.__anext__()
|
||||
except StopAsyncIteration:
|
||||
# 如果流直接结束,退回标准 SSE 输出
|
||||
return StreamingResponse(raw_response, media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
# 初始化流异常,直接返回 500 错误
|
||||
return JSONResponse(
|
||||
content={"error": {"code": e.args[0], "message": e.args[1]}},
|
||||
status_code=e.args[0],
|
||||
)
|
||||
|
||||
# 如果以 "data:" 开头,代表正常 SSE,将首块和后续块一起发送
|
||||
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
|
||||
|
||||
async def combined():
|
||||
yield first_chunk
|
||||
async for chunk in raw_response:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(combined(), media_type="text/event-stream")
|
||||
else:
|
||||
return raw_response
|
||||
|
||||
|
||||
@router.post("/v1/images/generations")
|
||||
@router.post("/hf/v1/images/generations")
|
||||
async def generate_image(
|
||||
request: ImageGenerationRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
):
|
||||
"""处理 OpenAI 图像生成请求。"""
|
||||
operation_name = "generate_image"
|
||||
@@ -122,7 +148,7 @@ async def generate_image(
|
||||
@router.post("/hf/v1/embeddings")
|
||||
async def embedding(
|
||||
request: EmbeddingRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""处理 OpenAI 文本嵌入请求。"""
|
||||
@@ -130,6 +156,7 @@ async def embedding(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
response = await embedding_service.create_embedding(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
@@ -162,7 +189,7 @@ async def get_keys_list(
|
||||
@router.post("/hf/v1/audio/speech")
|
||||
async def text_to_speech(
|
||||
request: TTSRequest,
|
||||
_=Depends(security_service.verify_authorization),
|
||||
allowed_token=Depends(security_service.verify_authorization),
|
||||
api_key: str = Depends(get_next_working_key_wrapper),
|
||||
tts_service: TTSService = Depends(get_tts_service),
|
||||
):
|
||||
@@ -171,6 +198,7 @@ async def text_to_speech(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling TTS request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
audio_data = await tts_service.create_tts(request, api_key)
|
||||
return Response(content=audio_data, media_type="audio/wav")
|
||||
|
||||
@@ -6,16 +6,31 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.core.security import verify_auth_token
|
||||
from app.config.config import settings
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_routes_logger
|
||||
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes, key_routes
|
||||
from app.router import (
|
||||
config_routes,
|
||||
error_log_routes,
|
||||
files_routes,
|
||||
gemini_routes,
|
||||
key_routes,
|
||||
openai_compatiable_routes,
|
||||
openai_routes,
|
||||
scheduler_routes,
|
||||
stats_routes,
|
||||
version_routes,
|
||||
vertex_express_routes,
|
||||
)
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats.stats_service import StatsService
|
||||
from app.utils.static_version import get_static_url
|
||||
|
||||
logger = get_routes_logger()
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
# 设置模板全局变量
|
||||
templates.env.globals["static_url"] = get_static_url
|
||||
|
||||
|
||||
def setup_routers(app: FastAPI) -> None:
|
||||
@@ -69,9 +84,12 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
if verify_auth_token(auth_token):
|
||||
logger.info("Successful authentication")
|
||||
response = RedirectResponse(url="/config", status_code=302)
|
||||
response = RedirectResponse(url="/keys", status_code=302)
|
||||
response.set_cookie(
|
||||
key="auth_token", value=auth_token, httponly=True, max_age=settings.ADMIN_SESSION_EXPIRE
|
||||
key="auth_token",
|
||||
value=auth_token,
|
||||
httponly=True,
|
||||
max_age=settings.ADMIN_SESSION_EXPIRE,
|
||||
)
|
||||
return response
|
||||
logger.warning("Failed authentication attempt with invalid token")
|
||||
@@ -91,7 +109,9 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
key_manager = await get_key_manager_instance()
|
||||
keys_status = await key_manager.get_keys_by_status()
|
||||
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
|
||||
total_keys = len(keys_status["valid_keys"]) + len(
|
||||
keys_status["invalid_keys"]
|
||||
)
|
||||
valid_key_count = len(keys_status["valid_keys"])
|
||||
invalid_key_count = len(keys_status["invalid_keys"])
|
||||
|
||||
@@ -133,7 +153,7 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
"""配置编辑页面"""
|
||||
@@ -142,13 +162,15 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
logger.info("Config page accessed successfully")
|
||||
return templates.TemplateResponse("config_editor.html", {"request": request})
|
||||
return templates.TemplateResponse(
|
||||
"config_editor.html", {"request": request}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing config page: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
"""错误日志页面"""
|
||||
@@ -157,7 +179,7 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to logs page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
logger.info("Logs page accessed successfully")
|
||||
return templates.TemplateResponse("error_logs.html", {"request": request})
|
||||
except Exception as e:
|
||||
@@ -187,6 +209,7 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
Args:
|
||||
app: FastAPI应用程序实例
|
||||
"""
|
||||
|
||||
@app.get("/api/stats/details")
|
||||
async def api_stats_details(request: Request, period: str):
|
||||
"""获取指定时间段内的 API 调用详情"""
|
||||
@@ -201,8 +224,67 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
details = await stats_service.get_api_call_details(period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||
logger.warning(
|
||||
f"Invalid period requested for API stats details: {period} - {str(e)}"
|
||||
)
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
|
||||
logger.error(
|
||||
f"Error fetching API stats details for period {period}: {str(e)}"
|
||||
)
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
@app.get("/api/stats/attention-keys")
|
||||
async def api_stats_attention_keys(
|
||||
request: Request, limit: int = 20, status_code: int = 429
|
||||
):
|
||||
"""返回最近24小时指定错误码次数最多的Key(仅包含内存Key列表中的)。默认错误码429。"""
|
||||
try:
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to attention-keys")
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
# 支持所有标准HTTP状态码范围
|
||||
# if not isinstance(status_code, int) or status_code < 100 or status_code > 599:
|
||||
# return {"error": f"Unsupported status_code: {status_code}"}, 400
|
||||
|
||||
key_manager = await get_key_manager_instance()
|
||||
keys_status = await key_manager.get_keys_by_status()
|
||||
in_memory_keys = set(keys_status.get("valid_keys", [])) | set(
|
||||
keys_status.get("invalid_keys", [])
|
||||
)
|
||||
stats_service = StatsService()
|
||||
data = await stats_service.get_attention_keys_last_24h(
|
||||
in_memory_keys, limit, status_code
|
||||
)
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching attention keys: {e}")
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
@app.get("/api/stats/key-details")
|
||||
async def api_stats_key_details(request: Request, key: str, period: str):
|
||||
"""获取指定密钥在指定时间段内的调用详情"""
|
||||
try:
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to API key stats details")
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
logger.info(
|
||||
f"Fetching key call details for key=...{key[-4:] if key else ''}, period: {period}"
|
||||
)
|
||||
stats_service = StatsService()
|
||||
details = await stats_service.get_key_call_details(key, period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
f"Invalid period requested for key stats details: {period} - {str(e)}"
|
||||
)
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching key stats details for period {period}: {str(e)}"
|
||||
)
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from copy import deepcopy
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_vertex_express_logger
|
||||
from app.core.constants import API_VERSION
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import GeminiRequest
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.log.logger import get_vertex_express_logger
|
||||
from app.service.chat.vertex_express_chat_service import GeminiChatService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.core.constants import API_VERSION
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter(prefix=f"/vertex-express/{API_VERSION}")
|
||||
@@ -37,8 +39,8 @@ async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
|
||||
@router.get("/models")
|
||||
async def list_models(
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager)
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
):
|
||||
"""获取可用的 Gemini 模型列表,并根据配置添加衍生模型(搜索、图像、非思考)。"""
|
||||
operation_name = "list_gemini_models"
|
||||
@@ -48,20 +50,30 @@ async def list_models(
|
||||
try:
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||
raise HTTPException(
|
||||
status_code=503, detail="No valid API keys available to fetch models."
|
||||
)
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
models_data = await model_service.get_gemini_models(api_key)
|
||||
if not models_data or "models" not in models_data:
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch base models list.")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to fetch base models list."
|
||||
)
|
||||
|
||||
models_json = deepcopy(models_data)
|
||||
model_mapping = {x.get("name", "").split("/", maxsplit=1)[-1]: x for x in models_json.get("models", [])}
|
||||
model_mapping = {
|
||||
x.get("name", "").split("/", maxsplit=1)[-1]: x
|
||||
for x in models_json.get("models", [])
|
||||
}
|
||||
|
||||
def add_derived_model(base_name, suffix, display_suffix):
|
||||
model = model_mapping.get(base_name)
|
||||
if not model:
|
||||
logger.warning(f"Base model '{base_name}' not found for derived model '{suffix}'.")
|
||||
logger.warning(
|
||||
f"Base model '{base_name}' not found for derived model '{suffix}'."
|
||||
)
|
||||
return
|
||||
item = deepcopy(model)
|
||||
item["name"] = f"models/{base_name}{suffix}"
|
||||
@@ -75,7 +87,7 @@ async def list_models(
|
||||
add_derived_model(name, "-search", " For Search")
|
||||
if settings.IMAGE_MODELS:
|
||||
for name in settings.IMAGE_MODELS:
|
||||
add_derived_model(name, "-image", " For Image")
|
||||
add_derived_model(name, "-image", " For Image")
|
||||
if settings.THINKING_MODELS:
|
||||
for name in settings.THINKING_MODELS:
|
||||
add_derived_model(name, "-non-thinking", " Non Thinking")
|
||||
@@ -87,7 +99,8 @@ async def list_models(
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting Gemini models list: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error while fetching Gemini models list"
|
||||
status_code=500,
|
||||
detail="Internal server error while fetching Gemini models list",
|
||||
) from e
|
||||
|
||||
|
||||
@@ -96,25 +109,30 @@ async def list_models(
|
||||
async def generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
):
|
||||
"""处理 Gemini 非流式内容生成请求。"""
|
||||
operation_name = "gemini_generate_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
|
||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Content generation failed"
|
||||
):
|
||||
logger.info(
|
||||
f"Handling Gemini content generation request for model: {model_name}"
|
||||
)
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response = await chat_service.generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -124,24 +142,50 @@ async def generate_content(
|
||||
async def stream_generate_content(
|
||||
model_name: str,
|
||||
request: GeminiRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
allowed_token=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
chat_service: GeminiChatService = Depends(get_chat_service)
|
||||
chat_service: GeminiChatService = Depends(get_chat_service),
|
||||
):
|
||||
"""处理 Gemini 流式内容生成请求。"""
|
||||
operation_name = "gemini_stream_generate_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
async with handle_route_errors(
|
||||
logger, operation_name, failure_message="Streaming request initiation failed"
|
||||
):
|
||||
logger.info(
|
||||
f"Handling Gemini streaming content generation for model: {model_name}"
|
||||
)
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using allowed token: {allowed_token}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Model {model_name} is not supported"
|
||||
)
|
||||
|
||||
response_stream = chat_service.stream_generate_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
raw_stream = chat_service.stream_generate_content(
|
||||
model=model_name, request=request, api_key=api_key
|
||||
)
|
||||
return StreamingResponse(response_stream, media_type="text/event-stream")
|
||||
try:
|
||||
# 尝试获取第一条数据,判断是正常 SSE(data: 前缀)还是错误 JSON
|
||||
first_chunk = await raw_stream.__anext__()
|
||||
except StopAsyncIteration:
|
||||
# 如果流直接结束,退回标准 SSE 输出
|
||||
return StreamingResponse(raw_stream, media_type="text/event-stream")
|
||||
except Exception as e:
|
||||
# 初始化流异常,直接返回 500 错误
|
||||
return JSONResponse(
|
||||
content={"error": {"code": e.args[0], "message": e.args[1]}},
|
||||
status_code=e.args[0],
|
||||
)
|
||||
|
||||
# 如果以 "data:" 开头,代表正常 SSE,将首块和后续块一起发送
|
||||
if isinstance(first_chunk, str) and first_chunk.startswith("data:"):
|
||||
|
||||
async def combined():
|
||||
yield first_chunk
|
||||
async for chunk in raw_stream:
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(combined(), media_type="text/event-stream")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
|
||||
from app.config.config import settings
|
||||
@@ -6,9 +5,9 @@ from app.domain.gemini_models import GeminiContent, GeminiRequest
|
||||
from app.log.logger import Logger
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.error_log.error_log_service import delete_old_error_logs
|
||||
from app.service.files.files_service import get_files_service
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.request_log.request_log_service import delete_old_request_logs_task
|
||||
from app.service.files.files_service import get_files_service
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = Logger.setup_logger("scheduler")
|
||||
@@ -106,15 +105,16 @@ async def cleanup_expired_files():
|
||||
try:
|
||||
files_service = await get_files_service()
|
||||
deleted_count = await files_service.cleanup_expired_files()
|
||||
|
||||
|
||||
if deleted_count > 0:
|
||||
logger.info(f"Successfully cleaned up {deleted_count} expired files.")
|
||||
else:
|
||||
logger.info("No expired files to clean up.")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"An error occurred during the scheduled file cleanup: {str(e)}", exc_info=True
|
||||
f"An error occurred during the scheduled file cleanup: {str(e)}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,44 +122,45 @@ def setup_scheduler():
|
||||
"""设置并启动 APScheduler"""
|
||||
scheduler = AsyncIOScheduler(timezone=str(settings.TIMEZONE)) # 从配置读取时区
|
||||
# 添加检查失败密钥的定时任务
|
||||
scheduler.add_job(
|
||||
check_failed_keys,
|
||||
"interval",
|
||||
hours=settings.CHECK_INTERVAL_HOURS,
|
||||
id="check_failed_keys_job",
|
||||
name="Check Failed API Keys",
|
||||
)
|
||||
logger.info(
|
||||
f"Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s)."
|
||||
)
|
||||
if settings.CHECK_INTERVAL_HOURS != 0:
|
||||
scheduler.add_job(
|
||||
check_failed_keys,
|
||||
"interval",
|
||||
hours=settings.CHECK_INTERVAL_HOURS,
|
||||
id="check_failed_keys_job",
|
||||
name="Check Failed API Keys",
|
||||
)
|
||||
logger.info(
|
||||
f"Key check job scheduled to run every {settings.CHECK_INTERVAL_HOURS} hour(s)."
|
||||
)
|
||||
|
||||
# 新增:添加自动删除错误日志的定时任务,每天凌晨3点执行
|
||||
# 新增:添加自动删除错误日志的定时任务,每天凌晨0点执行
|
||||
scheduler.add_job(
|
||||
delete_old_error_logs,
|
||||
"cron",
|
||||
hour=3,
|
||||
hour=0,
|
||||
minute=0,
|
||||
id="delete_old_error_logs_job",
|
||||
name="Delete Old Error Logs",
|
||||
)
|
||||
logger.info("Auto-delete error logs job scheduled to run daily at 3:00 AM.")
|
||||
|
||||
# 新增:添加自动删除请求日志的定时任务,每天凌晨3点05分执行
|
||||
# 新增:添加自动删除请求日志的定时任务,每天凌晨0点执行
|
||||
scheduler.add_job(
|
||||
delete_old_request_logs_task,
|
||||
"cron",
|
||||
hour=3,
|
||||
minute=5,
|
||||
hour=0,
|
||||
minute=0,
|
||||
id="delete_old_request_logs_job",
|
||||
name="Delete Old Request Logs",
|
||||
)
|
||||
logger.info(
|
||||
f"Auto-delete request logs job scheduled to run daily at 3:05 AM, if enabled and AUTO_DELETE_REQUEST_LOGS_DAYS is set to {settings.AUTO_DELETE_REQUEST_LOGS_DAYS} days."
|
||||
)
|
||||
|
||||
|
||||
# 新增:添加文件过期清理的定时任务,每小时执行一次
|
||||
if getattr(settings, 'FILES_CLEANUP_ENABLED', True):
|
||||
cleanup_interval = getattr(settings, 'FILES_CLEANUP_INTERVAL_HOURS', 1)
|
||||
if getattr(settings, "FILES_CLEANUP_ENABLED", True):
|
||||
cleanup_interval = getattr(settings, "FILES_CLEANUP_INTERVAL_HOURS", 1)
|
||||
scheduler.add_job(
|
||||
cleanup_expired_files,
|
||||
"interval",
|
||||
|
||||
@@ -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:
|
||||
@@ -119,6 +140,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
record[k] = v
|
||||
return record
|
||||
|
||||
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
|
||||
"""检查请求是否要求结构化JSON输出"""
|
||||
try:
|
||||
generation_config = payload.get("generationConfig", {})
|
||||
return generation_config.get("responseMimeType") == "application/json"
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
tool = dict()
|
||||
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||
if payload.get("tools") and isinstance(payload.get("tools"), dict):
|
||||
@@ -127,21 +156,29 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if items and isinstance(items, list):
|
||||
tool.update(_merge_tools(items))
|
||||
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
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 a response mime type: 'application/json' is unsupported"
|
||||
# Gemini API限制:不支持同时使用tools和结构化输出(response_mime_type='application/json')
|
||||
# 当请求指定了JSON响应格式时,跳过所有工具的添加以避免API错误
|
||||
has_structured_output = _is_structured_output_request(payload)
|
||||
if not has_structured_output:
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
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)
|
||||
@@ -175,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()
|
||||
@@ -227,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
|
||||
|
||||
@@ -297,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()
|
||||
@@ -316,13 +365,9 @@ class GeminiChatService:
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
@@ -330,7 +375,8 @@ class GeminiChatService:
|
||||
error_type="gemini-chat-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
@@ -342,7 +388,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(
|
||||
@@ -350,7 +396,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
|
||||
@@ -364,13 +412,9 @@ class GeminiChatService:
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Count tokens API call failed with error: {error_log_msg}")
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
@@ -378,7 +422,7 @@ class GeminiChatService:
|
||||
error_type="gemini-count-tokens",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
@@ -390,7 +434,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(
|
||||
@@ -403,11 +447,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)
|
||||
@@ -452,15 +500,11 @@ class GeminiChatService:
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key,
|
||||
@@ -468,21 +512,26 @@ class GeminiChatService:
|
||||
error_type="gemini-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=(
|
||||
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
|
||||
),
|
||||
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
|
||||
raise
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break
|
||||
logger.error(f"Max retries ({max_retries}) reached for streaming.")
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -492,5 +541,5 @@ class GeminiChatService:
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
||||
@@ -40,15 +39,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:
|
||||
@@ -59,7 +74,7 @@ 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
|
||||
|
||||
|
||||
@@ -87,7 +102,7 @@ def _build_tools(
|
||||
|
||||
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"] = {}
|
||||
@@ -116,7 +131,7 @@ def _build_tools(
|
||||
names, functions = set(), []
|
||||
for fc in function_declarations:
|
||||
if fc.get("name") not in names:
|
||||
if fc.get("name")=="googleSearch":
|
||||
if fc.get("name") == "googleSearch":
|
||||
# cherry开启内置搜索时,添加googleSearch工具
|
||||
tool["googleSearch"] = {}
|
||||
else:
|
||||
@@ -130,7 +145,7 @@ def _build_tools(
|
||||
if tool.get("functionDeclarations"):
|
||||
tool.pop("googleSearch", None)
|
||||
tool.pop("codeExecution", None)
|
||||
tool.pop("urlContext",None)
|
||||
tool.pop("urlContext", None)
|
||||
|
||||
return [tool] if tool else []
|
||||
|
||||
@@ -160,17 +175,17 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
||||
|
||||
|
||||
def _validate_and_set_max_tokens(
|
||||
payload: Dict[str, Any],
|
||||
max_tokens: Optional[int],
|
||||
logger_instance
|
||||
payload: Dict[str, Any], max_tokens: Optional[int], logger_instance
|
||||
) -> None:
|
||||
"""验证并设置 max_tokens 参数"""
|
||||
if max_tokens is None:
|
||||
return
|
||||
|
||||
|
||||
# 参数验证和处理
|
||||
if max_tokens <= 0:
|
||||
logger_instance.warning(f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens")
|
||||
logger_instance.warning(
|
||||
f"Invalid max_tokens value: {max_tokens}, will not set maxOutputTokens"
|
||||
)
|
||||
# 不设置 maxOutputTokens,让 Gemini API 使用默认值
|
||||
else:
|
||||
payload["generationConfig"]["maxOutputTokens"] = max_tokens
|
||||
@@ -193,27 +208,33 @@ def _build_payload(
|
||||
"tools": _build_tools(request, messages),
|
||||
"safetySettings": _get_safety_settings(request.model),
|
||||
}
|
||||
|
||||
|
||||
# 处理 max_tokens 参数
|
||||
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
|
||||
|
||||
|
||||
# 处理 n 参数
|
||||
if request.n is not None and request.n > 0:
|
||||
payload["generationConfig"]["candidateCount"] = request.n
|
||||
|
||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
|
||||
|
||||
if request.model.endswith("-non-thinking"):
|
||||
if "gemini-2.5-pro" in request.model:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
|
||||
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
|
||||
"includeThoughts": True
|
||||
"includeThoughts": True,
|
||||
}
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)}
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
|
||||
}
|
||||
|
||||
if (
|
||||
instruction
|
||||
@@ -263,7 +284,9 @@ class OpenAIChatService:
|
||||
api_key: str,
|
||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||
"""创建聊天完成"""
|
||||
messages, instruction = self.message_converter.convert(request.messages)
|
||||
messages, instruction = self.message_converter.convert(
|
||||
request.messages, request.model
|
||||
)
|
||||
|
||||
payload = _build_payload(request, messages, instruction)
|
||||
|
||||
@@ -280,13 +303,13 @@ class OpenAIChatService:
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
|
||||
try:
|
||||
response = await self.api_client.generate_content(payload, model, api_key)
|
||||
usage_metadata = response.get("usageMetadata", {})
|
||||
is_success = True
|
||||
status_code = 200
|
||||
|
||||
|
||||
# 尝试处理响应,捕获可能的响应处理异常
|
||||
try:
|
||||
result = self.response_handler.handle_response(
|
||||
@@ -298,8 +321,10 @@ class OpenAIChatService:
|
||||
)
|
||||
return result
|
||||
except Exception as response_error:
|
||||
logger.error(f"Response processing failed for model {model}: {str(response_error)}")
|
||||
|
||||
logger.error(
|
||||
f"Response processing failed for model {model}: {str(response_error)}"
|
||||
)
|
||||
|
||||
# 记录详细的错误信息
|
||||
if "parts" in str(response_error):
|
||||
logger.error("Response structure issue - missing or invalid parts")
|
||||
@@ -307,26 +332,26 @@ class OpenAIChatService:
|
||||
candidate = response["candidates"][0]
|
||||
content = candidate.get("content", {})
|
||||
logger.error(f"Content structure: {content}")
|
||||
|
||||
|
||||
# 重新抛出异常
|
||||
raise response_error
|
||||
|
||||
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"API call failed for model {model}: {error_log_msg}")
|
||||
|
||||
|
||||
# 特别记录 max_tokens 相关的错误
|
||||
gen_config = payload.get('generationConfig', {})
|
||||
gen_config = payload.get("generationConfig", {})
|
||||
if "maxOutputTokens" in gen_config:
|
||||
logger.error(f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}")
|
||||
|
||||
logger.error(
|
||||
f"Request had maxOutputTokens: {gen_config['maxOutputTokens']}"
|
||||
)
|
||||
|
||||
# 如果是响应处理错误,记录更多信息
|
||||
if "parts" in error_log_msg:
|
||||
logger.error("This is likely a response processing error")
|
||||
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
status_code = int(match.group(1)) if match else 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
@@ -334,14 +359,17 @@ class OpenAIChatService:
|
||||
error_type="openai-chat-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload,
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
logger.info(f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms")
|
||||
|
||||
logger.info(
|
||||
f"Normal completion finished - Success: {is_success}, Latency: {latency_ms}ms"
|
||||
)
|
||||
|
||||
await add_request_log(
|
||||
model_name=model,
|
||||
api_key=api_key,
|
||||
@@ -358,49 +386,44 @@ class OpenAIChatService:
|
||||
logger.info(
|
||||
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
||||
)
|
||||
keep_sending_empty_data = True
|
||||
|
||||
async def send_empty_data_locally() -> AsyncGenerator[str, None]:
|
||||
"""定期发送空数据以保持连接"""
|
||||
while keep_sending_empty_data:
|
||||
await asyncio.sleep(settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS)
|
||||
if keep_sending_empty_data:
|
||||
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||
|
||||
empty_data_generator = send_empty_data_locally()
|
||||
api_response_task = asyncio.create_task(
|
||||
self.api_client.generate_content(payload, model, api_key)
|
||||
)
|
||||
|
||||
i = 0
|
||||
try:
|
||||
while not api_response_task.done():
|
||||
try:
|
||||
next_empty_chunk = await asyncio.wait_for(
|
||||
empty_data_generator.__anext__(), timeout=0.1
|
||||
i = i + 1
|
||||
"""定期发送空数据以保持连接"""
|
||||
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS:
|
||||
i = 0
|
||||
empty_chunk = self.response_handler.handle_response(
|
||||
{},
|
||||
model,
|
||||
stream=True,
|
||||
finish_reason="stop",
|
||||
usage_metadata=None,
|
||||
)
|
||||
yield next_empty_chunk
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except (
|
||||
StopAsyncIteration
|
||||
):
|
||||
break
|
||||
|
||||
response = await api_response_task
|
||||
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
keep_sending_empty_data = False
|
||||
response = await api_response_task
|
||||
|
||||
if response and response.get("candidates"):
|
||||
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
|
||||
response = self.response_handler.handle_response(
|
||||
response,
|
||||
model,
|
||||
stream=True,
|
||||
finish_reason="stop",
|
||||
usage_metadata=response.get("usageMetadata", {}),
|
||||
)
|
||||
yield f"data: {json.dumps(response)}\n\n"
|
||||
logger.info(f"Sent full response content for fake stream: {model}")
|
||||
else:
|
||||
error_message = "Failed to get response from model"
|
||||
if (
|
||||
response and isinstance(response, dict) and response.get("error")
|
||||
):
|
||||
if response and isinstance(response, dict) and response.get("error"):
|
||||
error_details = response.get("error")
|
||||
if isinstance(error_details, dict):
|
||||
error_message = error_details.get("message", error_message)
|
||||
@@ -408,7 +431,9 @@ class OpenAIChatService:
|
||||
logger.error(
|
||||
f"No candidates or error in response for fake stream model {model}: {response}"
|
||||
)
|
||||
error_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||
error_chunk = self.response_handler.handle_response(
|
||||
{}, model, stream=True, finish_reason="stop", usage_metadata=None
|
||||
)
|
||||
yield f"data: {json.dumps(error_chunk)}\n\n"
|
||||
|
||||
async def _real_stream_logic_impl(
|
||||
@@ -436,7 +461,11 @@ class OpenAIChatService:
|
||||
)
|
||||
continue
|
||||
openai_chunk = self.response_handler.handle_response(
|
||||
chunk, model, stream=True, finish_reason=None, usage_metadata=usage_metadata
|
||||
chunk,
|
||||
model,
|
||||
stream=True,
|
||||
finish_reason=None,
|
||||
usage_metadata=usage_metadata,
|
||||
)
|
||||
if openai_chunk:
|
||||
text = self._extract_text_from_openai_chunk(openai_chunk)
|
||||
@@ -450,7 +479,9 @@ class OpenAIChatService:
|
||||
):
|
||||
yield optimized_chunk_data
|
||||
else:
|
||||
if openai_chunk.get("choices") and openai_chunk["choices"][0].get("delta", {}).get("tool_calls"):
|
||||
if openai_chunk.get("choices") and openai_chunk["choices"][
|
||||
0
|
||||
].get("delta", {}).get("tool_calls"):
|
||||
tool_call_flag = True
|
||||
|
||||
yield f"data: {json.dumps(openai_chunk)}\n\n"
|
||||
@@ -506,27 +537,22 @@ class OpenAIChatService:
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries} with key {current_attempt_key}"
|
||||
)
|
||||
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
if isinstance(e, asyncio.TimeoutError):
|
||||
status_code = 408
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key,
|
||||
model_name=model,
|
||||
error_type="openai-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload,
|
||||
request_msg=(
|
||||
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
|
||||
),
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
|
||||
if self.key_manager:
|
||||
@@ -542,7 +568,7 @@ class OpenAIChatService:
|
||||
logger.error(
|
||||
f"No valid API key available after {retries} retries, ceasing attempts for this request."
|
||||
)
|
||||
break
|
||||
raise
|
||||
else:
|
||||
logger.error(
|
||||
"KeyManager not available, cannot switch API key. Ceasing attempts for this request."
|
||||
@@ -553,6 +579,7 @@ class OpenAIChatService:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming model {model}."
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -565,13 +592,6 @@ class OpenAIChatService:
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
if not is_success:
|
||||
logger.error(
|
||||
f"Streaming failed permanently for model {model} after {retries} attempts."
|
||||
)
|
||||
yield f"data: {json.dumps({'error': f'Streaming failed after {retries} retries.'})}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
async def create_image_chat_completion(
|
||||
self, request: ChatRequest, api_key: str
|
||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||
@@ -630,9 +650,9 @@ class OpenAIChatService:
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = f"Stream image completion failed for model {model}: {e}"
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
@@ -640,9 +660,9 @@ 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"
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -680,9 +700,9 @@ class OpenAIChatService:
|
||||
return result
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = f"Normal image completion failed for model {model}: {e}"
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(error_log_msg)
|
||||
status_code = 500
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
@@ -690,8 +710,9 @@ 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
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# app/services/chat_service.py
|
||||
|
||||
import json
|
||||
import re
|
||||
import datetime
|
||||
import json
|
||||
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 +33,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 +68,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 +89,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:
|
||||
@@ -97,6 +113,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
record[k] = v
|
||||
return record
|
||||
|
||||
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
|
||||
"""检查请求是否要求结构化JSON输出"""
|
||||
try:
|
||||
generation_config = payload.get("generationConfig", {})
|
||||
return generation_config.get("responseMimeType") == "application/json"
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
tool = dict()
|
||||
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||
if payload.get("tools") and isinstance(payload.get("tools"), dict):
|
||||
@@ -105,21 +129,29 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if items and isinstance(items, list):
|
||||
tool.update(_merge_tools(items))
|
||||
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
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 a response mime type: 'application/json' is unsupported"
|
||||
# Gemini API限制:不支持同时使用tools和结构化输出(response_mime_type='application/json')
|
||||
# 当请求指定了JSON响应格式时,跳过所有工具的添加以避免API错误
|
||||
has_structured_output = _is_structured_output_request(payload)
|
||||
if not has_structured_output:
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
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)
|
||||
@@ -153,7 +185,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),
|
||||
@@ -165,30 +197,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
|
||||
|
||||
@@ -243,13 +277,9 @@ class GeminiChatService:
|
||||
return self.response_handler.handle_response(response, model, stream=False)
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
@@ -257,7 +287,8 @@ class GeminiChatService:
|
||||
error_type="gemini-chat-non-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
finally:
|
||||
@@ -269,7 +300,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(
|
||||
@@ -287,7 +318,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
|
||||
@@ -320,15 +351,11 @@ class GeminiChatService:
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key,
|
||||
@@ -336,21 +363,26 @@ class GeminiChatService:
|
||||
error_type="gemini-chat-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload
|
||||
request_msg=(
|
||||
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
|
||||
),
|
||||
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
|
||||
raise
|
||||
|
||||
if retries >= max_retries:
|
||||
logger.error(
|
||||
f"Max retries ({max_retries}) reached for streaming."
|
||||
)
|
||||
break
|
||||
logger.error(f"Max retries ({max_retries}) reached for streaming.")
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -360,5 +392,5 @@ class GeminiChatService:
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime
|
||||
request_time=request_datetime,
|
||||
)
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
# app/services/chat/api_client.py
|
||||
|
||||
from typing import Dict, Any, AsyncGenerator, Optional
|
||||
import httpx
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_api_client_logger
|
||||
from app.core.constants import DEFAULT_TIMEOUT
|
||||
from app.log.logger import get_api_client_logger
|
||||
|
||||
logger = get_api_client_logger()
|
||||
|
||||
|
||||
class ApiClient(ABC):
|
||||
"""API客户端基类"""
|
||||
|
||||
@abstractmethod
|
||||
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||
async def generate_content(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
|
||||
async def stream_generate_content(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -50,7 +57,7 @@ class GeminiApiClient(ApiClient):
|
||||
async def get_models(self, api_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取可用的 Gemini 模型列表"""
|
||||
timeout = httpx.Timeout(timeout=5)
|
||||
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
@@ -73,11 +80,13 @@ class GeminiApiClient(ApiClient):
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"请求模型列表失败: {e}")
|
||||
return None
|
||||
|
||||
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||
|
||||
async def generate_content(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
@@ -85,42 +94,46 @@ class GeminiApiClient(ApiClient):
|
||||
else:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for getting models: {proxy_to_use}")
|
||||
|
||||
|
||||
headers = self._prepare_headers()
|
||||
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
||||
|
||||
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
logger.error(f"API call failed - Status: {response.status_code}, Content: {error_content}")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
|
||||
logger.error(
|
||||
f"API call failed - Status: {response.status_code}, Content: {error_content}"
|
||||
)
|
||||
raise Exception(response.status_code, error_content)
|
||||
|
||||
response_data = response.json()
|
||||
|
||||
|
||||
# 检查响应结构的基本信息
|
||||
if not response_data.get("candidates"):
|
||||
logger.warning("No candidates found in API response")
|
||||
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Request timeout: {e}")
|
||||
raise Exception(f"Request timeout: {e}")
|
||||
raise Exception(500, f"Request timeout: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Request error: {e}")
|
||||
raise Exception(f"Request error: {e}")
|
||||
raise Exception(500, f"Request error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
raise
|
||||
raise Exception(500, f"Unexpected error: {e}")
|
||||
|
||||
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
|
||||
async def stream_generate_content(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
@@ -132,15 +145,19 @@ class GeminiApiClient(ApiClient):
|
||||
headers = self._prepare_headers()
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
|
||||
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
|
||||
async with client.stream(
|
||||
method="POST", url=url, json=payload, headers=headers
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
error_content = await response.aread()
|
||||
error_msg = error_content.decode("utf-8")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
||||
raise Exception(response.status_code, error_msg)
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
|
||||
async def count_tokens(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||
async def count_tokens(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
@@ -158,9 +175,91 @@ class GeminiApiClient(ApiClient):
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
raise Exception(response.status_code, error_content)
|
||||
return response.json()
|
||||
|
||||
async def embed_content(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""单一嵌入内容生成"""
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
|
||||
else:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for embedding: {proxy_to_use}")
|
||||
|
||||
headers = self._prepare_headers()
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:embedContent?key={api_key}"
|
||||
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
logger.error(
|
||||
f"Embedding API call failed - Status: {response.status_code}, Content: {error_content}"
|
||||
)
|
||||
raise Exception(response.status_code, error_content)
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Embedding request timeout: {e}")
|
||||
raise Exception(500, f"Request timeout: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Embedding request error: {e}")
|
||||
raise Exception(500, f"Request error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected embedding error: {e}")
|
||||
raise Exception(500, f"Unexpected embedding error: {e}")
|
||||
|
||||
async def batch_embed_contents(
|
||||
self, payload: Dict[str, Any], model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""批量嵌入内容生成"""
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
|
||||
else:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for batch embedding: {proxy_to_use}")
|
||||
|
||||
headers = self._prepare_headers()
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:batchEmbedContents?key={api_key}"
|
||||
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
logger.error(
|
||||
f"Batch embedding API call failed - Status: {response.status_code}, Content: {error_content}"
|
||||
)
|
||||
raise Exception(response.status_code, error_content)
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Batch embedding request timeout: {e}")
|
||||
raise Exception(500, f"Request timeout: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Batch embedding request error: {e}")
|
||||
raise Exception(500, f"Request error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected batch embedding error: {e}")
|
||||
raise Exception(500, f"Unexpected batch embedding error: {e}")
|
||||
|
||||
|
||||
class OpenaiApiClient(ApiClient):
|
||||
"""OpenAI API客户端"""
|
||||
@@ -168,7 +267,7 @@ class OpenaiApiClient(ApiClient):
|
||||
def __init__(self, base_url: str, timeout: int = DEFAULT_TIMEOUT):
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def _prepare_headers(self, api_key: str) -> Dict[str, str]:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if settings.CUSTOM_HEADERS:
|
||||
@@ -193,12 +292,16 @@ class OpenaiApiClient(ApiClient):
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
raise Exception(response.status_code, error_content)
|
||||
return response.json()
|
||||
|
||||
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
|
||||
async def generate_content(
|
||||
self, payload: Dict[str, Any], api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
logger.info(f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}")
|
||||
logger.info(
|
||||
f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}"
|
||||
)
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
@@ -213,10 +316,12 @@ class OpenaiApiClient(ApiClient):
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
raise Exception(response.status_code, error_content)
|
||||
return response.json()
|
||||
|
||||
async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]:
|
||||
async def stream_generate_content(
|
||||
self, payload: Dict[str, Any], api_key: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
@@ -229,17 +334,21 @@ class OpenaiApiClient(ApiClient):
|
||||
headers = self._prepare_headers(api_key)
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/openai/chat/completions"
|
||||
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
|
||||
async with client.stream(
|
||||
method="POST", url=url, json=payload, headers=headers
|
||||
) as response:
|
||||
if response.status_code != 200:
|
||||
error_content = await response.aread()
|
||||
error_msg = error_content.decode("utf-8")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_msg}")
|
||||
raise Exception(response.status_code, error_msg)
|
||||
async for line in response.aiter_lines():
|
||||
yield line
|
||||
|
||||
async def create_embeddings(self, input: str, model: str, api_key: str) -> Dict[str, Any]:
|
||||
|
||||
async def create_embeddings(
|
||||
self, input: str, model: str, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
@@ -258,10 +367,12 @@ class OpenaiApiClient(ApiClient):
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
raise Exception(response.status_code, error_content)
|
||||
return response.json()
|
||||
|
||||
async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
|
||||
|
||||
async def generate_images(
|
||||
self, payload: Dict[str, Any], api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
|
||||
proxy_to_use = None
|
||||
@@ -278,5 +389,5 @@ class OpenaiApiClient(ApiClient):
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
raise Exception(response.status_code, error_content)
|
||||
return response.json()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
import time
|
||||
import re
|
||||
from typing import List, Union
|
||||
|
||||
import openai
|
||||
@@ -8,8 +7,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 +26,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)
|
||||
@@ -48,13 +55,9 @@ class EmbeddingService:
|
||||
raise e
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
status_code = 500
|
||||
error_log_msg = f"Generic error: {e}"
|
||||
logger.error(f"Error creating embedding (Exception): {error_log_msg}")
|
||||
match = re.search(r"status code (\d+)", str(e))
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
raise e
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
@@ -66,13 +69,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,
|
||||
)
|
||||
|
||||
141
app/service/embedding/gemini_embedding_service.py
Normal file
141
app/service/embedding/gemini_embedding_service.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# app/service/embedding/gemini_embedding_service.py
|
||||
|
||||
import datetime
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config.config import settings
|
||||
from app.database.services import add_error_log, add_request_log
|
||||
from app.domain.gemini_models import GeminiBatchEmbedRequest, GeminiEmbedRequest
|
||||
from app.log.logger import get_gemini_embedding_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
|
||||
logger = get_gemini_embedding_logger()
|
||||
|
||||
|
||||
def _build_embed_payload(request: GeminiEmbedRequest) -> Dict[str, Any]:
|
||||
"""构建嵌入请求payload"""
|
||||
payload = {"content": request.content.model_dump()}
|
||||
|
||||
if request.taskType:
|
||||
payload["taskType"] = request.taskType
|
||||
if request.title:
|
||||
payload["title"] = request.title
|
||||
if request.outputDimensionality:
|
||||
payload["outputDimensionality"] = request.outputDimensionality
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _build_batch_embed_payload(
|
||||
request: GeminiBatchEmbedRequest, model: str
|
||||
) -> Dict[str, Any]:
|
||||
"""构建批量嵌入请求payload"""
|
||||
requests = []
|
||||
for embed_request in request.requests:
|
||||
embed_payload = _build_embed_payload(embed_request)
|
||||
embed_payload["model"] = (
|
||||
f"models/{model}" # Gemini API要求每个请求包含model字段
|
||||
)
|
||||
requests.append(embed_payload)
|
||||
|
||||
return {"requests": requests}
|
||||
|
||||
|
||||
class GeminiEmbeddingService:
|
||||
"""Gemini嵌入服务"""
|
||||
|
||||
def __init__(self, base_url: str, key_manager: KeyManager):
|
||||
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
|
||||
self.key_manager = key_manager
|
||||
|
||||
async def embed_content(
|
||||
self, model: str, request: GeminiEmbedRequest, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""生成单一嵌入内容"""
|
||||
payload = _build_embed_payload(request)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.api_client.embed_content(payload, model, api_key)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Single embedding API call failed: {error_log_msg}")
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="gemini-embed-single",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
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 batch_embed_contents(
|
||||
self, model: str, request: GeminiBatchEmbedRequest, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""生成批量嵌入内容"""
|
||||
payload = _build_batch_embed_payload(request, model)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.api_client.batch_embed_contents(
|
||||
payload, model, api_key
|
||||
)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Batch embedding API call failed: {error_log_msg}")
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
model_name=model,
|
||||
error_type="gemini-embed-batch",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None,
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
raise e
|
||||
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,
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
@@ -28,7 +28,7 @@ async def delete_old_error_logs():
|
||||
)
|
||||
return
|
||||
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
||||
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
||||
|
||||
logger.info(
|
||||
f"Attempting to delete error logs older than {days_to_keep} days (before {cutoff_date.strftime('%Y-%m-%d %H:%M:%S %Z')})."
|
||||
@@ -121,6 +121,30 @@ async def process_get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]
|
||||
raise
|
||||
|
||||
|
||||
async def process_find_error_log_by_info(
|
||||
gemini_key: str,
|
||||
timestamp: datetime,
|
||||
status_code: Optional[int] = None,
|
||||
window_seconds: int = 100,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据 key/状态码/时间窗口 查询最匹配的一条错误日志,未找到则返回 None。
|
||||
"""
|
||||
try:
|
||||
return await db_services.find_error_log_by_info(
|
||||
gemini_key=gemini_key,
|
||||
timestamp=timestamp,
|
||||
status_code=status_code,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Service error in process_find_error_log_by_info: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def process_delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||
"""
|
||||
按 ID 批量删除错误日志。
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.config.config import settings
|
||||
from app.core.constants import VALID_IMAGE_RATIOS
|
||||
from app.domain.openai_models import ImageGenerationRequest
|
||||
from app.log.logger import get_image_create_logger
|
||||
from app.utils.helpers import is_image_upload_configured
|
||||
from app.utils.uploader import ImageUploaderFactory
|
||||
|
||||
logger = get_image_create_logger()
|
||||
@@ -97,12 +98,18 @@ class ImageCreateService:
|
||||
image_data = generated_image.image.image_bytes
|
||||
image_uploader = None
|
||||
|
||||
if request.response_format == "b64_json":
|
||||
# Return base64 if explicitly requested or if no uploader is configured
|
||||
if (
|
||||
request.response_format == "b64_json"
|
||||
or not is_image_upload_configured(settings)
|
||||
):
|
||||
base64_image = base64.b64encode(image_data).decode("utf-8")
|
||||
images_data.append(
|
||||
{"b64_json": base64_image, "revised_prompt": request.prompt}
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Upload to configured provider
|
||||
current_date = time.strftime("%Y/%m/%d")
|
||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||
|
||||
@@ -115,6 +122,7 @@ class ImageCreateService:
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
api_key=settings.PICGO_API_KEY,
|
||||
api_url=settings.PICGO_API_URL,
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "cloudflare_imgbed":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
@@ -123,6 +131,16 @@ class ImageCreateService:
|
||||
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
|
||||
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
|
||||
)
|
||||
elif settings.UPLOAD_PROVIDER == "aliyun_oss":
|
||||
image_uploader = ImageUploaderFactory.create(
|
||||
provider=settings.UPLOAD_PROVIDER,
|
||||
access_key=settings.OSS_ACCESS_KEY,
|
||||
access_key_secret=settings.OSS_ACCESS_KEY_SECRET,
|
||||
bucket_name=settings.OSS_BUCKET_NAME,
|
||||
endpoint=settings.OSS_ENDPOINT,
|
||||
region=settings.OSS_REGION,
|
||||
use_internal=False
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported upload provider: {settings.UPLOAD_PROVIDER}"
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Dict, Union
|
||||
|
||||
@@ -11,20 +8,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 +35,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,
|
||||
@@ -78,13 +78,9 @@ class OpenAICompatiableService:
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.error(f"Normal API call failed with error: {error_log_msg}")
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=api_key,
|
||||
@@ -136,15 +132,11 @@ class OpenAICompatiableService:
|
||||
except Exception as e:
|
||||
retries += 1
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
status_code = e.args[0]
|
||||
error_log_msg = e.args[1]
|
||||
logger.warning(
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries}"
|
||||
)
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
status_code = 500
|
||||
|
||||
await add_error_log(
|
||||
gemini_key=current_attempt_key,
|
||||
@@ -152,7 +144,10 @@ class OpenAICompatiableService:
|
||||
error_type="openai-compatiable-stream",
|
||||
error_log=error_log_msg,
|
||||
error_code=status_code,
|
||||
request_msg=payload,
|
||||
request_msg=(
|
||||
payload if settings.ERROR_LOG_RECORD_REQUEST_BODY else None
|
||||
),
|
||||
request_datetime=request_datetime,
|
||||
)
|
||||
|
||||
if self.key_manager:
|
||||
@@ -160,19 +155,21 @@ 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
|
||||
raise
|
||||
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.")
|
||||
break
|
||||
raise
|
||||
finally:
|
||||
end_time = time.perf_counter()
|
||||
latency_ms = int((end_time - start_time) * 1000)
|
||||
@@ -184,8 +181,3 @@ class OpenAICompatiableService:
|
||||
latency_ms=latency_ms,
|
||||
request_time=request_datetime,
|
||||
)
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
Service for request log operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.database.connection import database
|
||||
from app.config.config import settings
|
||||
from app.database.connection import database
|
||||
from app.database.models import RequestLog
|
||||
from app.log.logger import get_request_log_logger
|
||||
|
||||
@@ -30,7 +30,7 @@ async def delete_old_request_logs_task():
|
||||
)
|
||||
|
||||
try:
|
||||
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_to_keep)
|
||||
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
||||
|
||||
query = delete(RequestLog).where(RequestLog.request_time < cutoff_date)
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ class StatsService:
|
||||
period: 时间段标识 ('1m', '1h', '24h')
|
||||
|
||||
Returns:
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status
|
||||
包含调用详情的字典列表,每个字典包含 timestamp, key, model, status, status_code, latency_ms, error_log_id(可选)
|
||||
|
||||
Raises:
|
||||
ValueError: 如果 period 无效
|
||||
@@ -156,6 +156,8 @@ class StatsService:
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == "1h":
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == "8h":
|
||||
start_time = now - datetime.timedelta(hours=8)
|
||||
elif period == "24h":
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
@@ -167,7 +169,8 @@ class StatsService:
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
RequestLog.model_name.label("model"),
|
||||
RequestLog.status_code,
|
||||
RequestLog.status_code.label("status_code"),
|
||||
RequestLog.latency_ms.label("latency_ms"),
|
||||
)
|
||||
.where(RequestLog.request_time >= start_time)
|
||||
.order_by(RequestLog.request_time.desc())
|
||||
@@ -175,31 +178,127 @@ class StatsService:
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
details = []
|
||||
details: list[dict] = []
|
||||
for row in results:
|
||||
status = "failure"
|
||||
if row["status_code"] is not None:
|
||||
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||
details.append(
|
||||
{
|
||||
"timestamp": row[
|
||||
"timestamp"
|
||||
].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
|
||||
record = {
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
"status_code": row["status_code"],
|
||||
"latency_ms": row["latency_ms"],
|
||||
}
|
||||
|
||||
details.append(record)
|
||||
|
||||
logger.info(
|
||||
f"Retrieved {len(details)} API call details for period '{period}'"
|
||||
)
|
||||
return details
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get API call details for period '{period}': {e}")
|
||||
logger.error(f"Failed to get API call details for period '{period}': {e}")
|
||||
raise
|
||||
|
||||
async def get_key_call_details(self, key: str, period: str) -> list[dict]:
|
||||
"""获取指定密钥在指定时间段内的调用详情 (与 get_api_call_details 结构一致)"""
|
||||
now = datetime.datetime.now()
|
||||
if period == "1m":
|
||||
start_time = now - datetime.timedelta(minutes=1)
|
||||
elif period == "1h":
|
||||
start_time = now - datetime.timedelta(hours=1)
|
||||
elif period == "8h":
|
||||
start_time = now - datetime.timedelta(hours=8)
|
||||
elif period == "24h":
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
else:
|
||||
raise ValueError(f"无效的时间段标识: {period}")
|
||||
|
||||
try:
|
||||
query = (
|
||||
select(
|
||||
RequestLog.request_time.label("timestamp"),
|
||||
RequestLog.api_key.label("key"),
|
||||
RequestLog.model_name.label("model"),
|
||||
RequestLog.status_code.label("status_code"),
|
||||
RequestLog.latency_ms.label("latency_ms"),
|
||||
)
|
||||
.where(RequestLog.request_time >= start_time, RequestLog.api_key == key)
|
||||
.order_by(RequestLog.request_time.desc())
|
||||
)
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
details: list[dict] = []
|
||||
for row in results:
|
||||
status = "failure"
|
||||
if row["status_code"] is not None:
|
||||
status = "success" if 200 <= row["status_code"] < 300 else "failure"
|
||||
|
||||
record = {
|
||||
"timestamp": row["timestamp"].isoformat(),
|
||||
"key": row["key"],
|
||||
"model": row["model"],
|
||||
"status": status,
|
||||
"status_code": row["status_code"],
|
||||
"latency_ms": row["latency_ms"],
|
||||
}
|
||||
|
||||
details.append(record)
|
||||
|
||||
logger.info(
|
||||
f"Retrieved {len(details)} key call details for key=...{key[-4:] if key else ''} period '{period}'"
|
||||
)
|
||||
return details
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get key call details for key=...{key[-4:] if key else ''} period '{period}': {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
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}, ...] 按次数降序
|
||||
"""
|
||||
try:
|
||||
now = datetime.datetime.now()
|
||||
start_time = now - datetime.timedelta(hours=24)
|
||||
if not include_keys:
|
||||
return []
|
||||
query = (
|
||||
select(
|
||||
RequestLog.api_key.label("key"),
|
||||
func.count(RequestLog.id).label("count"),
|
||||
)
|
||||
.where(
|
||||
RequestLog.request_time >= start_time,
|
||||
RequestLog.status_code == status_code,
|
||||
RequestLog.api_key.isnot(None),
|
||||
RequestLog.api_key.in_(list(include_keys)),
|
||||
)
|
||||
.group_by(RequestLog.api_key)
|
||||
.order_by(func.count(RequestLog.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = await database.fetch_all(query)
|
||||
return [
|
||||
{"key": row["key"], "count": row["count"], "status_code": status_code}
|
||||
for row in rows
|
||||
if row["key"]
|
||||
]
|
||||
except Exception as 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]:
|
||||
"""
|
||||
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
|
||||
@@ -220,8 +319,7 @@ class StatsService:
|
||||
try:
|
||||
query = (
|
||||
select(
|
||||
RequestLog.model_name, func.count(
|
||||
RequestLog.id).label("call_count")
|
||||
RequestLog.model_name, func.count(RequestLog.id).label("call_count")
|
||||
)
|
||||
.where(
|
||||
RequestLog.api_key == key,
|
||||
@@ -240,8 +338,7 @@ class StatsService:
|
||||
)
|
||||
return {}
|
||||
|
||||
usage_details = {row["model_name"]: row["call_count"]
|
||||
for row in results}
|
||||
usage_details = {row["model_name"]: row["call_count"] for row in results}
|
||||
logger.info(
|
||||
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
|
||||
)
|
||||
|
||||
315
app/static/css/fonts.css
Normal file
315
app/static/css/fonts.css
Normal file
@@ -0,0 +1,315 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -16,6 +16,13 @@ const PROXY_REGEX =
|
||||
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_\-]{50}/g; // 新增 Vertex Express API Key 正则
|
||||
const MASKED_VALUE = "••••••••";
|
||||
|
||||
// API Keys Pagination Constants
|
||||
const API_KEYS_PER_PAGE = 20; // 每页显示的API密钥数量
|
||||
let currentApiKeyPage = 1;
|
||||
let totalApiKeyPages = 1;
|
||||
let allApiKeys = []; // 存储所有API密钥数据
|
||||
let filteredApiKeys = []; // 存储过滤后的API密钥数据
|
||||
|
||||
// DOM Elements - Global Scope for frequently accessed elements
|
||||
const safetySettingsContainer = document.getElementById(
|
||||
"SAFETY_SETTINGS_container"
|
||||
@@ -97,6 +104,24 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
}
|
||||
|
||||
// 检查间隔小时数输入控制
|
||||
const checkIntervalInput = document.getElementById("CHECK_INTERVAL_HOURS");
|
||||
if (checkIntervalInput) {
|
||||
checkIntervalInput.addEventListener("input", function () {
|
||||
let value = parseFloat(this.value);
|
||||
if (isNaN(value) || value < 0) {
|
||||
this.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
checkIntervalInput.addEventListener("change", function () {
|
||||
let value = parseFloat(this.value);
|
||||
if (isNaN(value) || value < 0) {
|
||||
this.value = 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle switch events
|
||||
const toggleSwitches = document.querySelectorAll(".toggle-switch");
|
||||
toggleSwitches.forEach((toggleSwitch) => {
|
||||
@@ -147,6 +172,17 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (apiKeySearchInput)
|
||||
apiKeySearchInput.addEventListener("input", handleApiKeySearch);
|
||||
|
||||
// API Key Pagination Event Listeners
|
||||
const apiKeyPrevBtn = document.getElementById("apiKeyPrevBtn");
|
||||
const apiKeyNextBtn = document.getElementById("apiKeyNextBtn");
|
||||
|
||||
if (apiKeyPrevBtn) {
|
||||
apiKeyPrevBtn.addEventListener("click", prevApiKeyPage);
|
||||
}
|
||||
if (apiKeyNextBtn) {
|
||||
apiKeyNextBtn.addEventListener("click", nextApiKeyPage);
|
||||
}
|
||||
|
||||
// Bulk Delete API Key Modal Elements and Events
|
||||
const bulkDeleteApiKeyBtn = document.getElementById("bulkDeleteApiKeyBtn");
|
||||
const closeBulkDeleteModalBtn = document.getElementById(
|
||||
@@ -753,6 +789,10 @@ async function initConfig() {
|
||||
if (typeof config.AUTO_DELETE_ERROR_LOGS_DAYS === "undefined") {
|
||||
config.AUTO_DELETE_ERROR_LOGS_DAYS = 7;
|
||||
}
|
||||
// 错误日志是否记录请求体(默认不记录)
|
||||
if (typeof config.ERROR_LOG_RECORD_REQUEST_BODY === "undefined") {
|
||||
config.ERROR_LOG_RECORD_REQUEST_BODY = false;
|
||||
}
|
||||
// --- 结束:处理自动删除错误日志配置的默认值 ---
|
||||
|
||||
// --- 新增:处理自动删除请求日志配置的默认值 ---
|
||||
@@ -924,9 +964,9 @@ function populateForm(config) {
|
||||
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
|
||||
}
|
||||
|
||||
// 4. Populate other array fields (excluding THINKING_MODELS)
|
||||
// 4. Populate other array fields (excluding THINKING_MODELS and API_KEYS)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (Array.isArray(value) && key !== "THINKING_MODELS") {
|
||||
if (Array.isArray(value) && key !== "THINKING_MODELS" && key !== "API_KEYS") {
|
||||
const container = document.getElementById(`${key}_container`);
|
||||
if (container) {
|
||||
value.forEach((itemValue) => {
|
||||
@@ -940,6 +980,17 @@ function populateForm(config) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4.1. 特殊处理API_KEYS - 使用分页
|
||||
if (Array.isArray(config.API_KEYS)) {
|
||||
allApiKeys = config.API_KEYS.filter(key =>
|
||||
typeof key === "string" && key.trim() !== ""
|
||||
);
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
currentApiKeyPage = 1;
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
// 5. Populate non-array/non-budget fields
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (
|
||||
@@ -1062,44 +1113,31 @@ function populateForm(config) {
|
||||
* Handles the bulk addition of API keys from the modal input.
|
||||
*/
|
||||
function handleBulkAddApiKeys() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return;
|
||||
if (!apiKeyBulkInput || !apiKeyModal) return;
|
||||
|
||||
const bulkText = apiKeyBulkInput.value;
|
||||
const extractedKeys = bulkText.match(API_KEY_REGEX) || [];
|
||||
|
||||
const currentKeyInputs = apiKeyContainer.querySelectorAll(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
let currentKeys = Array.from(currentKeyInputs)
|
||||
.map((input) => {
|
||||
return input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value")
|
||||
: input.value;
|
||||
})
|
||||
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
|
||||
|
||||
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
|
||||
// 合并现有密钥和新密钥,去重
|
||||
const combinedKeys = new Set([...allApiKeys, ...extractedKeys]);
|
||||
const uniqueKeys = Array.from(combinedKeys);
|
||||
|
||||
apiKeyContainer.innerHTML = ""; // Clear existing items more directly
|
||||
// 更新全局密钥数组
|
||||
allApiKeys = uniqueKeys;
|
||||
|
||||
// 更新过滤后的数组
|
||||
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
uniqueKeys.forEach((key) => {
|
||||
addArrayItemWithValue("API_KEYS", key);
|
||||
});
|
||||
|
||||
const newKeyInputs = apiKeyContainer.querySelectorAll(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
newKeyInputs.forEach((input) => {
|
||||
if (configForm && typeof initializeSensitiveFields === "function") {
|
||||
const focusoutEvent = new Event("focusout", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(focusoutEvent);
|
||||
}
|
||||
});
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
|
||||
closeModal(apiKeyModal);
|
||||
showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, "success");
|
||||
@@ -1109,32 +1147,139 @@ function handleBulkAddApiKeys() {
|
||||
* Handles searching/filtering of API keys in the list.
|
||||
*/
|
||||
function handleApiKeySearch() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeySearchInput || !apiKeyContainer) return;
|
||||
if (!apiKeySearchInput) return;
|
||||
|
||||
const searchTerm = apiKeySearchInput.value.toLowerCase();
|
||||
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
|
||||
|
||||
keyItems.forEach((item) => {
|
||||
const input = item.querySelector(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
|
||||
// 过滤API密钥
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
if (input) {
|
||||
const realValue = input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value").toLowerCase()
|
||||
: input.value.toLowerCase();
|
||||
item.style.display = realValue.includes(searchTerm) ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// 重置到第一页
|
||||
currentApiKeyPage = 1;
|
||||
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染当前页的API密钥
|
||||
*/
|
||||
function renderApiKeyPage() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeyContainer) return;
|
||||
|
||||
// 清空容器
|
||||
apiKeyContainer.innerHTML = "";
|
||||
|
||||
// 计算当前页的数据范围
|
||||
const startIndex = (currentApiKeyPage - 1) * API_KEYS_PER_PAGE;
|
||||
const endIndex = Math.min(startIndex + API_KEYS_PER_PAGE, filteredApiKeys.length);
|
||||
const pageKeys = filteredApiKeys.slice(startIndex, endIndex);
|
||||
|
||||
// 渲染当前页的密钥
|
||||
pageKeys.forEach((key) => {
|
||||
addArrayItemWithValue("API_KEYS", key);
|
||||
});
|
||||
|
||||
// 如果没有密钥,显示提示信息
|
||||
if (pageKeys.length === 0) {
|
||||
const emptyMessage = document.createElement("div");
|
||||
emptyMessage.className = "text-gray-500 text-sm italic text-center py-4";
|
||||
emptyMessage.textContent = filteredApiKeys.length === 0 ?
|
||||
(allApiKeys.length === 0 ? "暂无API密钥" : "未找到匹配的密钥") :
|
||||
"当前页无数据";
|
||||
apiKeyContainer.appendChild(emptyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分页控件
|
||||
*/
|
||||
function updateApiKeyPagination() {
|
||||
totalApiKeyPages = Math.max(1, Math.ceil(filteredApiKeys.length / API_KEYS_PER_PAGE));
|
||||
|
||||
// 确保当前页在有效范围内
|
||||
if (currentApiKeyPage > totalApiKeyPages) {
|
||||
currentApiKeyPage = totalApiKeyPages;
|
||||
}
|
||||
|
||||
const paginationContainer = document.getElementById("apiKeyPagination");
|
||||
if (!paginationContainer) return;
|
||||
|
||||
// 如果只有一页或没有数据,隐藏分页控件
|
||||
if (totalApiKeyPages <= 1) {
|
||||
paginationContainer.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
paginationContainer.style.display = "flex";
|
||||
|
||||
// 更新页码信息
|
||||
const pageInfo = document.getElementById("apiKeyPageInfo");
|
||||
if (pageInfo) {
|
||||
pageInfo.textContent = `第 ${currentApiKeyPage} 页,共 ${totalApiKeyPages} 页 (${filteredApiKeys.length} 个密钥)`;
|
||||
}
|
||||
|
||||
// 更新按钮状态
|
||||
const prevBtn = document.getElementById("apiKeyPrevBtn");
|
||||
const nextBtn = document.getElementById("apiKeyNextBtn");
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.disabled = currentApiKeyPage <= 1;
|
||||
prevBtn.className = currentApiKeyPage <= 1 ?
|
||||
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
|
||||
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = currentApiKeyPage >= totalApiKeyPages;
|
||||
nextBtn.className = currentApiKeyPage >= totalApiKeyPages ?
|
||||
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
|
||||
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定页
|
||||
*/
|
||||
function goToApiKeyPage(page) {
|
||||
if (page < 1 || page > totalApiKeyPages) return;
|
||||
|
||||
currentApiKeyPage = page;
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一页
|
||||
*/
|
||||
function prevApiKeyPage() {
|
||||
if (currentApiKeyPage > 1) {
|
||||
goToApiKeyPage(currentApiKeyPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一页
|
||||
*/
|
||||
function nextApiKeyPage() {
|
||||
if (currentApiKeyPage < totalApiKeyPages) {
|
||||
goToApiKeyPage(currentApiKeyPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the bulk deletion of API keys based on input from the modal.
|
||||
*/
|
||||
function handleBulkDeleteApiKeys() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!bulkDeleteApiKeyInput || !apiKeyContainer || !bulkDeleteApiKeyModal)
|
||||
return;
|
||||
if (!bulkDeleteApiKeyInput || !bulkDeleteApiKeyModal) return;
|
||||
|
||||
const bulkText = bulkDeleteApiKeyInput.value;
|
||||
if (!bulkText.trim()) {
|
||||
@@ -1149,24 +1294,30 @@ function handleBulkDeleteApiKeys() {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
|
||||
// 从allApiKeys数组中删除匹配的密钥
|
||||
let deleteCount = 0;
|
||||
|
||||
keyItems.forEach((item) => {
|
||||
const input = item.querySelector(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
const realValue =
|
||||
input &&
|
||||
(input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value")
|
||||
: input.value);
|
||||
if (realValue && keysToDelete.has(realValue)) {
|
||||
item.remove();
|
||||
allApiKeys = allApiKeys.filter(key => {
|
||||
if (keysToDelete.has(key)) {
|
||||
deleteCount++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 更新过滤后的数组
|
||||
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
|
||||
closeModal(bulkDeleteApiKeyModal);
|
||||
|
||||
if (deleteCount > 0) {
|
||||
@@ -1782,6 +1933,15 @@ function collectFormData() {
|
||||
const arrayContainers = document.querySelectorAll(".array-container");
|
||||
arrayContainers.forEach((container) => {
|
||||
const key = container.id.replace("_container", "");
|
||||
|
||||
// 特殊处理API_KEYS - 使用全局数组而不是DOM元素
|
||||
if (key === "API_KEYS") {
|
||||
formData[key] = allApiKeys.filter(
|
||||
(value) => value && value.trim() !== "" && value !== MASKED_VALUE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayInputs = container.querySelectorAll(`.${ARRAY_INPUT_CLASS}`);
|
||||
formData[key] = Array.from(arrayInputs)
|
||||
.map((input) => {
|
||||
|
||||
@@ -108,8 +108,14 @@ function initStatItemAnimations() {
|
||||
|
||||
// 获取指定类型区域内选中的密钥
|
||||
function getSelectedKeys(type) {
|
||||
let selectorRoot;
|
||||
if (type === 'attention') {
|
||||
selectorRoot = '#attentionKeysList';
|
||||
} else {
|
||||
selectorRoot = `#${type}Keys`;
|
||||
}
|
||||
const checkboxes = document.querySelectorAll(
|
||||
`#${type}Keys .key-checkbox:checked`
|
||||
`${selectorRoot} .key-checkbox:checked`
|
||||
);
|
||||
return Array.from(checkboxes).map((cb) => cb.value);
|
||||
}
|
||||
@@ -119,27 +125,27 @@ function updateBatchActions(type) {
|
||||
const selectedKeys = getSelectedKeys(type);
|
||||
const count = selectedKeys.length;
|
||||
const batchActionsDiv = document.getElementById(`${type}BatchActions`);
|
||||
if (!batchActionsDiv) return;
|
||||
const selectedCountSpan = document.getElementById(`${type}SelectedCount`);
|
||||
const buttons = batchActionsDiv.querySelectorAll("button");
|
||||
|
||||
if (count > 0) {
|
||||
batchActionsDiv.classList.remove("hidden");
|
||||
selectedCountSpan.textContent = count;
|
||||
if (selectedCountSpan) selectedCountSpan.textContent = count;
|
||||
buttons.forEach((button) => (button.disabled = false));
|
||||
} else {
|
||||
batchActionsDiv.classList.add("hidden");
|
||||
selectedCountSpan.textContent = "0";
|
||||
if (selectedCountSpan) selectedCountSpan.textContent = "0";
|
||||
buttons.forEach((button) => (button.disabled = true));
|
||||
}
|
||||
|
||||
// 更新全选复选框状态
|
||||
const selectAllCheckbox = document.getElementById(
|
||||
`selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`
|
||||
);
|
||||
const allCheckboxes = document.querySelectorAll(`#${type}Keys .key-checkbox`);
|
||||
const selectAllId = `selectAll${type.charAt(0).toUpperCase() + type.slice(1)}`;
|
||||
const selectAllCheckbox = document.getElementById(selectAllId);
|
||||
const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`;
|
||||
// 只有在有可见的 key 时才考虑全选状态
|
||||
const visibleCheckboxes = document.querySelectorAll(
|
||||
`#${type}Keys li:not([style*="display: none"]) .key-checkbox`
|
||||
`#${rootId} li:not([style*="display: none"]) .key-checkbox`
|
||||
);
|
||||
if (selectAllCheckbox && visibleCheckboxes.length > 0) {
|
||||
selectAllCheckbox.checked = count === visibleCheckboxes.length;
|
||||
@@ -153,29 +159,28 @@ function updateBatchActions(type) {
|
||||
|
||||
// 全选/取消全选指定类型的密钥
|
||||
function toggleSelectAll(type, isChecked) {
|
||||
const listElement = document.getElementById(`${type}Keys`);
|
||||
// Select checkboxes within LI elements that are NOT styled with display:none
|
||||
// This targets currently visible items based on filtering.
|
||||
const rootId = type === 'attention' ? 'attentionKeysList' : `${type}Keys`;
|
||||
const listElement = document.getElementById(rootId);
|
||||
if (!listElement) return;
|
||||
const visibleCheckboxes = listElement.querySelectorAll(
|
||||
`li:not([style*="display: none"]) .key-checkbox`
|
||||
);
|
||||
|
||||
visibleCheckboxes.forEach((checkbox) => {
|
||||
checkbox.checked = isChecked;
|
||||
const listItem = checkbox.closest("li[data-key]"); // Get the LI from the current DOM
|
||||
const listItem = checkbox.closest("li[data-key]");
|
||||
if (listItem) {
|
||||
listItem.classList.toggle("selected", isChecked);
|
||||
|
||||
// Sync with master array
|
||||
const key = listItem.dataset.key;
|
||||
const masterList = type === "valid" ? allValidKeys : allInvalidKeys;
|
||||
if (masterList) {
|
||||
// Ensure masterList is defined
|
||||
const masterListItem = masterList.find((li) => li.dataset.key === key);
|
||||
if (masterListItem) {
|
||||
const masterCheckbox = masterListItem.querySelector(".key-checkbox");
|
||||
if (masterCheckbox) {
|
||||
masterCheckbox.checked = isChecked;
|
||||
if (type !== 'attention') {
|
||||
const key = listItem.dataset.key;
|
||||
const masterList = type === "valid" ? allValidKeys : allInvalidKeys;
|
||||
if (masterList) {
|
||||
const masterListItem = masterList.find((li) => li.dataset.key === key);
|
||||
if (masterListItem) {
|
||||
const masterCheckbox = masterListItem.querySelector(".key-checkbox");
|
||||
if (masterCheckbox) {
|
||||
masterCheckbox.checked = isChecked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,7 +351,8 @@ function showResetModal(type) {
|
||||
// 设置确认按钮事件
|
||||
confirmButton.onclick = () => executeResetAll(type);
|
||||
|
||||
// 显示模态框
|
||||
// 显示模态框,确保位于最上层
|
||||
modalElement.style.zIndex = '1001';
|
||||
modalElement.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -1161,20 +1167,21 @@ function initializeKeySelectionListeners() {
|
||||
if (listItem) {
|
||||
listItem.classList.toggle("selected", checkbox.checked);
|
||||
|
||||
// Sync with master array
|
||||
const key = listItem.dataset.key;
|
||||
const masterList =
|
||||
keyType === "valid" ? allValidKeys : allInvalidKeys;
|
||||
if (masterList) {
|
||||
// Ensure masterList is defined
|
||||
const masterListItem = masterList.find(
|
||||
(li) => li.dataset.key === key
|
||||
);
|
||||
if (masterListItem) {
|
||||
const masterCheckbox =
|
||||
masterListItem.querySelector(".key-checkbox");
|
||||
if (masterCheckbox) {
|
||||
masterCheckbox.checked = checkbox.checked;
|
||||
// Sync with master array (only for valid/invalid lists)
|
||||
if (keyType !== 'attention') {
|
||||
const key = listItem.dataset.key;
|
||||
const masterList =
|
||||
keyType === "valid" ? allValidKeys : allInvalidKeys;
|
||||
if (masterList) {
|
||||
const masterListItem = masterList.find(
|
||||
(li) => li.dataset.key === key
|
||||
);
|
||||
if (masterListItem) {
|
||||
const masterCheckbox =
|
||||
masterListItem.querySelector(".key-checkbox");
|
||||
if (masterCheckbox) {
|
||||
masterCheckbox.checked = checkbox.checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1186,50 +1193,9 @@ function initializeKeySelectionListeners() {
|
||||
|
||||
setupEventListenersForList("validKeys", "valid");
|
||||
setupEventListenersForList("invalidKeys", "invalid");
|
||||
setupEventListenersForList("attentionKeysList", "attention");
|
||||
}
|
||||
|
||||
function initializeAutoRefreshControls() {
|
||||
const autoRefreshToggle = document.getElementById("autoRefreshToggle");
|
||||
const autoRefreshIntervalTime = 60000; // 60秒
|
||||
let autoRefreshTimer = null;
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (autoRefreshTimer) return;
|
||||
console.log("启动自动刷新...");
|
||||
showNotification("自动刷新已启动", "info", 2000);
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
console.log("自动刷新 keys_status 页面...");
|
||||
location.reload();
|
||||
}, autoRefreshIntervalTime);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (autoRefreshTimer) {
|
||||
console.log("停止自动刷新...");
|
||||
showNotification("自动刷新已停止", "info", 2000);
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (autoRefreshToggle) {
|
||||
const isAutoRefreshEnabled =
|
||||
localStorage.getItem("autoRefreshEnabled") === "true";
|
||||
autoRefreshToggle.checked = isAutoRefreshEnabled;
|
||||
if (isAutoRefreshEnabled) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
autoRefreshToggle.addEventListener("change", () => {
|
||||
if (autoRefreshToggle.checked) {
|
||||
localStorage.setItem("autoRefreshEnabled", "true");
|
||||
startAutoRefresh();
|
||||
} else {
|
||||
localStorage.setItem("autoRefreshEnabled", "false");
|
||||
stopAutoRefresh();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
function debounce(func, delay) {
|
||||
@@ -1478,6 +1444,261 @@ function initializeDropdownMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chart: API success/failure over time ---
|
||||
let apiStatsChart = null;
|
||||
|
||||
function buildChartConfig(labels, successData, failureData) {
|
||||
return {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: '成功',
|
||||
data: successData,
|
||||
borderColor: 'rgba(16,185,129,1)', // emerald-500
|
||||
backgroundColor: 'rgba(16,185,129,0.15)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 2,
|
||||
},
|
||||
{
|
||||
label: '失败',
|
||||
data: failureData,
|
||||
borderColor: 'rgba(239,68,68,1)', // red-500
|
||||
backgroundColor: 'rgba(239,68,68,0.15)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
pointRadius: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
tooltip: { mode: 'index', intersect: false },
|
||||
},
|
||||
interaction: { mode: 'nearest', axis: 'x', intersect: false },
|
||||
scales: {
|
||||
x: { title: { display: true, text: '时间' } },
|
||||
y: { title: { display: true, text: '调用次数' }, beginAtZero: true, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchPeriodDetails(period) {
|
||||
// Uses backend endpoint /api/stats/details?period={period}
|
||||
return await fetchAPI(`/api/stats/details?period=${period}`);
|
||||
}
|
||||
|
||||
function bucketizeDetails(period, details) {
|
||||
// details is expected to be an array of call records with fields: timestamp, status
|
||||
// Build buckets depending on period
|
||||
const buckets = new Map();
|
||||
const addToBucket = (key, isSuccess) => {
|
||||
if (!buckets.has(key)) buckets.set(key, { success: 0, failure: 0 });
|
||||
const obj = buckets.get(key);
|
||||
if (isSuccess) obj.success += 1; else obj.failure += 1;
|
||||
};
|
||||
|
||||
const toKey = (ts) => {
|
||||
const d = new Date(ts);
|
||||
if (period === '1m') {
|
||||
// bucket by second within last minute
|
||||
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||
const ss = String(d.getSeconds()).padStart(2,'0');
|
||||
return `${mm}:${ss}`;
|
||||
} else if (period === '1h') {
|
||||
// bucket by minute
|
||||
const HH = String(d.getHours()).padStart(2,'0');
|
||||
const mm = String(d.getMinutes()).padStart(2,'0');
|
||||
return `${HH}:${mm}`;
|
||||
} else if (period === '8h') {
|
||||
// bucket by hour for 8h window (same as 24h)
|
||||
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||
const DD = String(d.getDate()).padStart(2,'0');
|
||||
const HH = String(d.getHours()).padStart(2,'0');
|
||||
return `${MM}-${DD} ${HH}:00`;
|
||||
} else {
|
||||
// 24h: bucket by hour
|
||||
const MM = String(d.getMonth()+1).padStart(2,'0');
|
||||
const DD = String(d.getDate()).padStart(2,'0');
|
||||
const HH = String(d.getHours()).padStart(2,'0');
|
||||
return `${MM}-${DD} ${HH}:00`;
|
||||
}
|
||||
};
|
||||
|
||||
(details || []).forEach((call) => {
|
||||
const key = toKey(call.timestamp);
|
||||
const isSuccess = call.status === 'success';
|
||||
addToBucket(key, isSuccess);
|
||||
});
|
||||
|
||||
// sort labels chronologically by parsing back to date when possible
|
||||
const labels = Array.from(buckets.keys()).sort((a,b)=>{
|
||||
// Try to create date objects relative to today for ordering; fallback to string compare
|
||||
const da = Date.parse(a) || 0;
|
||||
const db = Date.parse(b) || 0;
|
||||
if (da && db) return da - db;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
const successData = labels.map(l => buckets.get(l).success);
|
||||
const failureData = labels.map(l => buckets.get(l).failure);
|
||||
return { labels, successData, failureData };
|
||||
}
|
||||
|
||||
async function renderApiChart(period) {
|
||||
const canvas = document.getElementById('apiStatsChart');
|
||||
if (!canvas || typeof Chart === 'undefined') return;
|
||||
try {
|
||||
const details = await fetchPeriodDetails(period);
|
||||
const { labels, successData, failureData } = bucketizeDetails(period, details || []);
|
||||
const cfg = buildChartConfig(labels, successData, failureData);
|
||||
if (apiStatsChart) {
|
||||
apiStatsChart.destroy();
|
||||
}
|
||||
apiStatsChart = new Chart(canvas.getContext('2d'), cfg);
|
||||
} catch (e) {
|
||||
console.error('Failed to render chart:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers for Attention Keys panel ---
|
||||
// track current active status code tab
|
||||
let currentStatus = 429;
|
||||
|
||||
function getLimit() {
|
||||
const el = document.getElementById('attentionLimitInput');
|
||||
const v = parseInt(el && el.value, 10);
|
||||
if (isNaN(v)) return 10;
|
||||
// clamp between 1 and 1000 to match input limits
|
||||
return Math.min(1000, Math.max(1, v));
|
||||
}
|
||||
|
||||
async function fetchAndRenderAttentionKeys(statusCode = 429, limit = 10) {
|
||||
const listEl = document.getElementById('attentionKeysList');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const data = await fetchAPI(`/api/stats/attention-keys?status_code=${statusCode}&limit=${limit}`);
|
||||
listEl.innerHTML = '';
|
||||
if (!data || (Array.isArray(data) && data.length === 0) || data.error) {
|
||||
listEl.innerHTML = '<li class="text-center text-gray-500 py-2">暂无需要注意的Key</li>';
|
||||
updateBatchActions('attention');
|
||||
return;
|
||||
}
|
||||
data.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'flex items-center justify-between bg-white rounded border px-3 py-2';
|
||||
li.dataset.key = item.key || '';
|
||||
const masked = item.key ? `${item.key.substring(0,4)}...${item.key.substring(item.key.length-4)}` : 'N/A';
|
||||
const code = item.status_code ?? statusCode;
|
||||
li.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
<input type="checkbox" class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded key-checkbox" value="${item.key || ''}">
|
||||
<span class="font-mono text-sm">${masked}</span>
|
||||
<span class="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded">${code}: ${item.count}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="px-2 py-1 text-xs rounded bg-success-600 hover:bg-success-700 text-white" title="验证此Key">验证</button>
|
||||
<button class="px-2 py-1 text-xs rounded bg-blue-600 hover:bg-blue-700 text-white" title="查看24小时详情">详情</button>
|
||||
<button class="px-2 py-1 text-xs rounded bg-blue-500 hover:bg-blue-600 text-white" title="复制Key">复制</button>
|
||||
<button class="px-2 py-1 text-xs rounded bg-red-800 hover:bg-red-900 text-white" title="删除此Key">删除</button>
|
||||
</div>`;
|
||||
const [verifyBtn, detailBtn, copyBtn, deleteBtn] = li.querySelectorAll('button');
|
||||
verifyBtn.addEventListener('click', (e) => { e.stopPropagation(); verifyKey(item.key, e.currentTarget); });
|
||||
detailBtn.addEventListener('click', (e) => { e.stopPropagation(); window.showKeyUsageDetails(item.key); });
|
||||
copyBtn.addEventListener('click', (e) => { e.stopPropagation(); copyKey(item.key); });
|
||||
deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); showSingleKeyDeleteConfirmModal(item.key, e.currentTarget); });
|
||||
// Checkbox change updates batch actions
|
||||
const checkbox = li.querySelector('.key-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('change', () => updateBatchActions('attention'));
|
||||
}
|
||||
listEl.appendChild(li);
|
||||
});
|
||||
updateBatchActions('attention');
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<li class="text-center text-red-500 py-2">加载失败: ${e.message}</li>`;
|
||||
updateBatchActions('attention');
|
||||
}
|
||||
}
|
||||
|
||||
function initChartControls() {
|
||||
const btn1h = document.getElementById('chartBtn1h');
|
||||
const btn8h = document.getElementById('chartBtn8h');
|
||||
const btn24h = document.getElementById('chartBtn24h');
|
||||
const setActive = (activeBtn) => {
|
||||
[btn1h, btn8h, btn24h].forEach(btn => {
|
||||
if (!btn) return;
|
||||
if (btn === activeBtn) {
|
||||
btn.classList.remove('bg-gray-200');
|
||||
btn.classList.add('bg-primary-600','text-white');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-200');
|
||||
btn.classList.remove('bg-primary-600','text-white');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (btn1h) btn1h.addEventListener('click', async () => { setActive(btn1h); await renderApiChart('1h'); });
|
||||
if (btn8h) btn8h.addEventListener('click', async () => { setActive(btn8h); await renderApiChart('8h'); });
|
||||
if (btn24h) btn24h.addEventListener('click', async () => { setActive(btn24h); await renderApiChart('24h'); });
|
||||
|
||||
// default period
|
||||
if (btn1h) setActive(btn1h);
|
||||
renderApiChart('1h');
|
||||
}
|
||||
|
||||
function initAttentionKeysControls() {
|
||||
const btn429 = document.getElementById('attentionErr429');
|
||||
const btn403 = document.getElementById('attentionErr403');
|
||||
const btn400 = document.getElementById('attentionErr400');
|
||||
// 修复:补充获取数量输入框,避免未声明变量导致初始化报错
|
||||
const limitInput = document.getElementById('attentionLimitInput');
|
||||
const setActive = (activeBtn) => {
|
||||
[btn429, btn403, btn400].forEach(btn => {
|
||||
if (!btn) return;
|
||||
if (btn === activeBtn) {
|
||||
btn.classList.remove('bg-gray-200');
|
||||
btn.classList.add('bg-primary-600','text-white');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-200');
|
||||
btn.classList.remove('bg-primary-600','text-white');
|
||||
}
|
||||
});
|
||||
};
|
||||
if (btn429) btn429.addEventListener('click', () => { setActive(btn429); currentStatus = 429; fetchAndRenderAttentionKeys(429, getLimit()); });
|
||||
if (btn403) btn403.addEventListener('click', () => { setActive(btn403); currentStatus = 403; fetchAndRenderAttentionKeys(403, getLimit()); });
|
||||
if (btn400) btn400.addEventListener('click', () => { setActive(btn400); currentStatus = 400; fetchAndRenderAttentionKeys(400, getLimit()); });
|
||||
// 自定义查询
|
||||
const input = document.getElementById('attentionErrCustom');
|
||||
const go = document.getElementById('attentionErrGo');
|
||||
const trigger = () => {
|
||||
if (!input) return;
|
||||
const val = parseInt(input.value, 10);
|
||||
if (!isNaN(val) && val >= 100 && val <= 599) {
|
||||
setActive(null);
|
||||
[btn429, btn403, btn400].forEach(btn=>{ if(btn){ btn.classList.add('bg-gray-200'); btn.classList.remove('bg-primary-600','text-white'); }});
|
||||
currentStatus = val;
|
||||
fetchAndRenderAttentionKeys(val, getLimit());
|
||||
} else {
|
||||
showNotification('请输入100-599之间的HTTP状态码', 'warning');
|
||||
}
|
||||
};
|
||||
if (go) go.addEventListener('click', trigger);
|
||||
if (input) input.addEventListener('keydown', (e)=>{ if(e.key==='Enter'){ trigger(); }});
|
||||
|
||||
// limit变化实时刷新当前状态码
|
||||
if (limitInput) limitInput.addEventListener('change', () => {
|
||||
fetchAndRenderAttentionKeys(currentStatus, getLimit());
|
||||
});
|
||||
|
||||
if (btn429) setActive(btn429); // default active
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initializePageAnimationsAndEffects();
|
||||
@@ -1485,10 +1706,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initializeKeyFilterControls();
|
||||
initializeGlobalBatchVerificationHandlers();
|
||||
initializeKeySelectionListeners();
|
||||
initializeAutoRefreshControls();
|
||||
initializeKeyPaginationAndSearch(); // This will also handle initial display
|
||||
registerServiceWorker();
|
||||
initializeDropdownMenu(); // 初始化下拉菜单
|
||||
initChartControls(); // 初始化图表与时间区间切换
|
||||
initAttentionKeysControls(); // 初始化值得注意的Key错误码切换
|
||||
fetchAndRenderAttentionKeys(429, 10); // 默认渲染429,数量10
|
||||
|
||||
// Initial batch actions update might be needed if not covered by displayPage
|
||||
// updateBatchActions('valid');
|
||||
@@ -1744,6 +1967,82 @@ async function showApiCallDetails(
|
||||
}
|
||||
}
|
||||
|
||||
// 获取并显示错误日志详情(通过日志ID)
|
||||
async function fetchAndShowErrorDetail(logId) {
|
||||
try {
|
||||
const detail = await fetchAPI(`/api/logs/errors/${logId}/details`);
|
||||
if (!detail) {
|
||||
showResultModal(false, `未找到日志 ${logId}`, false);
|
||||
return;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
container.className = 'space-y-3 text-sm';
|
||||
const basic = document.createElement('div');
|
||||
basic.innerHTML = `
|
||||
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
|
||||
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
|
||||
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
|
||||
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
|
||||
`;
|
||||
const codeBlock = document.createElement('pre');
|
||||
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
|
||||
codeBlock.textContent = detail.error_log || '无错误日志内容';
|
||||
const reqBlock = document.createElement('pre');
|
||||
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
|
||||
reqBlock.textContent = detail.request_msg || '';
|
||||
container.appendChild(basic);
|
||||
container.appendChild(codeBlock);
|
||||
if (detail.request_msg) container.appendChild(reqBlock);
|
||||
showResultModal(false, container, false);
|
||||
} catch (e) {
|
||||
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 新增:根据 key / 状态码 / 时间窗口(±100秒) 查询并显示错误日志详情
|
||||
async function fetchAndShowErrorDetailByInfo(geminiKey, statusCode, timestampISO) {
|
||||
try {
|
||||
if (!geminiKey || !timestampISO) {
|
||||
showResultModal(false, '缺少必要参数,无法查询错误详情', false);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('gemini_key', geminiKey);
|
||||
params.set('timestamp', timestampISO);
|
||||
if (statusCode !== null && statusCode !== undefined) {
|
||||
params.set('status_code', String(statusCode));
|
||||
}
|
||||
params.set('window_seconds', '100');
|
||||
const detail = await fetchAPI(`/api/logs/errors/lookup?${params.toString()}`);
|
||||
if (!detail) {
|
||||
showResultModal(false, '未找到匹配的错误日志', false);
|
||||
return;
|
||||
}
|
||||
const container = document.createElement('div');
|
||||
container.className = 'space-y-3 text-sm';
|
||||
const basic = document.createElement('div');
|
||||
basic.innerHTML = `
|
||||
<div><span class="font-semibold">Key:</span> ${detail.gemini_key ? detail.gemini_key.substring(0,4)+'...'+detail.gemini_key.slice(-4) : 'N/A'}</div>
|
||||
<div><span class="font-semibold">模型:</span> ${detail.model_name || 'N/A'}</div>
|
||||
<div><span class="font-semibold">时间:</span> ${detail.request_time ? new Date(detail.request_time).toLocaleString() : 'N/A'}</div>
|
||||
<div><span class="font-semibold">错误码:</span> ${detail.error_code ?? 'N/A'}</div>
|
||||
<div><span class="font-semibold">错误类型:</span> ${detail.error_type || 'N/A'}</div>
|
||||
`;
|
||||
const codeBlock = document.createElement('pre');
|
||||
codeBlock.className = 'bg-red-50 border border-red-200 rounded p-3 whitespace-pre-wrap break-words text-red-700';
|
||||
codeBlock.textContent = detail.error_log || '无错误日志内容';
|
||||
const reqBlock = document.createElement('pre');
|
||||
reqBlock.className = 'bg-gray-50 border border-gray-200 rounded p-3 whitespace-pre-wrap break-words';
|
||||
reqBlock.textContent = detail.request_msg || '';
|
||||
container.appendChild(basic);
|
||||
container.appendChild(codeBlock);
|
||||
if (detail.request_msg) container.appendChild(reqBlock);
|
||||
showResultModal(false, container, false);
|
||||
} catch (e) {
|
||||
showResultModal(false, `加载日志详情失败: ${e.message}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭 API 调用详情模态框
|
||||
function closeApiCallDetailsModal() {
|
||||
const modal = document.getElementById("apiCallDetailsModal");
|
||||
@@ -1767,23 +2066,33 @@ function renderApiCallDetails(
|
||||
successCalls !== undefined &&
|
||||
failureCalls !== undefined
|
||||
) {
|
||||
const total = Number(totalCalls) || 0;
|
||||
const succ = Number(successCalls) || 0;
|
||||
const fail = Number(failureCalls) || 0;
|
||||
const denom = total > 0 ? total : succ + fail;
|
||||
const succRate = denom > 0 ? ((succ / denom) * 100).toFixed(1) : '0.0';
|
||||
const failRate = denom > 0 ? ((fail / denom) * 100).toFixed(1) : '0.0';
|
||||
|
||||
summaryHtml = `
|
||||
<div class="mb-4 p-3 bg-white dark:bg-gray-700 rounded-lg">
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-200 mb-2 text-md border-b pb-1.5 dark:border-gray-600">期间调用概览:</h4>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">总计</p>
|
||||
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">${totalCalls}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">成功</p>
|
||||
<p class="text-lg font-bold text-success-600 dark:text-success-400">${successCalls}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">失败</p>
|
||||
<p class="text-lg font-bold text-danger-600 dark:text-danger-400">${failureCalls}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 rounded-lg overflow-hidden">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">总计</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">失败</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">成功率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800">
|
||||
<tr>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-primary-600 dark:text-primary-400">${totalCalls}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${successCalls}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-danger-600 dark:text-danger-400">${failureCalls}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm font-bold text-success-600 dark:text-success-400">${succRate}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1807,7 +2116,10 @@ function renderApiCallDetails(
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">时间</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">密钥 (部分)</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">模型</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态码</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">耗时(ms)</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">状态</th>
|
||||
<th scope="col" class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -1828,17 +2140,25 @@ function renderApiCallDetails(
|
||||
const statusIcon =
|
||||
call.status === "success" ? "fa-check-circle" : "fa-times-circle";
|
||||
|
||||
const detailsBtn =
|
||||
call.status === "failure"
|
||||
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${call.key}', ${call.status_code ?? 'null'}, '${call.timestamp}')">
|
||||
<i class="fas fa-info-circle mr-1"></i>详情
|
||||
</button>`
|
||||
: "-";
|
||||
|
||||
tableHtml += `
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">${timestamp}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">${keyDisplay}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${
|
||||
call.model || "N/A"
|
||||
}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.model || "N/A"}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.status_code ?? "-"}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">${call.latency_ms ?? "-"}</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm ${statusClass}">
|
||||
<i class="fas ${statusIcon} mr-1"></i>
|
||||
${call.status}
|
||||
</td>
|
||||
<td class="px-4 py-2 whitespace-nowrap text-sm">${detailsBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
@@ -1867,67 +2187,122 @@ window.showKeyUsageDetails = async function (key) {
|
||||
return;
|
||||
}
|
||||
|
||||
// renderKeyUsageDetails 变为 showKeyUsageDetails 的局部函数
|
||||
function renderKeyUsageDetails(data, container) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
// 构建内容框架(时间范围按钮 + 图表 + 表格容器)
|
||||
const controlsHtml = `
|
||||
<div class="flex items-center gap-2 mb-3 text-xs">
|
||||
<button id="keyBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
||||
<button id="keyBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
|
||||
<button id="keyBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
||||
</div>
|
||||
<div class="h-48 mb-4">
|
||||
<canvas id="keyUsageChart"></canvas>
|
||||
</div>
|
||||
<div id="keyUsageTable"></div>`;
|
||||
contentArea.innerHTML = controlsHtml;
|
||||
|
||||
// 设置标题
|
||||
titleElement.textContent = `密钥 ${keyDisplay} - 请求详情`;
|
||||
|
||||
// 显示模态框
|
||||
modal.classList.remove("hidden");
|
||||
|
||||
let keyUsageChart = null;
|
||||
function buildKeyChartConfig(labels, successData, failureData) {
|
||||
return buildChartConfig(labels, successData, failureData);
|
||||
}
|
||||
function bucketizeKeyDetails(period, details) {
|
||||
return bucketizeDetails(period, details);
|
||||
}
|
||||
function renderKeyUsageTable(data) {
|
||||
const container = document.getElementById('keyUsageTable');
|
||||
if (!container) return;
|
||||
if (!data || data.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<i class="fas fa-info-circle text-3xl"></i>
|
||||
<p class="mt-2">该密钥在最近24小时内没有调用记录。</p>
|
||||
<p class="mt-2">该时间段内没有 API 调用记录。</p>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
let tableHtml = `
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型名称</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">调用次数 (24h)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">时间</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">模型</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态码</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">耗时(ms)</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">`;
|
||||
const sortedModels = Object.entries(data).sort(
|
||||
([, countA], [, countB]) => countB - countA
|
||||
);
|
||||
sortedModels.forEach(([model, count]) => {
|
||||
data.forEach((row) => {
|
||||
const timestamp = new Date(row.timestamp).toLocaleString();
|
||||
const statusClass = row.status === 'success' ? 'text-success-600' : 'text-danger-600';
|
||||
const statusIcon = row.status === 'success' ? 'fa-check-circle' : 'fa-times-circle';
|
||||
const detailsBtn = row.status === 'failure'
|
||||
? `<button class="px-2 py-1 rounded bg-red-100 hover:bg-red-200 text-red-700 text-xs" onclick="fetchAndShowErrorDetailByInfo('${row.key}', ${row.status_code ?? 'null'}, '${row.timestamp}')">
|
||||
<i class="fas fa-info-circle mr-1"></i>详情
|
||||
</button>`
|
||||
: '-';
|
||||
tableHtml += `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${model}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${count}</td>
|
||||
</tr>`;
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">${timestamp}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.model || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.status_code ?? '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${row.latency_ms ?? '-'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm ${statusClass}"><i class="fas ${statusIcon} mr-1"></i>${row.status}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">${detailsBtn}</td>
|
||||
</tr>`;
|
||||
});
|
||||
tableHtml += `
|
||||
</tbody>
|
||||
</table>`;
|
||||
tableHtml += `</tbody></table>`;
|
||||
container.innerHTML = tableHtml;
|
||||
}
|
||||
|
||||
// 设置标题
|
||||
titleElement.textContent = `密钥 ${keyDisplay} - 最近24小时请求详情`;
|
||||
|
||||
// 显示模态框并设置加载状态
|
||||
modal.classList.remove("hidden");
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10">
|
||||
<i class="fas fa-spinner fa-spin text-primary-600 text-3xl"></i>
|
||||
<p class="text-gray-500 mt-2">加载中...</p>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const data = await fetchAPI(`/api/key-usage-details/${key}`);
|
||||
if (data) {
|
||||
renderKeyUsageDetails(data, contentArea);
|
||||
} else {
|
||||
renderKeyUsageDetails({}, contentArea); // Show empty state if no data
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("获取密钥使用详情失败:", apiError);
|
||||
contentArea.innerHTML = `
|
||||
<div class="text-center py-10 text-danger-500">
|
||||
async function renderForPeriod(period) {
|
||||
try {
|
||||
const details = await fetchAPI(`/api/stats/key-details?key=${encodeURIComponent(key)}&period=${period}`);
|
||||
const { labels, successData, failureData } = bucketizeKeyDetails(period, details || []);
|
||||
const canvas = document.getElementById('keyUsageChart');
|
||||
if (canvas && typeof Chart !== 'undefined') {
|
||||
const cfg = buildKeyChartConfig(labels, successData, failureData);
|
||||
if (keyUsageChart) keyUsageChart.destroy();
|
||||
keyUsageChart = new Chart(canvas.getContext('2d'), cfg);
|
||||
}
|
||||
renderKeyUsageTable(details || []);
|
||||
} catch (e) {
|
||||
console.error('加载密钥期内详情失败:', e);
|
||||
const tableContainer = document.getElementById('keyUsageTable');
|
||||
if (tableContainer) {
|
||||
tableContainer.innerHTML = `<div class="text-center py-10 text-danger-500">
|
||||
<i class="fas fa-exclamation-triangle text-3xl"></i>
|
||||
<p class="mt-2">加载失败: ${apiError.message}</p>
|
||||
<p class="mt-2">加载失败: ${e.message}</p>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 绑定按钮事件与默认加载
|
||||
const btn1h = document.getElementById('keyBtn1h');
|
||||
const btn8h = document.getElementById('keyBtn8h');
|
||||
const btn24h = document.getElementById('keyBtn24h');
|
||||
const setActive = (activeBtn) => {
|
||||
[btn1h, btn8h, btn24h].forEach((btn) => {
|
||||
if (!btn) return;
|
||||
if (btn === activeBtn) {
|
||||
btn.classList.remove('bg-gray-200');
|
||||
btn.classList.add('bg-primary-600','text-white');
|
||||
} else {
|
||||
btn.classList.add('bg-gray-200');
|
||||
btn.classList.remove('bg-primary-600','text-white');
|
||||
}
|
||||
});
|
||||
};
|
||||
if (btn1h) btn1h.addEventListener('click', () => { setActive(btn1h); renderForPeriod('1h'); });
|
||||
if (btn8h) btn8h.addEventListener('click', () => { setActive(btn8h); renderForPeriod('8h'); });
|
||||
if (btn24h) btn24h.addEventListener('click', () => { setActive(btn24h); renderForPeriod('24h'); });
|
||||
if (btn1h) setActive(btn1h);
|
||||
renderForPeriod('1h');
|
||||
};
|
||||
|
||||
// 关闭密钥使用详情模态框
|
||||
|
||||
83
app/static/js/tailwindcss.js
Normal file
83
app/static/js/tailwindcss.js
Normal file
File diff suppressed because one or more lines are too long
@@ -51,7 +51,7 @@
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-extrabold text-center text-gray-800 mb-8 animate-slide-down">
|
||||
<img src="/static/icons/logo.png" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
<img src="{{ static_url('icons/logo.png') }}" alt="Gemini Balance Logo" class="h-9 inline-block align-middle mr-2">
|
||||
Gemini Balance
|
||||
</h2>
|
||||
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Gemini Balance{% endblock %}</title>
|
||||
<link rel="manifest" href="/static/manifest.json" />
|
||||
<link rel="manifest" href="{{ static_url('manifest.json') }}" />
|
||||
<meta name="theme-color" content="#4F46E5" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<meta name="apple-mobile-web-app-title" content="GBalance" />
|
||||
<link rel="icon" href="/static/icons/icon-192x192.png" />
|
||||
<link rel="icon" href="{{ static_url('icons/icon-192x192.png') }}" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
href="{{ static_url('css/fonts.css') }}"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="{{ static_url('js/tailwindcss.js') }}"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,179 @@ endblock %} {% block head_extra_styles %}
|
||||
.search-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 移动端主容器布局 */
|
||||
.mobile-buttons-container {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 1rem !important;
|
||||
align-items: stretch !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 移动端搜索控件布局优化 */
|
||||
.mobile-search-controls {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 0.75rem !important;
|
||||
width: 100% !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* 按钮容器在移动端的布局 */
|
||||
.buttons-container-responsive {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
gap: 0.5rem !important;
|
||||
width: 100% !important;
|
||||
align-items: stretch !important;
|
||||
justify-content: stretch !important;
|
||||
}
|
||||
|
||||
/* 移动端所有按钮样式 */
|
||||
.buttons-container-responsive button {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
justify-content: center !important;
|
||||
text-align: center !important;
|
||||
min-width: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕优化 */
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.buttons-container-responsive {
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.buttons-container-responsive button {
|
||||
flex-shrink: 1 !important;
|
||||
min-width: 0 !important;
|
||||
padding-left: 0.75rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕(手机)特殊优化 - 确保按钮在边框内 */
|
||||
@media (max-width: 640px) {
|
||||
/* 强制重写主容器布局 */
|
||||
.mobile-buttons-container {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
gap: 1rem !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 搜索区域在移动端占满宽度 */
|
||||
.mobile-search-controls {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 按钮区域完全重新布局 */
|
||||
.buttons-container-responsive {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
gap: 0.5rem !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* 所有按钮统一样式 */
|
||||
.buttons-container-responsive button {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
margin: 0 !important;
|
||||
font-size: 0.875rem !important;
|
||||
line-height: 1.25rem !important;
|
||||
border-radius: 0.5rem !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* 特别针对清空全部按钮 */
|
||||
#deleteAllLogsBtn {
|
||||
background-color: #f87171 !important;
|
||||
border: 1px solid #f87171 !important;
|
||||
}
|
||||
|
||||
#deleteAllLogsBtn:hover {
|
||||
background-color: #ef4444 !important;
|
||||
border: 1px solid #ef4444 !important;
|
||||
}
|
||||
|
||||
/* 确保容器不会溢出父级 */
|
||||
.mobile-buttons-container,
|
||||
.mobile-buttons-container > *,
|
||||
.buttons-container-responsive,
|
||||
.buttons-container-responsive > * {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 额外的安全边距控制 */
|
||||
.mobile-buttons-container .grid {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 确保主内容区域有适当的内边距 */
|
||||
.rounded-xl.p-6 {
|
||||
padding-left: 1rem !important;
|
||||
padding-right: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 超小屏幕额外优化 */
|
||||
@media (max-width: 480px) {
|
||||
.mobile-buttons-container {
|
||||
gap: 0.75rem !important;
|
||||
}
|
||||
|
||||
.buttons-container-responsive {
|
||||
gap: 0.4rem !important;
|
||||
}
|
||||
|
||||
.buttons-container-responsive button {
|
||||
padding: 0.4rem 0.8rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
/* 主容器内边距进一步缩小 */
|
||||
.rounded-xl.p-6 {
|
||||
padding-left: 0.75rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* 确保清空全部按钮文字不会太挤 */
|
||||
#deleteAllLogsBtn i {
|
||||
margin-right: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
@@ -586,7 +759,7 @@ endblock %} {% block head_extra_styles %}
|
||||
class="text-3xl font-extrabold text-center text-gray-800 mb-4"
|
||||
>
|
||||
<img
|
||||
src="/static/icons/logo.png"
|
||||
src="{{ static_url('icons/logo.png') }}"
|
||||
alt="Gemini Balance Logo"
|
||||
class="h-9 inline-block align-middle mr-2"
|
||||
/>
|
||||
@@ -636,10 +809,10 @@ endblock %} {% block head_extra_styles %}
|
||||
|
||||
<!-- 搜索与操作控件 -->
|
||||
<div
|
||||
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6"
|
||||
class="grid grid-cols-1 lg:grid-cols-[1fr_auto] items-center gap-4 mb-6 mobile-buttons-container"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full"
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 w-full mobile-search-controls"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
@@ -684,7 +857,7 @@ endblock %} {% block head_extra_styles %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0">
|
||||
<div class="flex items-center gap-3 flex-shrink-0 buttons-container-responsive">
|
||||
<button
|
||||
id="searchBtn"
|
||||
class="flex items-center justify-center px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-all duration-200 shadow-sm hover:shadow-md whitespace-nowrap"
|
||||
@@ -1041,7 +1214,7 @@ endblock %} {% block head_extra_styles %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block body_scripts %}
|
||||
<script src="/static/js/error_logs.js"></script>
|
||||
<script src="{{ static_url('js/error_logs.js') }}"></script>
|
||||
<script>
|
||||
// error_logs.html specific JS initialization (if any)
|
||||
// e.g., initialize date pickers or other elements if needed
|
||||
|
||||
@@ -38,6 +38,18 @@ endblock %} {% block head_extra_styles %}
|
||||
}
|
||||
}
|
||||
|
||||
/* 让图表卡片在网格中占满整行 */
|
||||
.stats-card.chart-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
/* 图表容器固定高度,配合 Chart.js maintainAspectRatio:false */
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.chart-container { height: 220px; }
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-card {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
@@ -310,12 +322,13 @@ endblock %} {% block head_extra_styles %}
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* 隐藏原生复选框 */
|
||||
.key-checkbox {
|
||||
/* 隐藏原生复选框(仅隐藏有效/无效列表中的复选框,保留值得注意的Key列表中的复选框可见) */
|
||||
#validKeys .key-checkbox,
|
||||
#invalidKeys .key-checkbox {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 自定义复选框样式 */
|
||||
/* 自定义复选框样式(仅针对有效/无效列表) */
|
||||
#validKeys li::before,
|
||||
#invalidKeys li::before {
|
||||
content: "";
|
||||
@@ -351,6 +364,31 @@ endblock %} {% block head_extra_styles %}
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* 值得注意的Key列表样式与选中态(保留原生复选框可见) */
|
||||
#attentionKeysList li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
#attentionKeysList li:hover {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
background-color: rgba(249, 250, 251, 0.95);
|
||||
}
|
||||
#attentionKeysList li.selected {
|
||||
background-color: rgba(239, 246, 255, 0.95); /* light blue */
|
||||
border-color: rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
#attentionKeysList .key-checkbox {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.key-text {
|
||||
color: #374151 !important; /* gray-700 for light theme */
|
||||
text-shadow: none;
|
||||
@@ -1096,31 +1134,15 @@ endblock %} {% block head_extra_styles %}
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block head_extra_scripts %}
|
||||
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
|
||||
<script src="/static/js/keys_status.js"></script>
|
||||
<!-- Chart.js for time-series chart -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" defer></script>
|
||||
<!-- Load page script with defer to guarantee DOM is ready and keep execution order -->
|
||||
<script src="/static/js/keys_status.js" defer></script>
|
||||
{% endblock %} {% block content %}
|
||||
<div class="container max-w-6xl mx-auto px-4">
|
||||
<!-- Increased max-width -->
|
||||
<div class="glass-card rounded-2xl shadow-xl p-6 md:p-8">
|
||||
<div class="absolute top-6 right-6 flex items-center gap-3">
|
||||
<!-- 自动刷新开关 -->
|
||||
<div class="flex items-center text-sm select-none font-semibold" style="color: #1f2937 !important;">
|
||||
<span class="mr-2">自动刷新</span>
|
||||
<div
|
||||
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="autoRefreshToggle"
|
||||
id="autoRefreshToggle"
|
||||
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
for="autoRefreshToggle"
|
||||
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
|
||||
></label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 手动刷新按钮 -->
|
||||
<button
|
||||
class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300"
|
||||
@@ -1263,7 +1285,94 @@ endblock %} {% block head_extra_styles %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 可切换时间区间的成功/失败图表卡片 -->
|
||||
<div class="stats-card chart-wide">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-chart-bar"></i>
|
||||
<span>调用趋势图</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button id="chartBtn1h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">1小时</button>
|
||||
<button id="chartBtn8h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">8小时</button>
|
||||
<button id="chartBtn24h" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700">24小时</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 chart-container">
|
||||
<canvas id="apiStatsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 值得注意的 Key 卡片(错误码统计,可切换) -->
|
||||
<div class="stats-card chart-wide">
|
||||
<div class="stats-card-header">
|
||||
<h3 class="stats-card-title">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<span>值得注意的Key(24h内错误码最多)</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button id="attentionErr429" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="429 Too Many Requests">429</button>
|
||||
<button id="attentionErr403" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="403 Forbidden">403</button>
|
||||
<button id="attentionErr400" class="px-2 py-1 rounded bg-gray-200 hover:bg-gray-300 text-gray-700" title="400 Bad Request">400</button>
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<input id="attentionErrCustom" type="number" min="100" max="599" placeholder="自定义" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
|
||||
<button id="attentionErrGo" class="px-2 py-1 rounded bg-blue-500 hover:bg-blue-600 text-white" title="查询">查询</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-3">
|
||||
<label for="attentionLimitInput" class="text-xs text-gray-600">数量</label>
|
||||
<input id="attentionLimitInput" type="number" min="1" max="1000" value="10" class="form-input h-7 w-20 px-2 py-1 text-xs border rounded focus:ring-primary-500 focus:border-primary-500" />
|
||||
<!-- 全选移动到数量输入框右侧 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="selectAllAttention"
|
||||
class="form-checkbox h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-primary-500"
|
||||
onchange="toggleSelectAll('attention', this.checked)"
|
||||
/>
|
||||
<label for="selectAllAttention" class="text-xs select-none whitespace-nowrap font-semibold" style="color: #1f2937 !important;">全选</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<!-- 批量操作按钮组 (仅在选中时显示) -->
|
||||
<div
|
||||
id="attentionBatchActions"
|
||||
class="p-3 border mb-3 hidden flex items-center flex-wrap gap-3"
|
||||
style="background-color: rgba(249, 250, 251, 0.95); border-color: rgba(0, 0, 0, 0.08);"
|
||||
>
|
||||
<span class="text-sm font-semibold whitespace-nowrap" style="color: #1f2937 !important;">
|
||||
已选择 <span id="attentionSelectedCount">0</span> 项
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-success-600 hover:bg-success-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
|
||||
onclick="event.stopPropagation(); showVerifyModal('attention', event)"
|
||||
disabled
|
||||
>
|
||||
<i class="fas fa-check-double"></i> 批量验证
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
|
||||
onclick="event.stopPropagation(); copySelectedKeys('attention')"
|
||||
disabled
|
||||
>
|
||||
<i class="fas fa-copy"></i> 批量复制
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200 disabled:cursor-not-allowed"
|
||||
onclick="event.stopPropagation(); showDeleteConfirmationModal('attention', event)"
|
||||
disabled
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i> 批量删除
|
||||
</button>
|
||||
</div>
|
||||
<ul id="attentionKeysList" class="space-y-2">
|
||||
<li class="text-center text-gray-500 py-2">加载中...</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 有效密钥区域 -->
|
||||
<div class="stats-card mb-6 animate-fade-in" style="animation-delay: 0.2s">
|
||||
@@ -1873,7 +1982,8 @@ endblock %} {% block head_extra_styles %}
|
||||
<!-- 操作结果模态框 -->
|
||||
<div
|
||||
id="resultModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center hidden"
|
||||
style="z-index: 1001;"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-2xl p-0 shadow-2xl max-w-lg w-full animate-fade-in border border-gray-200"
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""
|
||||
通用工具函数模块
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import base64
|
||||
import requests
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from app.config.config import Settings
|
||||
from app.core.constants import DATA_URL_PATTERN, IMAGE_URL_PATTERN, VALID_IMAGE_RATIOS
|
||||
|
||||
helper_logger = logging.getLogger("app.utils")
|
||||
@@ -20,23 +23,25 @@ VERSION_FILE_PATH = PROJECT_ROOT / "VERSION"
|
||||
def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
||||
"""
|
||||
从 base64 字符串中提取 MIME 类型和数据
|
||||
|
||||
|
||||
Args:
|
||||
base64_string: 可能包含 MIME 类型信息的 base64 字符串
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (mime_type, encoded_data)
|
||||
"""
|
||||
# 检查字符串是否以 "data:" 格式开始
|
||||
if base64_string.startswith('data:'):
|
||||
if base64_string.startswith("data:"):
|
||||
# 提取 MIME 类型和数据
|
||||
pattern = DATA_URL_PATTERN
|
||||
match = re.match(pattern, base64_string)
|
||||
if match:
|
||||
mime_type = "image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||
mime_type = (
|
||||
"image/jpeg" if match.group(1) == "image/jpg" else match.group(1)
|
||||
)
|
||||
encoded_data = match.group(2)
|
||||
return mime_type, encoded_data
|
||||
|
||||
|
||||
# 如果不是预期格式,假定它只是数据部分
|
||||
return None, base64_string
|
||||
|
||||
@@ -44,20 +49,20 @@ def extract_mime_type_and_data(base64_string: str) -> Tuple[Optional[str], str]:
|
||||
def convert_image_to_base64(url: str) -> str:
|
||||
"""
|
||||
将图片URL转换为base64编码
|
||||
|
||||
|
||||
Args:
|
||||
url: 图片URL
|
||||
|
||||
|
||||
Returns:
|
||||
str: base64编码的图片数据
|
||||
|
||||
|
||||
Raises:
|
||||
Exception: 如果获取图片失败
|
||||
"""
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
# 将图片内容转换为base64
|
||||
img_data = base64.b64encode(response.content).decode('utf-8')
|
||||
img_data = base64.b64encode(response.content).decode("utf-8")
|
||||
return img_data
|
||||
else:
|
||||
raise Exception(f"Failed to fetch image: {response.status_code}")
|
||||
@@ -66,64 +71,66 @@ def convert_image_to_base64(url: str) -> str:
|
||||
def format_json_response(data: Dict[str, Any], indent: int = 2) -> str:
|
||||
"""
|
||||
格式化JSON响应
|
||||
|
||||
|
||||
Args:
|
||||
data: 要格式化的数据
|
||||
indent: 缩进空格数
|
||||
|
||||
|
||||
Returns:
|
||||
str: 格式化后的JSON字符串
|
||||
"""
|
||||
return json.dumps(data, indent=indent, ensure_ascii=False)
|
||||
|
||||
|
||||
def parse_prompt_parameters(prompt: str, default_ratio: str = "1:1") -> Tuple[str, int, str]:
|
||||
def parse_prompt_parameters(
|
||||
prompt: str, default_ratio: str = "1:1"
|
||||
) -> Tuple[str, int, str]:
|
||||
"""
|
||||
从prompt中解析参数
|
||||
|
||||
|
||||
支持的格式:
|
||||
- {n:数量} 例如: {n:2} 生成2张图片
|
||||
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
|
||||
|
||||
|
||||
Args:
|
||||
prompt: 提示文本
|
||||
default_ratio: 默认比例
|
||||
|
||||
|
||||
Returns:
|
||||
tuple: (清理后的提示文本, 图片数量, 比例)
|
||||
"""
|
||||
# 默认值
|
||||
n = 1
|
||||
aspect_ratio = default_ratio
|
||||
|
||||
|
||||
# 解析n参数
|
||||
n_match = re.search(r'{n:(\d+)}', prompt)
|
||||
n_match = re.search(r"{n:(\d+)}", prompt)
|
||||
if n_match:
|
||||
n = int(n_match.group(1))
|
||||
if n < 1 or n > 4:
|
||||
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
|
||||
prompt = prompt.replace(n_match.group(0), '').strip()
|
||||
|
||||
# 解析ratio参数
|
||||
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
|
||||
prompt = prompt.replace(n_match.group(0), "").strip()
|
||||
|
||||
# 解析ratio参数
|
||||
ratio_match = re.search(r"{ratio:(\d+:\d+)}", prompt)
|
||||
if ratio_match:
|
||||
aspect_ratio = ratio_match.group(1)
|
||||
if aspect_ratio not in VALID_IMAGE_RATIOS:
|
||||
raise ValueError(
|
||||
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(VALID_IMAGE_RATIOS)}"
|
||||
)
|
||||
prompt = prompt.replace(ratio_match.group(0), '').strip()
|
||||
|
||||
prompt = prompt.replace(ratio_match.group(0), "").strip()
|
||||
|
||||
return prompt, n, aspect_ratio
|
||||
|
||||
|
||||
def extract_image_urls_from_markdown(text: str) -> List[str]:
|
||||
"""
|
||||
从Markdown文本中提取图片URL
|
||||
|
||||
|
||||
Args:
|
||||
text: Markdown文本
|
||||
|
||||
|
||||
Returns:
|
||||
List[str]: 图片URL列表
|
||||
"""
|
||||
@@ -135,23 +142,22 @@ def extract_image_urls_from_markdown(text: str) -> List[str]:
|
||||
def is_valid_api_key(key: str) -> bool:
|
||||
"""
|
||||
检查API密钥格式是否有效
|
||||
|
||||
|
||||
Args:
|
||||
key: API密钥
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 如果密钥格式有效则返回True
|
||||
"""
|
||||
# 检查Gemini API密钥格式
|
||||
if key.startswith('AIza'):
|
||||
if key.startswith("AIza"):
|
||||
return len(key) >= 30
|
||||
|
||||
# 检查OpenAI API密钥格式
|
||||
if key.startswith('sk-'):
|
||||
return len(key) >= 30
|
||||
|
||||
return False
|
||||
|
||||
# 检查OpenAI API密钥格式
|
||||
if key.startswith("sk-"):
|
||||
return len(key) >= 30
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def redact_key_for_logging(key: str) -> str:
|
||||
@@ -177,15 +183,49 @@ def get_current_version(default_version: str = "0.0.0") -> str:
|
||||
"""Reads the current version from the VERSION file."""
|
||||
version_file = VERSION_FILE_PATH
|
||||
try:
|
||||
with version_file.open('r', encoding='utf-8') as f:
|
||||
with version_file.open("r", encoding="utf-8") as f:
|
||||
version = f.read().strip()
|
||||
if not version:
|
||||
helper_logger.warning(f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'.")
|
||||
helper_logger.warning(
|
||||
f"VERSION file ('{version_file}') is empty. Using default version '{default_version}'."
|
||||
)
|
||||
return default_version
|
||||
return version
|
||||
except FileNotFoundError:
|
||||
helper_logger.warning(f"VERSION file not found at '{version_file}'. Using default version '{default_version}'.")
|
||||
helper_logger.warning(
|
||||
f"VERSION file not found at '{version_file}'. Using default version '{default_version}'."
|
||||
)
|
||||
return default_version
|
||||
except IOError as e:
|
||||
helper_logger.error(f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'.")
|
||||
helper_logger.error(
|
||||
f"Error reading VERSION file ('{version_file}'): {e}. Using default version '{default_version}'."
|
||||
)
|
||||
return default_version
|
||||
|
||||
|
||||
def is_image_upload_configured(settings: Settings) -> bool:
|
||||
"""Return True only if a valid upload provider is selected and all required settings for that provider are present."""
|
||||
|
||||
provider = (getattr(settings, "UPLOAD_PROVIDER", "") or "").strip().lower()
|
||||
if provider == "smms":
|
||||
return bool(getattr(settings, "SMMS_SECRET_TOKEN", ""))
|
||||
if provider == "picgo":
|
||||
return bool(getattr(settings, "PICGO_API_KEY", ""))
|
||||
if provider == "aliyun_oss":
|
||||
return all(
|
||||
[
|
||||
getattr(settings, "OSS_ACCESS_KEY", ""),
|
||||
getattr(settings, "OSS_ACCESS_KEY_SECRET", ""),
|
||||
getattr(settings, "OSS_BUCKET_NAME", ""),
|
||||
getattr(settings, "OSS_ENDPOINT", ""),
|
||||
getattr(settings, "OSS_REGION", "")
|
||||
]
|
||||
)
|
||||
if provider == "cloudflare_imgbed":
|
||||
return all(
|
||||
[
|
||||
getattr(settings, "CLOUDFLARE_IMGBED_URL", ""),
|
||||
getattr(settings, "CLOUDFLARE_IMGBED_AUTH_CODE", ""),
|
||||
]
|
||||
)
|
||||
return False
|
||||
|
||||
127
app/utils/static_version.py
Normal file
127
app/utils/static_version.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
静态资源版本控制工具
|
||||
用于给CSS和JS文件添加版本参数,避免浏览器缓存问题
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from app.utils.helpers import get_current_version
|
||||
|
||||
|
||||
class StaticVersionManager:
|
||||
"""静态资源版本管理器"""
|
||||
|
||||
def __init__(self, static_dir: str = "app/static"):
|
||||
self.static_dir = Path(static_dir)
|
||||
self._version_cache: Dict[str, str] = {}
|
||||
self._use_file_hash = True # 是否使用文件哈希作为版本号
|
||||
|
||||
def get_version_for_file(self, file_path: str) -> str:
|
||||
"""
|
||||
获取文件的版本号
|
||||
|
||||
Args:
|
||||
file_path: 相对于static目录的文件路径,如 'css/fonts.css'
|
||||
|
||||
Returns:
|
||||
版本号字符串
|
||||
"""
|
||||
if self._use_file_hash:
|
||||
return self._get_file_hash_version(file_path)
|
||||
else:
|
||||
return self._get_app_version()
|
||||
|
||||
def _get_file_hash_version(self, file_path: str) -> str:
|
||||
"""基于文件内容生成哈希版本号"""
|
||||
# 如果已经缓存过,直接返回
|
||||
if file_path in self._version_cache:
|
||||
return self._version_cache[file_path]
|
||||
|
||||
full_path = self.static_dir / file_path
|
||||
|
||||
if not full_path.exists():
|
||||
# 文件不存在,使用应用版本号作为fallback
|
||||
version = self._get_app_version()
|
||||
else:
|
||||
try:
|
||||
# 读取文件内容并计算MD5哈希
|
||||
with open(full_path, "rb") as f:
|
||||
content = f.read()
|
||||
hash_object = hashlib.md5(content)
|
||||
version = hash_object.hexdigest()[:8] # 取前8位
|
||||
except Exception:
|
||||
# 读取失败,使用应用版本号作为fallback
|
||||
version = self._get_app_version()
|
||||
|
||||
# 缓存结果
|
||||
self._version_cache[file_path] = version
|
||||
return version
|
||||
|
||||
def _get_app_version(self) -> str:
|
||||
"""获取应用程序版本号"""
|
||||
try:
|
||||
return get_current_version().replace(".", "")
|
||||
except Exception:
|
||||
# 如果获取版本失败,使用时间戳
|
||||
return str(int(time.time()))
|
||||
|
||||
def get_versioned_url(self, file_path: str) -> str:
|
||||
"""
|
||||
获取带版本参数的URL
|
||||
|
||||
Args:
|
||||
file_path: 相对于static目录的文件路径
|
||||
|
||||
Returns:
|
||||
带版本参数的URL
|
||||
"""
|
||||
version = self.get_version_for_file(file_path)
|
||||
return f"/static/{file_path}?v={version}"
|
||||
|
||||
def clear_cache(self):
|
||||
"""清空版本缓存"""
|
||||
self._version_cache.clear()
|
||||
|
||||
|
||||
# 全局实例
|
||||
_static_version_manager = StaticVersionManager()
|
||||
|
||||
|
||||
def get_static_url(file_path: str) -> str:
|
||||
"""
|
||||
获取静态资源的版本化URL
|
||||
|
||||
Args:
|
||||
file_path: 相对于static目录的文件路径
|
||||
|
||||
Returns:
|
||||
带版本参数的完整URL
|
||||
|
||||
Example:
|
||||
get_static_url('css/fonts.css') -> '/static/css/fonts.css?v=a1b2c3d4'
|
||||
get_static_url('js/config_editor.js') -> '/static/js/config_editor.js?v=e5f6g7h8'
|
||||
"""
|
||||
return _static_version_manager.get_versioned_url(file_path)
|
||||
|
||||
|
||||
def clear_static_cache():
|
||||
"""清空静态资源版本缓存"""
|
||||
_static_version_manager.clear_cache()
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def get_cached_static_url(file_path: str) -> str:
|
||||
"""
|
||||
获取缓存的静态资源URL(用于开发环境)
|
||||
|
||||
Args:
|
||||
file_path: 相对于static目录的文件路径
|
||||
|
||||
Returns:
|
||||
带版本参数的完整URL
|
||||
"""
|
||||
return get_static_url(file_path)
|
||||
@@ -2,6 +2,12 @@ import requests
|
||||
from app.domain.image_models import ImageMetadata, ImageUploader, UploadResponse
|
||||
from enum import Enum
|
||||
from typing import Optional, Any
|
||||
import hashlib
|
||||
import base64
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
from app.log.logger import get_image_create_logger
|
||||
|
||||
class UploadErrorType(Enum):
|
||||
"""上传错误类型枚举"""
|
||||
@@ -179,9 +185,22 @@ class PicGoUploader(ImageUploader):
|
||||
"""
|
||||
try:
|
||||
# 准备请求头
|
||||
headers = {
|
||||
"X-API-Key": self.api_key
|
||||
}
|
||||
headers = {}
|
||||
|
||||
# 构建请求URL
|
||||
request_url = self.api_url
|
||||
|
||||
# 判断是否为默认PicGo URL,如果是则使用header认证,否则使用URL参数认证
|
||||
if self.api_url == "https://www.picgo.net/api/1/upload":
|
||||
headers["X-API-Key"] = self.api_key
|
||||
else:
|
||||
# 对于自定义URL,将API key作为查询参数添加到URL中
|
||||
from urllib.parse import urlparse, urlunparse, parse_qs, urlencode
|
||||
parsed_url = urlparse(request_url)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
query_params["key"] = self.api_key
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
request_url = urlunparse(parsed_url._replace(query=new_query))
|
||||
|
||||
# 准备文件数据
|
||||
files = {
|
||||
@@ -190,7 +209,7 @@ class PicGoUploader(ImageUploader):
|
||||
|
||||
# 发送请求
|
||||
response = requests.post(
|
||||
self.api_url,
|
||||
request_url,
|
||||
headers=headers,
|
||||
files=files
|
||||
)
|
||||
@@ -201,6 +220,34 @@ class PicGoUploader(ImageUploader):
|
||||
# 解析响应
|
||||
result = response.json()
|
||||
|
||||
# 处理自定义PicGo服务器的响应格式
|
||||
if "success" in result and "result" in result:
|
||||
# 自定义PicGo服务器格式: {"success": true, "result": ["url"]}
|
||||
if result["success"]:
|
||||
image_url = result["result"][0] if result["result"] and len(result["result"]) > 0 else ""
|
||||
image_metadata = ImageMetadata(
|
||||
width=0,
|
||||
height=0,
|
||||
filename=filename,
|
||||
size=0,
|
||||
url=image_url,
|
||||
delete_url=None
|
||||
)
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message="Upload success",
|
||||
data=image_metadata
|
||||
)
|
||||
else:
|
||||
raise UploadError(
|
||||
message="Upload failed",
|
||||
error_type=UploadErrorType.SERVER_ERROR,
|
||||
status_code=400,
|
||||
details=result
|
||||
)
|
||||
|
||||
# 处理官方PicGo服务器的响应格式
|
||||
# 验证上传是否成功
|
||||
if result.get("status_code") != 200:
|
||||
error_message = "Upload failed"
|
||||
@@ -259,6 +306,191 @@ class PicGoUploader(ImageUploader):
|
||||
)
|
||||
|
||||
|
||||
class AliyunOSSUploader(ImageUploader):
|
||||
"""阿里云OSS图片上传器"""
|
||||
|
||||
def __init__(self, access_key: str, access_key_secret: str, bucket_name: str,
|
||||
endpoint: str, region: str, use_internal: bool = False):
|
||||
"""
|
||||
初始化阿里云OSS上传器
|
||||
|
||||
Args:
|
||||
access_key: OSS访问密钥ID
|
||||
access_key_secret: OSS访问密钥
|
||||
bucket_name: OSS存储桶名称
|
||||
endpoint: OSS端点地址
|
||||
region: OSS区域
|
||||
use_internal: 是否使用内网端点
|
||||
"""
|
||||
self.access_key = access_key
|
||||
self.access_key_secret = access_key_secret
|
||||
self.bucket_name = bucket_name
|
||||
self.endpoint = endpoint
|
||||
self.region = region
|
||||
self.use_internal = use_internal
|
||||
self.logger = get_image_create_logger()
|
||||
|
||||
# 构建请求URL
|
||||
if not endpoint.startswith(('http://', 'https://')):
|
||||
self.base_url = f"https://{bucket_name}.{endpoint}"
|
||||
else:
|
||||
self.base_url = f"{endpoint}/{bucket_name}"
|
||||
|
||||
self.logger.info(f"Initialized AliyunOSSUploader for bucket: {bucket_name}, region: {region}")
|
||||
|
||||
def _sign_request(self, method: str, path: str, headers: dict, content: bytes = b'') -> dict:
|
||||
"""
|
||||
为OSS请求生成签名
|
||||
|
||||
Args:
|
||||
method: HTTP方法
|
||||
path: 请求路径
|
||||
headers: 请求头
|
||||
content: 请求内容
|
||||
|
||||
Returns:
|
||||
包含签名的请求头
|
||||
"""
|
||||
# 计算Content-MD5
|
||||
content_md5 = base64.b64encode(hashlib.md5(content).digest()).decode('utf-8') if content else ''
|
||||
|
||||
# 设置日期
|
||||
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
|
||||
# 更新headers
|
||||
headers['Date'] = date
|
||||
if content_md5:
|
||||
headers['Content-MD5'] = content_md5
|
||||
headers['Content-Type'] = headers.get('Content-Type', 'image/png')
|
||||
|
||||
# 构建CanonicalizedOSSHeaders
|
||||
oss_headers = []
|
||||
for key, value in sorted(headers.items()):
|
||||
if key.lower().startswith('x-oss-'):
|
||||
oss_headers.append(f"{key.lower()}:{value}")
|
||||
canonicalized_oss_headers = '\n'.join(oss_headers)
|
||||
if canonicalized_oss_headers:
|
||||
canonicalized_oss_headers += '\n'
|
||||
|
||||
# 构建CanonicalizedResource
|
||||
canonicalized_resource = f"/{self.bucket_name}{path}"
|
||||
|
||||
# 构建StringToSign
|
||||
string_to_sign = f"{method}\n{content_md5}\n{headers.get('Content-Type', '')}\n{date}\n{canonicalized_oss_headers}{canonicalized_resource}"
|
||||
|
||||
# 计算签名
|
||||
signature = base64.b64encode(
|
||||
hmac.new(
|
||||
self.access_key_secret.encode('utf-8'),
|
||||
string_to_sign.encode('utf-8'),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode('utf-8')
|
||||
|
||||
# 添加Authorization头
|
||||
headers['Authorization'] = f"OSS {self.access_key}:{signature}"
|
||||
|
||||
return headers
|
||||
|
||||
def upload(self, file: bytes, filename: str) -> UploadResponse:
|
||||
"""
|
||||
上传图片到阿里云OSS
|
||||
|
||||
Args:
|
||||
file: 图片文件二进制数据
|
||||
filename: 文件名(将作为OSS对象的key)
|
||||
|
||||
Returns:
|
||||
UploadResponse: 上传响应对象
|
||||
|
||||
Raises:
|
||||
UploadError: 上传失败时抛出异常
|
||||
"""
|
||||
# 记录开始上传的日志
|
||||
self.logger.info(f"Starting OSS upload for file: {filename}, size: {len(file)} bytes")
|
||||
|
||||
try:
|
||||
# 构建对象路径
|
||||
object_key = f"/{filename}"
|
||||
|
||||
# 准备请求头
|
||||
headers = {
|
||||
'Content-Type': 'image/png',
|
||||
'x-oss-object-acl': 'public-read' # 设置为公共读
|
||||
}
|
||||
|
||||
# 签名请求
|
||||
signed_headers = self._sign_request('PUT', object_key, headers, file)
|
||||
|
||||
# 构建完整URL
|
||||
upload_url = f"{self.base_url}{object_key}"
|
||||
self.logger.debug(f"OSS upload URL: {upload_url}")
|
||||
|
||||
# 发送请求
|
||||
response = requests.put(
|
||||
upload_url,
|
||||
data=file,
|
||||
headers=signed_headers
|
||||
)
|
||||
|
||||
# 检查响应状态
|
||||
if response.status_code != 200:
|
||||
error_msg = f"OSS upload failed with status {response.status_code}, response: {response.text}"
|
||||
self.logger.error(f"OSS upload failed for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=f"OSS upload failed with status {response.status_code}",
|
||||
error_type=UploadErrorType.SERVER_ERROR,
|
||||
status_code=response.status_code,
|
||||
details={'response': response.text}
|
||||
)
|
||||
|
||||
# 构建访问URL
|
||||
if self.endpoint.startswith(('http://', 'https://')):
|
||||
access_url = f"{self.endpoint}/{self.bucket_name}{object_key}"
|
||||
else:
|
||||
access_url = f"https://{self.bucket_name}.{self.endpoint}{object_key}"
|
||||
|
||||
# 构建图片元数据
|
||||
image_metadata = ImageMetadata(
|
||||
width=0, # OSS PUT不返回图片尺寸
|
||||
height=0,
|
||||
filename=filename,
|
||||
size=len(file),
|
||||
url=access_url,
|
||||
delete_url=None # OSS需要单独的删除操作
|
||||
)
|
||||
|
||||
# 记录上传成功的日志
|
||||
self.logger.info(f"OSS upload successful for {filename}, URL: {access_url}")
|
||||
|
||||
return UploadResponse(
|
||||
success=True,
|
||||
code="success",
|
||||
message="Upload to Aliyun OSS success",
|
||||
data=image_metadata
|
||||
)
|
||||
|
||||
except requests.RequestException as e:
|
||||
error_msg = f"OSS upload request failed: {str(e)}"
|
||||
self.logger.error(f"OSS upload request failed for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=error_msg,
|
||||
error_type=UploadErrorType.NETWORK_ERROR,
|
||||
original_error=e
|
||||
)
|
||||
except UploadError:
|
||||
# UploadError 已经被记录了,直接重新抛出
|
||||
raise
|
||||
except Exception as e:
|
||||
error_msg = f"OSS upload failed: {str(e)}"
|
||||
self.logger.error(f"OSS upload unexpected error for {filename}: {error_msg}")
|
||||
raise UploadError(
|
||||
message=error_msg,
|
||||
error_type=UploadErrorType.UNKNOWN,
|
||||
original_error=e
|
||||
)
|
||||
|
||||
|
||||
class CloudFlareImgBedUploader(ImageUploader):
|
||||
"""CloudFlare图床上传器"""
|
||||
|
||||
@@ -389,7 +621,7 @@ class ImageUploaderFactory:
|
||||
credentials["secret_key"]
|
||||
)
|
||||
elif provider == "picgo":
|
||||
api_url = credentials.get("api_url", "https://www.picgo.net/api/1/upload")
|
||||
api_url = credentials.get("api_url") or "https://www.picgo.net/api/1/upload"
|
||||
return PicGoUploader(credentials["api_key"], api_url)
|
||||
elif provider == "cloudflare_imgbed":
|
||||
return CloudFlareImgBedUploader(
|
||||
@@ -397,4 +629,13 @@ class ImageUploaderFactory:
|
||||
credentials["base_url"],
|
||||
credentials.get("upload_folder", ""),
|
||||
)
|
||||
elif provider == "aliyun_oss":
|
||||
return AliyunOSSUploader(
|
||||
credentials["access_key"],
|
||||
credentials["access_key_secret"],
|
||||
credentials["bucket_name"],
|
||||
credentials["endpoint"],
|
||||
credentials["region"],
|
||||
credentials.get("use_internal", False)
|
||||
)
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
@@ -36,4 +36,13 @@ services:
|
||||
interval: 10s # 每隔10秒检查一次
|
||||
timeout: 5s # 每次检查的超时时间为5秒
|
||||
retries: 3 # 重试3次失败后标记为 unhealthy
|
||||
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查
|
||||
start_period: 30s # 容器启动后等待30秒再开始第一次健康检查
|
||||
# adminer:
|
||||
# image: adminer:latest
|
||||
# container_name: gemini-balance-adminer
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
# depends_on:
|
||||
# mysql:
|
||||
# condition: service_healthy
|
||||
Reference in New Issue
Block a user