Compare commits

..

21 Commits

Author SHA1 Message Date
snaily
0b837c3f80 chore: 更新版本号至 2.1.9 2025-07-10 21:33:54 +08:00
snaily
a6cfc12443 feat: 更新响应处理逻辑以支持推理内容
- 修改了 response_handler.py 中的 _handle_openai_stream_response 和 _handle_openai_normal_response 方法,增加了对推理内容 (reasoning_content) 的支持。
- 更新了 _extract_result 方法的返回值,确保能够提取推理内容。
- 在 gemini_chat_service.py 和 openai_chat_service.py 中,调整了生成配置以包含思考过程的选项。
- 在 vertex_express_chat_service.py 中,增强了对客户端思考配置的处理逻辑,确保优先使用客户端提供的配置。
2025-07-10 21:21:55 +08:00
snaily
f6d64dd850 feat: 添加 TTS 语音名称常量并更新 TTS 服务逻辑
- 在 constants.py 中新增 TTS_VOICE_NAMES 列表,包含多个语音名称。
- 更新 tts_service.py 中的语音配置逻辑,确保使用请求中的语音名称(如果有效),否则回退到默认配置。
2025-07-10 01:03:20 +08:00
snaily
eed62caa78 refactor: 移除 ApiClient 中的 count_tokens 抽象方法
- 从 ApiClient 类中删除了 count_tokens 方法的抽象定义,以简化接口。
2025-07-10 00:53:06 +08:00
ripper
204d41d6f3 feat: add JSON Schema cleaning function to remove unsupported fields in Gemini API 2025-07-09 10:29:42 +08:00
ripper
858df0548e fix: ensure generationConfig is not None in payload 2025-07-09 10:17:32 +08:00
snaily
b3da021803 refactor: 优化配置解析逻辑,增强对泛型类型的支持
- 在 config.py 中引入 get_args 和 get_origin 函数,以更好地处理 List 和 Dict 类型的解析。
- 更新了对 List[str] 和 List[Dict[str, str]] 的解析逻辑,增加了错误处理和日志记录。
- 在 keys_status.js 中将 filterValidKeys 函数替换为 filterAndSearchValidKeys,保留旧函数以避免破坏潜在的遗留调用。
- 在 keys_status.html 中新增选项以支持更多项目选择。
2025-07-08 16:35:56 +08:00
snaily
d234f826f4 chore: 更新 Vertex API 相关注释和正则表达式为 Vertex Express API,确保一致性和准确性。修改了多个文件中的相关描述和提示信息,以反映 API 名称的变化。 2025-07-08 15:27:16 +08:00
snaily
231b69ecf8 feat: 添加自定义 Headers 功能
- 在配置中添加 `CUSTOM_HEADERS` 选项,允许用户定义全局请求头。
- 更新 API 客户端,将自定义 `header` 应用于所有出站请求。
- 在配置页面上为 `CUSTOM_HEADERS` 添加了完整的前端编辑功能。
2025-07-08 13:58:05 +08:00
snaily
0a08913677 Merge pull request #183 from liucong2013/feature/count-tokens-compatibility 2025-07-07 17:24:45 +08:00
snaily
49d32813ea chore: 更新 GitHub Actions 工作流以生成发布说明
- 修改了版本标签的引号格式
- 添加了生成发布说明的步骤
- 更新了创建发布的步骤以包含发布说明
- 调整了步骤的顺序和注释
2025-07-07 14:45:07 +08:00
snaily
c5d57e97b1 chore: 更新版本号至2.1.8 2025-07-07 14:21:41 +08:00
lc631017672
da8f7539a1 Fix: Handle empty parts in CountTokensRequest and improve payload filtering 2025-07-07 14:13:16 +08:00
lc631017672
64a68f1176 refactor: Remove debug logging for security checks 2025-07-07 10:27:48 +08:00
lc631017672
1199d7cc3c feat: Add support for countTokens API and improve debug logging 2025-07-07 10:08:57 +08:00
ry
8a827d2acb feat: 支持CloudFlare图床自定义上传文件夹路径
- 新增CLOUDFLARE_IMGBED_UPLOAD_FOLDER环境变量配置
- 用户可通过该配置项指定图片在CloudFlare图床中的上传路径
2025-07-05 23:32:45 +08:00
snaily
0e8a943d7f chore:更新 README 和 README_ZH 文件,调整徽章的 HTML 结构,使其居中显示。 2025-07-05 16:49:57 +08:00
snaily
4f62658440 Update README.md 2025-07-05 16:39:18 +08:00
snaily
6e7c3d5f6a Update README.md 2025-07-05 16:38:35 +08:00
snaily
d5062db9b6 Update README_ZH.md 2025-07-05 16:27:20 +08:00
snaily
a6ad006a49 Update README.md 2025-07-05 16:26:59 +08:00
22 changed files with 1036 additions and 407 deletions

View File

@@ -43,6 +43,7 @@ SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PICGO_API_KEY=xxxx
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
##########################################################################
#########################stream_optimizer 相关配置########################
STREAM_OPTIMIZER_ENABLED=false

View File

@@ -3,7 +3,7 @@ name: Publish Release
on:
push:
tags:
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
- "v*" # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0
jobs:
update-release-draft:
@@ -15,8 +15,17 @@ jobs:
# Step 1: 检出代码库
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
# Step 2: 自动生成 Release
# Step 2: 自动生成 Release Notes
- name: Generate release notes
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Step 3: 自动生成 Release
- name: Create Release
id: create_release
uses: actions/create-release@v1
@@ -25,15 +34,16 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
release_name: ${{ github.ref_name }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
# Step 3: 可选构建zip文件
# Step 4: 可选构建zip文件
- name: Create ZIP file
run: |
zip -r gemini-balance.zip . -x "*.git*" "*.github*" "*.env*" "logs/*" "tests/*"
# Step 4: 可选,上传构建文件
# Step 5: 可选,上传构建文件
- name: Upload Release Asset
uses: actions/upload-release-asset@v1
env:
@@ -41,5 +51,5 @@ jobs:
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./gemini-balance.zip # 替换为你的构建文件路径
asset_name: gemini-balance.zip # 替换为你的文件名
asset_name: gemini-balance.zip # 替换为你的文件名
asset_content_type: application/zip

View File

@@ -15,6 +15,7 @@ ENV TOOLS_CODE_EXECUTION_ENABLED=false
ENV IMAGE_MODELS='["gemini-2.0-flash-exp"]'
ENV SEARCH_MODELS='["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]'
ENV URL_NORMALIZATION_ENABLED=false
ENV CLOUDFLARE_IMGBED_UPLOAD_FOLDER=""
# Expose port
EXPOSE 8000

View File

@@ -2,6 +2,12 @@
# Gemini Balance - Gemini API Proxy and Load Balancer
<p align="center">
<a href="https://trendshift.io/repositories/13692" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/13692" alt="snailyp%2Fgemini-balance | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
> ⚠️ This project is licensed under the CC BY-NC 4.0 (Attribution-NonCommercial) license. Any form of commercial resale service is prohibited. See the LICENSE file for details.
> I have never sold this service on any platform. If you encounter someone selling this service, they are definitely a reseller. Please be careful not to be deceived.
@@ -211,6 +217,7 @@ If you want to run the source code directly locally for development or testing,
| `PICGO_API_KEY` | Optional, API Key for [PicoGo](https://www.picgo.net/) image hosting | `your-picogo-apikey` |
| `CLOUDFLARE_IMGBED_URL` | Optional, [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) image hosting upload address | `https://xxxxxxx.pages.dev/upload` |
| `CLOUDFLARE_IMGBED_AUTH_CODE` | Optional, authentication key for CloudFlare image hosting | `your-cloudflare-imgber-auth-code` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER` | Optional, upload folder path for CloudFlare image hosting | `""` |
| **Stream Optimizer Related** | | |
| `STREAM_OPTIMIZER_ENABLED` | Optional, whether to enable stream output optimization | `false` |
| `STREAM_MIN_DELAY` | Optional, minimum delay for stream output | `0.016` |

View File

@@ -1,5 +1,11 @@
# Gemini Balance - Gemini API 代理和负载均衡器
<p align="center">
<a href="https://trendshift.io/repositories/13692" target="_blank">
<img src="https://trendshift.io/api/badge/repositories/13692" alt="snailyp%2Fgemini-balance | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</a>
</p>
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
@@ -204,6 +210,7 @@ app/
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
| `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图床的上传文件夹路径 | `""` |
| **流式优化器相关** | | |
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
| `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` |

View File

@@ -1 +1 @@
2.1.7
2.1.9

View File

@@ -4,7 +4,7 @@
import datetime
import json
from typing import Any, Dict, List, Type
from typing import Any, Dict, List, Type, get_args, get_origin
from pydantic import ValidationError, ValidationInfo, field_validator
from pydantic_settings import BaseSettings
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
# 智能路由配置
URL_NORMALIZATION_ENABLED: bool = False # 是否启用智能路由映射功能
# 自定义 Headers
CUSTOM_HEADERS: Dict[str, str] = {}
# 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
@@ -90,6 +93,7 @@ class Settings(BaseSettings):
PICGO_API_KEY: str = ""
CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
# 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False
@@ -137,86 +141,106 @@ def _parse_db_value(key: str, db_value: str, target_type: Type) -> Any:
logger = get_config_logger()
try:
# 处理 List[str]
if target_type == List[str]:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
origin_type = get_origin(target_type)
args = get_args(target_type)
# 处理 List 类型
if origin_type is list:
# 处理 List[str]
if args and args[0] == str:
try:
parsed = json.loads(db_value)
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()]
logger.warning(
f"Could not parse '{db_value}' as List[str] for key '{key}', falling back to comma split or empty list."
)
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."
)
return [item.strip() for item in db_value.split(",") if item.strip()]
# 处理 Dict[str, float]
elif target_type == Dict[str, float]:
parsed_dict = {}
try:
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
)
except (json.JSONDecodeError, ValueError, TypeError) as e1:
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
logger.warning(
f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}"
)
try:
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()}
# 处理 List[Dict[str, str]]
elif args and get_origin(args[0]) is dict:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
valid = all(
isinstance(item, dict)
and all(isinstance(k, str) for k in item.keys())
and all(isinstance(v, str) for v in item.values())
for item in parsed
)
if valid:
return parsed
else:
logger.warning(
f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}"
f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}"
)
except (json.JSONDecodeError, ValueError, TypeError) as e2:
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict."
)
else:
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict."
)
return parsed_dict
# 处理 List[Dict[str, str]]
elif target_type == List[Dict[str, str]]:
try:
parsed = json.loads(db_value)
if isinstance(parsed, list):
# 验证列表中的每个元素是否为字典,并且键和值都是字符串
valid = all(
isinstance(item, dict)
and all(isinstance(k, str) for k in item.keys())
and all(isinstance(v, str) for v in item.values())
for item in parsed
)
if valid:
return parsed
return []
else:
logger.warning(
f"Invalid structure in List[Dict[str, str]] for key '{key}'. Value: {db_value}"
f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}"
)
return []
else:
logger.warning(
f"Parsed DB value for key '{key}' is not a list type. Value: {db_value}"
except json.JSONDecodeError:
logger.error(
f"Could not parse '{db_value}' as JSON for List[Dict[str, str]] for key '{key}'. Returning empty list."
)
return []
except json.JSONDecodeError:
logger.error(
f"Could not parse '{db_value}' as JSON for List[Dict[str, str]] for key '{key}'. Returning empty list."
)
return []
except Exception as e:
logger.error(
f"Error parsing List[Dict[str, str]] for key '{key}': {e}. Value: {db_value}. Returning empty list."
)
return []
except Exception as e:
logger.error(
f"Error parsing List[Dict[str, str]] for key '{key}': {e}. Value: {db_value}. Returning empty list."
)
return []
# 处理 Dict 类型
elif origin_type is dict:
# 处理 Dict[str, str]
if args and args == (str, str):
parsed_dict = {}
try:
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): str(v) for k, v in parsed.items()}
else:
logger.warning(
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.")
return parsed_dict
# 处理 Dict[str, float]
elif args and args == (str, float):
parsed_dict = {}
try:
parsed = json.loads(db_value)
if isinstance(parsed, dict):
parsed_dict = {str(k): float(v) for k, v in parsed.items()}
else:
logger.warning(
f"Parsed DB value for key '{key}' is not a dictionary type. Value: {db_value}"
)
except (json.JSONDecodeError, ValueError, TypeError) as e1:
if isinstance(e1, json.JSONDecodeError) and "'" in db_value:
logger.warning(
f"Failed initial JSON parse for key '{key}'. Attempting to replace single quotes. Error: {e1}"
)
try:
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()}
else:
logger.warning(
f"Parsed DB value (after quote replacement) for key '{key}' is not a dictionary type. Value: {corrected_db_value}"
)
except (json.JSONDecodeError, ValueError, TypeError) as e2:
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}' even after replacing quotes: {e2}. Returning empty dict."
)
else:
logger.error(
f"Could not parse '{db_value}' as Dict[str, float] for key '{key}': {e1}. Returning empty dict."
)
return parsed_dict
# 处理 bool
elif target_type == bool:
return db_value.lower() in ("true", "1", "yes", "on")
@@ -305,18 +329,12 @@ async def sync_initial_settings():
if parsed_db_value != memory_value:
# 检查类型是否匹配,以防解析函数返回了不兼容的类型
type_match = False
if target_type == List[str] and isinstance(
parsed_db_value, list
):
type_match = True
elif target_type == Dict[str, float] and isinstance(
parsed_db_value, dict
):
type_match = True
elif target_type not in (
List[str],
Dict[str, float],
) and isinstance(parsed_db_value, target_type):
origin_type = get_origin(target_type)
if origin_type: # It's a generic type
if isinstance(parsed_db_value, origin_type):
type_match = True
# It's a non-generic type, or a specific generic we want to handle
elif isinstance(parsed_db_value, target_type):
type_match = True
if type_match:

View File

@@ -76,4 +76,15 @@ DEFAULT_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"},
]
]
TTS_VOICE_NAMES = [
"Zephyr", "Puck", "Charon", "Kore",
"Fenrir", "Leda", "Orus", "Aoede",
"Callirhoe", "Autonoe", "Enceladus", "Iapetus",
"Umbriel", "Algieba", "Despina", "Erinome",
"Algenib", "Rasalgethi", "Laomedeia", "Achernar",
"Alnilam", "Schedar", "Gacrux", "Pulcherrima",
"Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia",
"Sadaltager", "Sulafat"
]

View File

@@ -39,13 +39,13 @@ class GeminiResponseHandler(ResponseHandler):
def _handle_openai_stream_response(
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls, _ = _extract_result(
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=True, gemini_format=False
)
if not text and not tool_calls:
if not text and not tool_calls and not reasoning_content:
delta = {}
else:
delta = {"content": text, "role": "assistant"}
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
if tool_calls:
delta["tool_calls"] = tool_calls
template_chunk = {
@@ -63,7 +63,7 @@ def _handle_openai_stream_response(
def _handle_openai_normal_response(
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
) -> Dict[str, Any]:
text, tool_calls, _ = _extract_result(
text, reasoning_content, tool_calls, _ = _extract_result(
response, model, stream=False, gemini_format=False
)
return {
@@ -77,6 +77,7 @@ def _handle_openai_normal_response(
"message": {
"role": "assistant",
"content": text,
"reasoning_content": reasoning_content,
"tool_calls": tool_calls,
},
"finish_reason": finish_reason,
@@ -156,19 +157,21 @@ def _extract_result(
model: str,
stream: bool = False,
gemini_format: bool = False,
) -> tuple[str, List[Dict[str, Any]], Optional[bool]]:
text, tool_calls = "", []
thought = None
) -> 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]
content = candidate.get("content", {})
parts = content.get("parts", [])
if not parts:
return "", [], None
return "", None, [], None
if "text" in parts[0]:
text = parts[0].get("text")
if "thought" in parts[0]:
if not gemini_format and settings.SHOW_THINKING_PROCESS:
reasoning_content = text
text = ""
thought = parts[0].get("thought")
elif "executableCode" in parts[0]:
text = _format_code_block(parts[0]["executableCode"])
@@ -187,32 +190,18 @@ def _extract_result(
else:
if response.get("candidates"):
candidate = response["candidates"][0]
if "thinking" in model:
if settings.SHOW_THINKING_PROCESS:
if len(candidate["content"]["parts"]) == 2:
text = (
"> thinking\n\n"
+ candidate["content"]["parts"][0]["text"]
+ "\n\n---\n> output\n\n"
+ candidate["content"]["parts"][1]["text"]
)
else:
text = candidate["content"]["parts"][0]["text"]
else:
if len(candidate["content"]["parts"]) == 2:
text = candidate["content"]["parts"][1]["text"]
else:
text = candidate["content"]["parts"][0]["text"]
else:
text = ""
if "parts" in candidate["content"]:
for part in candidate["content"]["parts"]:
if "text" in part:
text, reasoning_content = "", ""
if "parts" in candidate["content"]:
for part in candidate["content"]["parts"]:
if "text" in part:
if "thought" in part and settings.SHOW_THINKING_PROCESS:
reasoning_content += part["text"]
else:
text += part["text"]
if "thought" in part and thought is None:
thought = part.get("thought")
elif "inlineData" in part:
text += _extract_image_data(part)
if "thought" in part and thought is None:
thought = part.get("thought")
elif "inlineData" in part:
text += _extract_image_data(part)
text = _add_search_link_text(model, candidate, text)
tool_calls = _extract_tool_calls(
@@ -220,7 +209,7 @@ def _extract_result(
)
else:
text = "暂无返回"
return text, tool_calls, thought
return text, reasoning_content, tool_calls, thought
def _extract_image_data(part: dict) -> str:
@@ -238,6 +227,7 @@ def _extract_image_data(part: dict) -> str:
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
@@ -293,7 +283,7 @@ def _extract_tool_calls(
def _handle_gemini_stream_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls, thought = _extract_result(
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
if tool_calls:
@@ -310,16 +300,18 @@ def _handle_gemini_stream_response(
def _handle_gemini_normal_response(
response: Dict[str, Any], model: str, stream: bool
) -> Dict[str, Any]:
text, tool_calls, thought = _extract_result(
text, reasoning_content, tool_calls, thought = _extract_result(
response, model, stream=stream, gemini_format=True
)
parts = []
if tool_calls:
content = {"parts": tool_calls, "role": "model"}
parts = tool_calls
else:
part = {"text": text}
if thought is not None:
part["thought"] = thought
content = {"parts": [part], "role": "model"}
parts.append({"text": reasoning_content,"thought": thought})
part = {"text": text}
parts.append(part)
content = {"parts": parts, "role": "model"}
response["candidates"][0]["content"] = content
return response

View File

@@ -151,6 +151,35 @@ async def stream_generate_content(
return StreamingResponse(response_stream, media_type="text/event-stream")
@router.post("/models/{model_name}:countTokens")
@router_v1beta.post("/models/{model_name}:countTokens")
@RetryHandler(key_arg="api_key")
async def count_tokens(
model_name: str,
request: GeminiRequest,
_=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)
):
"""处理 Gemini token 计数请求。"""
operation_name = "gemini_count_tokens"
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 API key: {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 chat_service.count_tokens(
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)):
"""批量重置Gemini API密钥的失败计数可选择性地仅重置有效或无效密钥"""

View File

@@ -28,6 +28,33 @@ def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
return False
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"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
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]]:
"""构建工具"""
@@ -40,7 +67,15 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
for k, v in item.items():
if k == "functionDeclarations" and v and isinstance(v, list):
functions = record.get("functionDeclarations", [])
functions.extend(v)
# 清理每个函数声明中的不支持字段
cleaned_functions = []
for func in v:
if isinstance(func, dict):
cleaned_func = _clean_json_schema_properties(func)
cleaned_functions.append(cleaned_func)
else:
cleaned_functions.append(func)
functions.extend(cleaned_functions)
record["functionDeclarations"] = functions
else:
record[k] = v
@@ -78,6 +113,26 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
return settings.SAFETY_SETTINGS
def _filter_empty_parts(contents: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Filters out contents with empty or invalid parts."""
if not contents:
return []
filtered_contents = []
for content in contents:
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]
if valid_parts:
new_content = content.copy()
new_content["parts"] = valid_parts
filtered_contents.append(new_content)
return filtered_contents
def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
"""构建请求payload"""
request_dict = request.model_dump()
@@ -87,13 +142,17 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
request_dict["generationConfig"].pop("maxOutputTokens")
payload = {
"contents": request_dict.get("contents", []),
"contents": _filter_empty_parts(request_dict.get("contents", [])),
"tools": _build_tools(model, request_dict),
"safetySettings": _get_safety_settings(model),
"generationConfig": request_dict.get("generationConfig"),
"systemInstruction": request_dict.get("systemInstruction"),
}
# 确保 generationConfig 不为 None
if payload["generationConfig"] is None:
payload["generationConfig"] = {}
if model.endswith("-image") or model.endswith("-image-generation"):
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
@@ -111,7 +170,13 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload
@@ -195,6 +260,54 @@ class GeminiChatService:
request_time=request_datetime
)
async def count_tokens(
self, model: str, request: GeminiRequest, api_key: str
) -> Dict[str, Any]:
"""计算token数量"""
# countTokens API只需要contents
payload = {"contents": _filter_empty_parts(request.model_dump().get("contents", []))}
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
try:
response = await self.api_client.count_tokens(payload, model, api_key)
is_success = True
status_code = 200
return response
except Exception as e:
is_success = False
error_log_msg = str(e)
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,
model_name=model,
error_type="gemini-count-tokens",
error_log=error_log_msg,
error_code=status_code,
request_msg=payload
)
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 stream_generate_content(
self, model: str, request: GeminiRequest, api_key: str
) -> AsyncGenerator[str, None]:

View File

@@ -26,16 +26,43 @@ from app.service.key.key_manager import KeyManager
logger = get_openai_logger()
def _has_media_parts(contents: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含图片、音频或视频部分 (inline_data)"""
for content in contents:
if content and "parts" in content and isinstance(content["parts"], list):
for part in content["parts"]:
if isinstance(part, dict) and "inline_data" in part:
def _has_media_parts(messages: List[Dict[str, Any]]) -> bool:
"""判断消息是否包含多媒体部分"""
for message in messages:
if "parts" in message:
for part in message["parts"]:
if "image_url" in part or "inline_data" in part:
return True
return False
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"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
cleaned[key] = [_clean_json_schema_properties(item) for item in value]
else:
cleaned[key] = value
return cleaned
def _build_tools(
request: ChatRequest, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
@@ -76,6 +103,8 @@ def _build_tools(
):
function.pop("parameters", None)
# 清理函数中的不支持字段
function = _clean_json_schema_properties(function)
function_declarations.append(function)
if function_declarations:
@@ -137,9 +166,13 @@ def _build_payload(
if request.model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if request.model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)
}
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
"includeThoughts": True
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000)}
if (
instruction

View File

@@ -28,6 +28,33 @@ def _has_image_parts(contents: List[Dict[str, Any]]) -> bool:
return False
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"
}
cleaned = {}
for key, value in obj.items():
if key in unsupported_fields:
continue
if isinstance(value, dict):
cleaned[key] = _clean_json_schema_properties(value)
elif isinstance(value, list):
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]]:
"""构建工具"""
@@ -40,7 +67,15 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
for k, v in item.items():
if k == "functionDeclarations" and v and isinstance(v, list):
functions = record.get("functionDeclarations", [])
functions.extend(v)
# 清理每个函数声明中的不支持字段
cleaned_functions = []
for func in v:
if isinstance(func, dict):
cleaned_func = _clean_json_schema_properties(func)
cleaned_functions.append(cleaned_func)
else:
cleaned_functions.append(func)
functions.extend(cleaned_functions)
record["functionDeclarations"] = functions
else:
record[k] = v
@@ -98,10 +133,26 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
payload.pop("systemInstruction")
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
if model.endswith("-non-thinking"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
if model in settings.THINKING_BUDGET_MAP:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
# 处理思考配置:优先使用客户端提供的配置,否则使用默认配置
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"):
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
elif model in settings.THINKING_BUDGET_MAP:
if settings.SHOW_THINKING_PROCESS:
payload["generationConfig"]["thinkingConfig"] = {
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
"includeThoughts": True
}
else:
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000)}
return payload

View File

@@ -40,6 +40,13 @@ class GeminiApiClient(ApiClient):
model = model[:-20]
return model
def _prepare_headers(self) -> Dict[str, str]:
headers = {}
if settings.CUSTOM_HEADERS:
headers.update(settings.CUSTOM_HEADERS)
logger.info(f"Using custom headers: {settings.CUSTOM_HEADERS}")
return headers
async def get_models(self, api_key: str) -> Optional[Dict[str, Any]]:
"""获取可用的 Gemini 模型列表"""
timeout = httpx.Timeout(timeout=5)
@@ -52,10 +59,11 @@ class GeminiApiClient(ApiClient):
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?key={api_key}&pageSize=1000"
try:
response = await client.get(url)
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
@@ -78,9 +86,10 @@ class GeminiApiClient(ApiClient):
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}"
response = await client.post(url, json=payload)
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}")
@@ -98,9 +107,10 @@ class GeminiApiClient(ApiClient):
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}:streamGenerateContent?alt=sse&key={api_key}"
async with client.stream(method="POST", url=url, json=payload) 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")
@@ -108,6 +118,27 @@ class GeminiApiClient(ApiClient):
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]:
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 counting tokens: {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}:countTokens?key={api_key}"
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()
class OpenaiApiClient(ApiClient):
"""OpenAI API客户端"""
@@ -116,6 +147,13 @@ class OpenaiApiClient(ApiClient):
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:
headers.update(settings.CUSTOM_HEADERS)
logger.info(f"Using custom headers: {settings.CUSTOM_HEADERS}")
return headers
async def get_models(self, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout)
@@ -127,9 +165,9 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/models"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.get(url, headers=headers)
if response.status_code != 200:
error_content = response.text
@@ -147,9 +185,9 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
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"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text
@@ -166,9 +204,9 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
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"
headers = {"Authorization": f"Bearer {api_key}"}
async with client.stream(method="POST", url=url, json=payload, headers=headers) as response:
if response.status_code != 200:
error_content = await response.aread()
@@ -188,9 +226,9 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/embeddings"
headers = {"Authorization": f"Bearer {api_key}"}
payload = {
"input": input,
"model": model,
@@ -212,9 +250,9 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = random.choice(settings.PROXIES)
logger.info(f"Using proxy for getting models: {proxy_to_use}")
headers = self._prepare_headers(api_key)
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/images/generations"
headers = {"Authorization": f"Bearer {api_key}"}
response = await client.post(url, json=payload, headers=headers)
if response.status_code != 200:
error_content = response.text

View File

@@ -121,6 +121,7 @@ class ImageCreateService:
provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
)
else:
raise ValueError(

View File

@@ -34,7 +34,7 @@ class KeyManager:
return next(self.key_cycle)
async def get_next_vertex_key(self) -> str:
"""获取下一个 Vertex API key"""
"""获取下一个 Vertex Express API key"""
async with self.vertex_key_cycle_lock:
return next(self.vertex_key_cycle)
@@ -98,7 +98,7 @@ class KeyManager:
return current_key
async def get_next_working_vertex_key(self) -> str:
"""获取下一可用的 Vertex API key"""
"""获取下一可用的 Vertex Express API key"""
initial_key = await self.get_next_vertex_key()
current_key = initial_key
@@ -124,12 +124,12 @@ class KeyManager:
return ""
async def handle_vertex_api_failure(self, api_key: str, retries: int) -> str:
"""处理 Vertex API 调用失败"""
"""处理 Vertex Express API 调用失败"""
async with self.vertex_failure_count_lock:
self.vertex_key_failure_counts[api_key] += 1
if self.vertex_key_failure_counts[api_key] >= self.MAX_FAILURES:
logger.warning(
f"Vertex API key {api_key} has failed {self.MAX_FAILURES} times"
f"Vertex Express API key {api_key} has failed {self.MAX_FAILURES} times"
)
def get_fail_count(self, key: str) -> int:
@@ -156,7 +156,7 @@ class KeyManager:
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys}
async def get_vertex_keys_by_status(self) -> dict:
"""获取分类后的 Vertex API key 列表,包括失败次数"""
"""获取分类后的 Vertex Express API key 列表,包括失败次数"""
valid_keys = {}
invalid_keys = {}
@@ -178,8 +178,7 @@ class KeyManager:
if self.api_keys:
return self.api_keys[0]
if not self.api_keys:
logger.warning(
"API key list is empty, cannot get first valid key.")
logger.warning("API key list is empty, cannot get first valid key.")
return ""
return self.api_keys[0]
@@ -214,7 +213,7 @@ async def get_key_manager_instance(
)
if vertex_api_keys is None:
raise ValueError(
"Vertex API keys are required to initialize or re-initialize the KeyManager instance."
"Vertex Express API keys are required to initialize or re-initialize the KeyManager instance."
)
if not api_keys:
@@ -223,12 +222,12 @@ async def get_key_manager_instance(
)
if not vertex_api_keys:
logger.warning(
"Initializing KeyManager with an empty list of Vertex API keys."
"Initializing KeyManager with an empty list of Vertex Express API keys."
)
_singleton_instance = KeyManager(api_keys, vertex_api_keys)
logger.info(
f"KeyManager instance created/re-created with {len(api_keys)} API keys and {len(vertex_api_keys)} Vertex API keys."
f"KeyManager instance created/re-created with {len(api_keys)} API keys and {len(vertex_api_keys)} Vertex Express API keys."
)
# 1. 恢复失败计数
@@ -253,8 +252,7 @@ async def get_key_manager_instance(
_singleton_instance.vertex_key_failure_counts = (
current_vertex_failure_counts
)
logger.info(
"Inherited failure counts for applicable Vertex keys.")
logger.info("Inherited failure counts for applicable Vertex keys.")
_preserved_vertex_failure_counts = None
# 2. 调整 key_cycle 的起始点
@@ -351,7 +349,7 @@ async def get_key_manager_instance(
break
except ValueError:
logger.warning(
f"Preserved next key '{_preserved_vertex_next_key_in_cycle}' not found in preserved old Vertex API keys. "
f"Preserved next key '{_preserved_vertex_next_key_in_cycle}' not found in preserved old Vertex Express API keys. "
"New cycle will start from the beginning of the new list."
)
except Exception as e:
@@ -372,12 +370,12 @@ async def get_key_manager_instance(
)
except ValueError:
logger.warning(
f"Determined start key '{start_key_for_new_vertex_cycle}' not found in new Vertex API keys during cycle advancement. "
f"Determined start key '{start_key_for_new_vertex_cycle}' not found in new Vertex Express API keys during cycle advancement. "
"New cycle will start from the beginning."
)
except StopIteration:
logger.error(
"StopIteration while advancing Vertex key cycle, implies empty new Vertex API key list previously missed."
"StopIteration while advancing Vertex key cycle, implies empty new Vertex Express API key list previously missed."
)
except Exception as e:
logger.error(
@@ -386,11 +384,11 @@ async def get_key_manager_instance(
else:
if _singleton_instance.vertex_api_keys:
logger.info(
"New Vertex key cycle will start from the beginning of the new Vertex API key list (no specific start key determined or needed)."
"New Vertex key cycle will start from the beginning of the new Vertex Express API key list (no specific start key determined or needed)."
)
else:
logger.info(
"New Vertex key cycle not applicable as the new Vertex API key list is empty."
"New Vertex key cycle not applicable as the new Vertex Express API key list is empty."
)
# 清理所有保存的状态
@@ -411,11 +409,15 @@ async def reset_key_manager_instance():
if _singleton_instance:
# 1. 保存失败计数
_preserved_failure_counts = _singleton_instance.key_failure_counts.copy()
_preserved_vertex_failure_counts = _singleton_instance.vertex_key_failure_counts.copy()
_preserved_vertex_failure_counts = (
_singleton_instance.vertex_key_failure_counts.copy()
)
# 2. 保存旧的 API keys 列表
_preserved_old_api_keys_for_reset = _singleton_instance.api_keys.copy()
_preserved_vertex_old_api_keys_for_reset = _singleton_instance.vertex_api_keys.copy()
_preserved_vertex_old_api_keys_for_reset = (
_singleton_instance.vertex_api_keys.copy()
)
# 3. 保存 key_cycle 的下一个 key 提示
try:
@@ -431,8 +433,7 @@ async def reset_key_manager_instance():
)
_preserved_next_key_in_cycle = None
except Exception as e:
logger.error(
f"Error preserving next key hint during reset: {e}")
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_next_key_in_cycle = None
# 4. 保存 vertex_key_cycle 的下一个 key 提示
@@ -449,8 +450,7 @@ async def reset_key_manager_instance():
)
_preserved_vertex_next_key_in_cycle = None
except Exception as e:
logger.error(
f"Error preserving next key hint during reset: {e}")
logger.error(f"Error preserving next key hint during reset: {e}")
_preserved_vertex_next_key_in_cycle = None
_singleton_instance = None

View File

@@ -8,6 +8,7 @@ from typing import Optional
from google import genai
from app.config.config import settings
from app.core.constants import TTS_VOICE_NAMES
from app.database.services import add_error_log, add_request_log
from app.domain.openai_models import TTSRequest
from app.log.logger import get_openai_logger
@@ -47,7 +48,7 @@ class TTSService:
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": settings.TTS_VOICE_NAME
"voice_name": request.voice if request.voice in TTS_VOICE_NAMES else settings.TTS_VOICE_NAME
}
}
},

View File

@@ -5,12 +5,15 @@ const ARRAY_INPUT_CLASS = "array-input";
const MAP_ITEM_CLASS = "map-item";
const MAP_KEY_INPUT_CLASS = "map-key-input";
const MAP_VALUE_INPUT_CLASS = "map-value-input";
const CUSTOM_HEADER_ITEM_CLASS = "custom-header-item";
const CUSTOM_HEADER_KEY_INPUT_CLASS = "custom-header-key-input";
const CUSTOM_HEADER_VALUE_INPUT_CLASS = "custom-header-value-input";
const SAFETY_SETTING_ITEM_CLASS = "safety-setting-item";
const SHOW_CLASS = "show"; // For modals
const API_KEY_REGEX = /AIzaSy\S{33}/g;
const PROXY_REGEX =
/(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g;
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_]{50}/g; // 新增 Vertex API Key 正则
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_]{50}/g; // 新增 Vertex Express API Key 正则
const MASKED_VALUE = "••••••••";
// DOM Elements - Global Scope for frequently accessed elements
@@ -32,7 +35,7 @@ const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput");
const resetConfirmModal = document.getElementById("resetConfirmModal");
const configForm = document.getElementById("configForm"); // Added for frequent use
// Vertex API Key Modal Elements
// Vertex Express API Key Modal Elements
const vertexApiKeyModal = document.getElementById("vertexApiKeyModal");
const vertexApiKeyBulkInput = document.getElementById("vertexApiKeyBulkInput");
const bulkDeleteVertexApiKeyModal = document.getElementById(
@@ -383,9 +386,15 @@ document.addEventListener("DOMContentLoaded", function () {
addSafetySettingBtn.addEventListener("click", () => addSafetySettingItem());
}
// Add Custom Header button
const addCustomHeaderBtn = document.getElementById("addCustomHeaderBtn");
if (addCustomHeaderBtn) {
addCustomHeaderBtn.addEventListener("click", () => addCustomHeaderItem());
}
initializeSensitiveFields(); // Initialize sensitive field handling
// Vertex API Key Modal Elements and Events
// Vertex Express API Key Modal Elements and Events
const addVertexApiKeyBtn = document.getElementById("addVertexApiKeyBtn");
const closeVertexApiKeyModalBtn = document.getElementById(
"closeVertexApiKeyModalBtn"
@@ -691,6 +700,14 @@ async function initConfig() {
) {
config.THINKING_BUDGET_MAP = {}; // 默认为空对象
}
// --- 新增:处理 CUSTOM_HEADERS 默认值 ---
if (
!config.CUSTOM_HEADERS ||
typeof config.CUSTOM_HEADERS !== "object" ||
config.CUSTOM_HEADERS === null
) {
config.CUSTOM_HEADERS = {}; // 默认为空对象
}
// --- 新增:处理 SAFETY_SETTINGS 默认值 ---
if (!config.SAFETY_SETTINGS || !Array.isArray(config.SAFETY_SETTINGS)) {
config.SAFETY_SETTINGS = []; // 默认为空数组
@@ -756,6 +773,7 @@ async function initConfig() {
VERTEX_EXPRESS_BASE_URL: "", // 确保默认值存在
THINKING_MODELS: [],
THINKING_BUDGET_MAP: {},
CUSTOM_HEADERS: {},
AUTO_DELETE_ERROR_LOGS_ENABLED: false,
AUTO_DELETE_ERROR_LOGS_DAYS: 7, // 新增默认值
AUTO_DELETE_REQUEST_LOGS_ENABLED: false, // 新增默认值
@@ -854,6 +872,26 @@ function populateForm(config) {
'<div class="text-gray-500 text-sm italic">请在上方添加思考模型,预算将自动关联。</div>';
}
// Populate CUSTOM_HEADERS
const customHeadersContainer = document.getElementById(
"CUSTOM_HEADERS_container"
);
let customHeadersAdded = false;
if (
customHeadersContainer &&
config.CUSTOM_HEADERS &&
typeof config.CUSTOM_HEADERS === "object"
) {
for (const [key, value] of Object.entries(config.CUSTOM_HEADERS)) {
createAndAppendCustomHeaderItem(key, value);
customHeadersAdded = true;
}
}
if (!customHeadersAdded && customHeadersContainer) {
customHeadersContainer.innerHTML =
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
}
// 4. Populate other array fields (excluding THINKING_MODELS)
for (const [key, value] of Object.entries(config)) {
if (Array.isArray(value) && key !== "THINKING_MODELS") {
@@ -1179,17 +1217,13 @@ function handleBulkDeleteProxies() {
}
/**
* Handles the bulk addition of Vertex API keys from the modal input.
* Handles the bulk addition of Vertex Express API keys from the modal input.
*/
function handleBulkAddVertexApiKeys() {
const vertexApiKeyContainer = document.getElementById(
"VERTEX_API_KEYS_container"
);
if (
!vertexApiKeyBulkInput ||
!vertexApiKeyContainer ||
!vertexApiKeyModal
) {
if (!vertexApiKeyBulkInput || !vertexApiKeyContainer || !vertexApiKeyModal) {
return;
}
@@ -1239,7 +1273,7 @@ function handleBulkAddVertexApiKeys() {
}
/**
* Handles the bulk deletion of Vertex API keys based on input from the modal.
* Handles the bulk deletion of Vertex Express API keys based on input from the modal.
*/
function handleBulkDeleteVertexApiKeys() {
const vertexApiKeyContainer = document.getElementById(
@@ -1255,7 +1289,7 @@ function handleBulkDeleteVertexApiKeys() {
const bulkText = bulkDeleteVertexApiKeyInput.value;
if (!bulkText.trim()) {
showNotification("请粘贴需要删除的 Vertex API 密钥", "warning");
showNotification("请粘贴需要删除的 Vertex Express API 密钥", "warning");
return;
}
@@ -1263,13 +1297,15 @@ function handleBulkDeleteVertexApiKeys() {
if (keysToDelete.size === 0) {
showNotification(
"未在输入内容中提取到有效的 Vertex API 密钥格式",
"未在输入内容中提取到有效的 Vertex Express API 密钥格式",
"warning"
);
return;
}
const keyItems = vertexApiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
const keyItems = vertexApiKeyContainer.querySelectorAll(
`.${ARRAY_ITEM_CLASS}`
);
let deleteCount = 0;
keyItems.forEach((item) => {
@@ -1290,7 +1326,10 @@ function handleBulkDeleteVertexApiKeys() {
closeModal(bulkDeleteVertexApiKeyModal);
if (deleteCount > 0) {
showNotification(`成功删除了 ${deleteCount} 个匹配的 Vertex 密钥`, "success");
showNotification(
`成功删除了 ${deleteCount} 个匹配的 Vertex 密钥`,
"success"
);
} else {
showNotification("列表中未找到您输入的任何 Vertex 密钥进行删除", "info");
}
@@ -1305,8 +1344,10 @@ function switchTab(tabId) {
console.log(`Switching to tab: ${tabId}`);
// 定义选中态和未选中态的样式
const activeStyle = "background-color: #3b82f6 !important; color: #ffffff !important; border: 2px solid #2563eb !important; box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4), 0 2px 6px -1px rgba(59, 130, 246, 0.2) !important; transform: translateY(-2px) !important; font-weight: 600 !important;";
const inactiveStyle = "background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important; transform: none !important;";
const activeStyle =
"background-color: #3b82f6 !important; color: #ffffff !important; border: 2px solid #2563eb !important; box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4), 0 2px 6px -1px rgba(59, 130, 246, 0.2) !important; transform: translateY(-2px) !important; font-weight: 600 !important;";
const inactiveStyle =
"background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important; transform: none !important;";
// 更新标签按钮状态
const tabButtons = document.querySelectorAll(".tab-btn");
@@ -1439,8 +1480,7 @@ function addArrayItemWithValue(key, value) {
const isThinkingModel = key === "THINKING_MODELS";
const isAllowedToken = key === "ALLOWED_TOKENS";
const isVertexApiKey = key === "VERTEX_API_KEYS"; // 新增判断
const isSensitive =
key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
const isSensitive = key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
const modelId = isThinkingModel ? generateUUID() : null;
const arrayItem = document.createElement("div");
@@ -1562,6 +1602,67 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) {
container.appendChild(mapItem);
}
/**
* Adds a new custom header item to the DOM.
*/
function addCustomHeaderItem() {
createAndAppendCustomHeaderItem("", "");
}
/**
* Creates and appends a DOM element for a custom header.
* @param {string} key - The header key.
* @param {string} value - The header value.
*/
function createAndAppendCustomHeaderItem(key, value) {
const container = document.getElementById("CUSTOM_HEADERS_container");
if (!container) {
console.error(
"Cannot add custom header: CUSTOM_HEADERS_container not found!"
);
return;
}
const placeholder = container.querySelector(".text-gray-500.italic");
if (
placeholder &&
container.children.length === 1 &&
container.firstChild === placeholder
) {
container.innerHTML = "";
}
const headerItem = document.createElement("div");
headerItem.className = `${CUSTOM_HEADER_ITEM_CLASS} flex items-center mb-2 gap-2`;
const keyInput = document.createElement("input");
keyInput.type = "text";
keyInput.value = key;
keyInput.placeholder = "Header Name";
keyInput.className = `${CUSTOM_HEADER_KEY_INPUT_CLASS} flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none bg-gray-100 text-gray-500`;
const valueInput = document.createElement("input");
valueInput.type = "text";
valueInput.value = value;
valueInput.placeholder = "Header Value";
valueInput.className = `${CUSTOM_HEADER_VALUE_INPUT_CLASS} flex-grow px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50`;
const removeBtn = createRemoveButton();
removeBtn.addEventListener("click", () => {
headerItem.remove();
if (container.children.length === 0) {
container.innerHTML =
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
}
});
headerItem.appendChild(keyInput);
headerItem.appendChild(valueInput);
headerItem.appendChild(removeBtn);
container.appendChild(headerItem);
}
/**
* Collects all data from the configuration form.
* @returns {object} An object containing all configuration data.
@@ -1638,6 +1739,26 @@ function collectFormData() {
});
}
const customHeadersContainer = document.getElementById(
"CUSTOM_HEADERS_container"
);
if (customHeadersContainer) {
formData["CUSTOM_HEADERS"] = {};
const customHeaderItems = customHeadersContainer.querySelectorAll(
`.${CUSTOM_HEADER_ITEM_CLASS}`
);
customHeaderItems.forEach((item) => {
const keyInput = item.querySelector(`.${CUSTOM_HEADER_KEY_INPUT_CLASS}`);
const valueInput = item.querySelector(
`.${CUSTOM_HEADER_VALUE_INPUT_CLASS}`
);
if (keyInput && valueInput && keyInput.value.trim() !== "") {
formData["CUSTOM_HEADERS"][keyInput.value.trim()] =
valueInput.value.trim();
}
});
}
if (safetySettingsContainer) {
formData["SAFETY_SETTINGS"] = [];
const settingItems = safetySettingsContainer.querySelectorAll(

View File

@@ -817,58 +817,11 @@ function toggleSection(header, sectionId) {
}
}
// 筛选有效密钥(根据失败次数阈值)并更新批量操作状态
// filterValidKeys 函数已被 filterAndSearchValidKeys 替代,此函数保留为空或可移除
function filterValidKeys() {
const thresholdInput = document.getElementById("failCountThreshold");
const validKeysList = document.getElementById("validKeys"); // Get the UL element
if (!validKeysList) return; // Exit if the list doesn't exist
const validKeyItems = validKeysList.querySelectorAll("li[data-key]"); // Select li elements within the list
// 读取阈值如果输入无效或为空则默认为0不过滤
const threshold = parseInt(thresholdInput.value, 10);
const filterThreshold = isNaN(threshold) || threshold < 0 ? 0 : threshold;
let hasVisibleItems = false;
validKeyItems.forEach((item) => {
// 确保只处理包含 data-fail-count 的 li 元素
if (item.dataset.failCount !== undefined) {
const failCount = parseInt(item.dataset.failCount, 10);
// 如果失败次数大于等于阈值,则显示,否则隐藏
if (failCount >= filterThreshold) {
item.style.display = "flex"; // 使用 flex 因为 li 现在是 flex 容器
hasVisibleItems = true;
} else {
item.style.display = "none"; // 隐藏
// 如果隐藏了一个项,取消其选中状态
const checkbox = item.querySelector(".key-checkbox");
if (checkbox && checkbox.checked) {
checkbox.checked = false;
}
}
}
});
// 更新有效密钥的批量操作状态和全选复选框
updateBatchActions("valid");
// 处理"暂无有效密钥"消息
const noMatchMsgId = "no-valid-keys-msg";
let noMatchMsg = validKeysList.querySelector(`#${noMatchMsgId}`);
const initialKeyCount = validKeysList.querySelectorAll("li[data-key]").length; // 获取初始密钥数量
if (!hasVisibleItems && initialKeyCount > 0) {
// 仅当初始有密钥但现在都不可见时显示
if (!noMatchMsg) {
noMatchMsg = document.createElement("li");
noMatchMsg.id = noMatchMsgId;
noMatchMsg.className = "text-center text-gray-500 py-4 col-span-full";
noMatchMsg.textContent = "没有符合条件的有效密钥";
validKeysList.appendChild(noMatchMsg);
}
noMatchMsg.style.display = "";
} else if (noMatchMsg) {
noMatchMsg.style.display = "none";
}
// This function is now handled by filterAndSearchValidKeys
// Kept for now to avoid breaking any potential legacy calls, but should be removed later.
filterAndSearchValidKeys();
}
// --- Initialization Helper Functions ---

View File

@@ -96,12 +96,7 @@ endblock %} {% block head_extra_styles %}
/* Theming for select fields - 改进下拉框样式 */
.form-select-themed {
background-color: rgba(
255,
255,
255,
0.95
) !important; /* 白色背景 */
background-color: rgba(255, 255, 255, 0.95) !important; /* 白色背景 */
border: 1px solid rgba(0, 0, 0, 0.12) !important; /* 灰色边框 */
color: #374151 !important; /* gray-700 文字颜色 */
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M6 8l4 4 4-4'/%3e%3c/svg%3e") !important; /* 灰色箭头 */
@@ -124,7 +119,12 @@ endblock %} {% block head_extra_styles %}
}
.form-select-themed option {
background-color: rgba(255, 255, 255, 0.98) !important; /* white background */
background-color: rgba(
255,
255,
255,
0.98
) !important; /* white background */
color: #374151 !important; /* gray-700 */
padding: 8px !important;
}
@@ -141,22 +141,42 @@ endblock %} {% block head_extra_styles %}
}
#LOG_LEVEL option[value="INFO"] {
background-color: rgba(249, 250, 251, 0.98) !important; /* gray-50 浅灰背景 */
background-color: rgba(
249,
250,
251,
0.98
) !important; /* gray-50 浅灰背景 */
color: #374151 !important; /* gray-700 深灰色文字 */
}
#LOG_LEVEL option[value="WARNING"] {
background-color: rgba(243, 244, 246, 0.98) !important; /* gray-100 稍深灰背景 */
background-color: rgba(
243,
244,
246,
0.98
) !important; /* gray-100 稍深灰背景 */
color: #374151 !important; /* gray-700 深灰色文字 */
}
#LOG_LEVEL option[value="ERROR"] {
background-color: rgba(229, 231, 235, 0.98) !important; /* gray-200 中灰背景 */
background-color: rgba(
229,
231,
235,
0.98
) !important; /* gray-200 中灰背景 */
color: #374151 !important; /* gray-700 深灰色文字 */
}
#LOG_LEVEL option[value="CRITICAL"] {
background-color: rgba(209, 213, 219, 0.98) !important; /* gray-300 深灰背景 */
background-color: rgba(
209,
213,
219,
0.98
) !important; /* gray-300 深灰背景 */
color: #374151 !important; /* gray-700 深灰色文字 */
}
@@ -228,7 +248,12 @@ endblock %} {% block head_extra_styles %}
}
.generate-btn:hover {
background-color: rgba(59, 130, 246, 0.2) !important; /* blue-500 more opaque */
background-color: rgba(
59,
130,
246,
0.2
) !important; /* blue-500 more opaque */
color: #1d4ed8 !important; /* blue-700 */
box-shadow: 0 0 8px rgba(59, 130, 246, 0.3) !important;
}
@@ -302,36 +327,47 @@ endblock %} {% block head_extra_styles %}
}
/* Override all violet/purple buttons to light blue */
.bg-violet-600, button.bg-violet-600 {
.bg-violet-600,
button.bg-violet-600 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
}
.bg-violet-600:hover, button.bg-violet-600:hover,
.bg-violet-600:hover,
button.bg-violet-600:hover,
.hover\\:bg-violet-700:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
}
/* Override blue buttons to light blue */
.bg-blue-600, button.bg-blue-600 {
.bg-blue-600,
button.bg-blue-600 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
}
.bg-blue-600:hover, button.bg-blue-600:hover,
.bg-blue-600:hover,
button.bg-blue-600:hover,
.hover\\:bg-blue-700:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
}
/* Override red buttons to bright light red */
.bg-red-600, button.bg-red-600,
.bg-red-700, button.bg-red-700,
.bg-red-800, button.bg-red-800 {
.bg-red-600,
button.bg-red-600,
.bg-red-700,
button.bg-red-700,
.bg-red-800,
button.bg-red-800 {
background-color: #f87171 !important; /* red-400 - bright light red */
}
.bg-red-600:hover, button.bg-red-600:hover,
.bg-red-700:hover, button.bg-red-700:hover,
.bg-red-800:hover, button.bg-red-800:hover,
.hover\\:bg-red-700:hover, .hover\\:bg-red-800:hover {
.bg-red-600:hover,
button.bg-red-600:hover,
.bg-red-700:hover,
button.bg-red-700:hover,
.bg-red-800:hover,
button.bg-red-800:hover,
.hover\\:bg-red-700:hover,
.hover\\:bg-red-800:hover {
background-color: #ef4444 !important; /* red-500 - darker bright light red */
}
@@ -349,7 +385,8 @@ endblock %} {% block head_extra_styles %}
button.tab-btn.active {
background-color: #3b82f6 !important; /* blue-500 */
color: #ffffff !important; /* 确保白色文字 */
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4), 0 2px 6px -1px rgba(59, 130, 246, 0.2) !important; /* 蓝色阴影 */
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4),
0 2px 6px -1px rgba(59, 130, 246, 0.2) !important; /* 蓝色阴影 */
transform: translateY(-2px) !important; /* 更明显的上移效果 */
border: 2px solid #2563eb !important; /* blue-600 边框 */
font-weight: 600 !important; /* 加粗字体 */
@@ -388,7 +425,8 @@ endblock %} {% block head_extra_styles %}
background-color: #3b82f6 !important;
color: #ffffff !important;
border: 2px solid #2563eb !important;
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4), 0 2px 6px -1px rgba(59, 130, 246, 0.2) !important;
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4),
0 2px 6px -1px rgba(59, 130, 246, 0.2) !important;
transform: translateY(-2px) !important;
font-weight: 600 !important;
}
@@ -409,7 +447,8 @@ endblock %} {% block head_extra_styles %}
background-color: rgba(255, 255, 255, 0.98) !important;
color: #374151 !important; /* gray-700 */
border-color: rgba(0, 0, 0, 0.08) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04) !important;
}
/* Fix modal titles */
@@ -428,62 +467,78 @@ endblock %} {% block head_extra_styles %}
}
/* Fix modal body text */
.modal p, .modal label, .modal span {
.modal p,
.modal label,
.modal span {
color: #374151 !important; /* gray-700 */
}
/* Fix modal textarea and input styling */
.modal textarea, .modal input {
.modal textarea,
.modal input {
background-color: rgba(255, 255, 255, 0.95) !important;
color: #374151 !important; /* gray-700 */
border: 1px solid rgba(0, 0, 0, 0.12) !important;
}
.modal textarea:focus, .modal input:focus {
.modal textarea:focus,
.modal input:focus {
border-color: #3b82f6 !important; /* blue-500 */
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
/* Fix modal button styling */
.modal .bg-violet-600, .modal button.bg-violet-600 {
.modal .bg-violet-600,
.modal button.bg-violet-600 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
color: #ffffff !important;
border: 1px solid #2563eb !important; /* blue-600 */
}
.modal .bg-violet-600:hover, .modal button.bg-violet-600:hover {
.modal .bg-violet-600:hover,
.modal button.bg-violet-600:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
transform: translateY(-1px) !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
/* Fix modal blue button styling */
.modal .bg-blue-500, .modal button.bg-blue-500,
.modal .bg-blue-600, .modal button.bg-blue-600,
.modal .bg-blue-700, .modal button.bg-blue-700 {
.modal .bg-blue-500,
.modal button.bg-blue-500,
.modal .bg-blue-600,
.modal button.bg-blue-600,
.modal .bg-blue-700,
.modal button.bg-blue-700 {
background-color: #3b82f6 !important; /* blue-500 - light blue */
color: #ffffff !important;
border: 1px solid #2563eb !important; /* blue-600 */
}
.modal .bg-blue-500:hover, .modal button.bg-blue-500:hover,
.modal .bg-blue-600:hover, .modal button.bg-blue-600:hover,
.modal .bg-blue-700:hover, .modal button.bg-blue-700:hover {
.modal .bg-blue-500:hover,
.modal button.bg-blue-500:hover,
.modal .bg-blue-600:hover,
.modal button.bg-blue-600:hover,
.modal .bg-blue-700:hover,
.modal button.bg-blue-700:hover {
background-color: #2563eb !important; /* blue-600 - darker light blue */
transform: translateY(-1px) !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
/* Fix modal cancel/secondary buttons */
.modal .bg-gray-500, .modal button.bg-gray-500,
.modal .bg-gray-600, .modal button.bg-gray-600 {
.modal .bg-gray-500,
.modal button.bg-gray-500,
.modal .bg-gray-600,
.modal button.bg-gray-600 {
background-color: #e5e7eb !important; /* gray-200 - light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
border: 1px solid #d1d5db !important; /* gray-300 */
}
.modal .bg-gray-500:hover, .modal button.bg-gray-500:hover,
.modal .bg-gray-600:hover, .modal button.bg-gray-600:hover {
.modal .bg-gray-500:hover,
.modal button.bg-gray-500:hover,
.modal .bg-gray-600:hover,
.modal button.bg-gray-600:hover {
background-color: #d1d5db !important; /* gray-300 - darker light gray */
color: #374151 !important; /* gray-700 - dark text for contrast */
transform: translateY(-1px) !important;
@@ -491,17 +546,23 @@ endblock %} {% block head_extra_styles %}
}
/* Fix modal red/danger buttons */
.modal .bg-red-500, .modal button.bg-red-500,
.modal .bg-red-600, .modal button.bg-red-600,
.modal .bg-red-700, .modal button.bg-red-700 {
.modal .bg-red-500,
.modal button.bg-red-500,
.modal .bg-red-600,
.modal button.bg-red-600,
.modal .bg-red-700,
.modal button.bg-red-700 {
background-color: #f87171 !important; /* red-400 - bright light red */
color: #ffffff !important;
border: 1px solid #ef4444 !important; /* red-500 */
}
.modal .bg-red-500:hover, .modal button.bg-red-500:hover,
.modal .bg-red-600:hover, .modal button.bg-red-600:hover,
.modal .bg-red-700:hover, .modal button.bg-red-700:hover {
.modal .bg-red-500:hover,
.modal button.bg-red-500:hover,
.modal .bg-red-600:hover,
.modal button.bg-red-600:hover,
.modal .bg-red-700:hover,
.modal button.bg-red-700:hover {
background-color: #ef4444 !important; /* red-500 - darker bright light red */
transform: translateY(-1px) !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
@@ -542,27 +603,54 @@ endblock %} {% block head_extra_styles %}
}
/* Comprehensive button text color fixes */
.bg-blue-500, .bg-blue-600, .bg-blue-700,
.bg-red-500, .bg-red-600, .bg-red-700, .bg-red-800,
.bg-green-500, .bg-green-600, .bg-green-700,
.bg-sky-500, .bg-sky-600, .bg-sky-700,
.bg-purple-500, .bg-purple-600, .bg-purple-700,
.bg-violet-500, .bg-violet-600, .bg-violet-700 {
.bg-blue-500,
.bg-blue-600,
.bg-blue-700,
.bg-red-500,
.bg-red-600,
.bg-red-700,
.bg-red-800,
.bg-green-500,
.bg-green-600,
.bg-green-700,
.bg-sky-500,
.bg-sky-600,
.bg-sky-700,
.bg-purple-500,
.bg-purple-600,
.bg-purple-700,
.bg-violet-500,
.bg-violet-600,
.bg-violet-700 {
color: #ffffff !important;
}
/* Ensure button children inherit white text */
.bg-blue-500 *, .bg-blue-600 *, .bg-blue-700 *,
.bg-red-500 *, .bg-red-600 *, .bg-red-700 *, .bg-red-800 *,
.bg-green-500 *, .bg-green-600 *, .bg-green-700 *,
.bg-sky-500 *, .bg-sky-600 *, .bg-sky-700 *,
.bg-purple-500 *, .bg-purple-600 *, .bg-purple-700 *,
.bg-violet-500 *, .bg-violet-600 *, .bg-violet-700 * {
.bg-blue-500 *,
.bg-blue-600 *,
.bg-blue-700 *,
.bg-red-500 *,
.bg-red-600 *,
.bg-red-700 *,
.bg-red-800 *,
.bg-green-500 *,
.bg-green-600 *,
.bg-green-700 *,
.bg-sky-500 *,
.bg-sky-600 *,
.bg-sky-700 *,
.bg-purple-500 *,
.bg-purple-600 *,
.bg-purple-700 *,
.bg-violet-500 *,
.bg-violet-600 *,
.bg-violet-700 * {
color: inherit !important;
}
/* Fix page title gradient - comprehensive override */
h1.text-transparent, .text-transparent.bg-clip-text,
h1.text-transparent,
.text-transparent.bg-clip-text,
.bg-gradient-to-r.from-violet-400.to-pink-400 {
background: none !important;
color: #1f2937 !important; /* gray-800 - consistent with other pages */
@@ -579,15 +667,24 @@ endblock %} {% block head_extra_styles %}
}
/* Ensure all violet/purple colors are converted to blue theme */
.text-violet-300, .text-violet-400, .text-violet-100 {
.text-violet-300,
.text-violet-400,
.text-violet-100 {
color: #3b82f6 !important; /* blue-500 */
}
.border-violet-300, .border-violet-400 {
border-color: rgba(59, 130, 246, 0.3) !important; /* blue-500 with opacity */
.border-violet-300,
.border-violet-400 {
border-color: rgba(
59,
130,
246,
0.3
) !important; /* blue-500 with opacity */
}
.hover\\:text-violet-400:hover, .hover\\:text-violet-100:hover {
.hover\\:text-violet-400:hover,
.hover\\:text-violet-100:hover {
color: #2563eb !important; /* blue-600 */
}
@@ -613,7 +710,9 @@ endblock %} {% block head_extra_styles %}
}
/* Even more specific selector targeting the exact auth token wrapper */
.mb-6 .flex.items-center div.flex.items-center.flex-grow.border.rounded-md:focus-within {
.mb-6
.flex.items-center
div.flex.items-center.flex-grow.border.rounded-md:focus-within {
border-color: #3b82f6 !important; /* blue-500 */
}
@@ -625,7 +724,12 @@ endblock %} {% block head_extra_styles %}
.focus\\:ring-primary-200:focus,
.focus\\:ring-primary-300:focus {
--tw-ring-color: rgba(59, 130, 246, 0.2) !important; /* blue-500 with opacity */
--tw-ring-color: rgba(
59,
130,
246,
0.2
) !important; /* blue-500 with opacity */
}
/* Fix select element styling */
@@ -645,15 +749,18 @@ endblock %} {% block head_extra_styles %}
}
/* Override any remaining primary colors */
.text-primary-600, .text-primary-500 {
.text-primary-600,
.text-primary-500 {
color: #3b82f6 !important; /* blue-500 */
}
.bg-primary-600, .bg-primary-500 {
.bg-primary-600,
.bg-primary-500 {
background-color: #3b82f6 !important; /* blue-500 */
}
.bg-primary-700:hover, .hover\\:bg-primary-700:hover {
.bg-primary-700:hover,
.hover\\:bg-primary-700:hover {
background-color: #2563eb !important; /* blue-600 */
}
@@ -693,9 +800,7 @@ endblock %} {% block head_extra_styles %}
<i class="fas fa-sync-alt"></i>
</button>
<h1
class="text-3xl font-extrabold text-center text-gray-800 mb-4"
>
<h1 class="text-3xl font-extrabold text-center text-gray-800 mb-4">
<img
src="/static/icons/logo.png"
alt="Gemini Balance Logo"
@@ -705,11 +810,13 @@ endblock %} {% block head_extra_styles %}
</h1>
<!-- Navigation Tabs -->
<div class="nav-buttons-container flex justify-center mb-8 overflow-x-auto gap-2">
<div
class="nav-buttons-container flex justify-center mb-8 overflow-x-auto gap-2"
>
<a
href="/config"
class="main-nav-btn whitespace-nowrap flex items-center justify-center gap-2 px-6 py-3 font-medium rounded-lg shadow-md hover:shadow-lg transition-all duration-200"
style="background-color: #3b82f6 !important; color: #ffffff !important;"
style="background-color: #3b82f6 !important; color: #ffffff !important"
>
<i class="fas fa-cog"></i> 配置编辑
</a>
@@ -734,49 +841,93 @@ endblock %} {% block head_extra_styles %}
<button
class="tab-btn active px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="api"
style="background-color: #3b82f6 !important; color: #ffffff !important; border: 2px solid #2563eb !important; box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4), 0 2px 6px -1px rgba(59, 130, 246, 0.2) !important; transform: translateY(-2px) !important; font-weight: 600 !important;"
style="
background-color: #3b82f6 !important;
color: #ffffff !important;
border: 2px solid #2563eb !important;
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4),
0 2px 6px -1px rgba(59, 130, 246, 0.2) !important;
transform: translateY(-2px) !important;
font-weight: 600 !important;
"
>
API配置
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="model"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
模型配置
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="tts"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
TTS 配置
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="image"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
图像生成
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="stream"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
流式输出
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="scheduler"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
定时任务
</button>
<button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="logging"
style="background-color: #f8fafc !important; color: #64748b !important; border: 2px solid #e2e8f0 !important; box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important; font-weight: 500 !important;"
style="
background-color: #f8fafc !important;
color: #64748b !important;
border: 2px solid #e2e8f0 !important;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1) !important;
font-weight: 500 !important;
"
>
日志配置
</button>
@@ -898,11 +1049,48 @@ endblock %} {% block head_extra_styles %}
/>
<small class="text-gray-500 mt-1 block">Gemini API的基础URL</small>
</div>
<!-- Vertex API密钥列表 -->
<!-- 自定义Headers -->
<div class="mb-6">
<label for="VERTEX_API_KEYS" class="block font-semibold mb-2 text-gray-700"
>Vertex API密钥列表</label
<label
for="CUSTOM_HEADERS"
class="block font-semibold mb-2 text-gray-700"
>自定义Headers</label
>
<div
class="bg-white rounded-lg border border-gray-200 p-4 mb-2 space-y-3"
id="CUSTOM_HEADERS_container"
style="
background-color: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.12);
color: #374151;
"
>
<!-- 键值对将在这里动态添加 -->
<div class="text-gray-500 text-sm italic">
添加自定义请求头,例如 X-Api-Key: your-key
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
id="addCustomHeaderBtn"
>
<i class="fas fa-plus"></i> 添加Header
</button>
</div>
<small class="text-gray-500 mt-1 block"
>在这里添加的键值对将被添加到所有出站API请求的Header中。</small
>
</div>
<!-- Vertex Express API密钥列表 -->
<div class="mb-6">
<label
for="VERTEX_API_KEYS"
class="block font-semibold mb-2 text-gray-700"
>Vertex Express API密钥列表</label
>
<div class="array-container" id="VERTEX_API_KEYS_container">
<!-- 数组项将在这里动态添加 -->
@@ -927,10 +1115,12 @@ endblock %} {% block head_extra_styles %}
>Vertex AI Platform API密钥列表。点击按钮可批量添加或删除。</small
>
</div>
<!-- Vertex Express API基础URL -->
<div class="mb-6">
<label for="VERTEX_EXPRESS_BASE_URL" class="block font-semibold mb-2 text-gray-700"
<label
for="VERTEX_EXPRESS_BASE_URL"
class="block font-semibold mb-2 text-gray-700"
>Vertex Express API基础URL</label
>
<input
@@ -940,30 +1130,32 @@ endblock %} {% block head_extra_styles %}
placeholder="https://aiplatform.googleapis.com/v1beta1/publishers/google/models"
class="w-full px-4 py-3 rounded-lg form-input-themed"
/>
<small class="text-gray-500 mt-1 block">Vertex Express API的基础URL</small>
<small class="text-gray-500 mt-1 block"
>Vertex Express API的基础URL</small
>
</div>
<!-- 智能路由配置 -->
<div class="mb-6">
<div class="flex items-center justify-between">
<label
for="URL_NORMALIZATION_ENABLED"
class="font-semibold text-gray-700"
<label
for="URL_NORMALIZATION_ENABLED"
class="font-semibold text-gray-700"
>启用智能路由映射</label
>
<div
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
>
<input
type="checkbox"
name="URL_NORMALIZATION_ENABLED"
id="URL_NORMALIZATION_ENABLED"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
/>
<label
for="URL_NORMALIZATION_ENABLED"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
>
<div
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
>
<input
type="checkbox"
name="URL_NORMALIZATION_ENABLED"
id="URL_NORMALIZATION_ENABLED"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
/>
<label
for="URL_NORMALIZATION_ENABLED"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
</div>
<small class="text-gray-500 mt-1 block">
自动客户端请求的url拼接为正确格式仅保证正常聊天出现问题请关闭
@@ -1057,28 +1249,28 @@ endblock %} {% block head_extra_styles %}
<!-- 代理使用策略 -->
<div class="mb-6">
<div class="flex items-center justify-between">
<label
for="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="font-semibold text-gray-700"
<label
for="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="font-semibold text-gray-700"
>是否开启固定代理策略</label
>
<div
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
>
<input
type="checkbox"
name="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
id="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
/>
<label
for="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
>
<div
class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"
>
<input
type="checkbox"
name="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
id="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer"
/>
<label
for="PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY"
class="toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer"
></label>
</div>
</div>
<small class="text-gray-500 mt-1 block"
>开启后对于每一个API_KEY将根据算法从代理列表中选取同一个代理IP防止一个API_KEY同时被多个IP访问也同时防止了一个IP访问了过多的API_KEY。</small
>开启后对于每一个API_KEY将根据算法从代理列表中选取同一个代理IP防止一个API_KEY同时被多个IP访问也同时防止了一个IP访问了过多的API_KEY。</small
>
</div>
</div>
@@ -1377,8 +1569,8 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<!-- TTS配置 -->
<!-- TTS配置 -->
<div class="config-section" id="tts-section">
<h2
class="text-xl font-bold mb-6 pb-3 border-b flex items-center gap-2 text-gray-800 border-violet-300 border-opacity-30"
@@ -1396,15 +1588,21 @@ endblock %} {% block head_extra_styles %}
name="TTS_MODEL"
class="w-full px-4 py-3 rounded-lg form-select-themed"
>
<option value="gemini-2.5-flash-preview-tts">gemini-2.5-flash-preview-tts</option>
<option value="gemini-2.5-pro-preview-tts">gemini-2.5-pro-preview-tts</option>
<option value="gemini-2.5-flash-preview-tts">
gemini-2.5-flash-preview-tts
</option>
<option value="gemini-2.5-pro-preview-tts">
gemini-2.5-pro-preview-tts
</option>
</select>
<small class="text-gray-500 mt-1 block">用于TTS的模型</small>
</div>
<!-- TTS 语音名称 -->
<div class="mb-6">
<label for="TTS_VOICE_NAME" class="block font-semibold mb-2 text-gray-700"
<label
for="TTS_VOICE_NAME"
class="block font-semibold mb-2 text-gray-700"
>TTS 语音名称</label
>
<select
@@ -1443,7 +1641,9 @@ endblock %} {% block head_extra_styles %}
<option value="Sadaltager">Sadaltager (博学)</option>
<option value="Sulafat">Sulafat (温暖)</option>
</select>
<small class="text-gray-500 mt-1 block">TTS 的语音名称,控制风格、语调、口音和节奏</small>
<small class="text-gray-500 mt-1 block"
>TTS 的语音名称,控制风格、语调、口音和节奏</small
>
</div>
<!-- TTS 语速 -->
@@ -1464,9 +1664,9 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
<!-- 图像生成相关配置 -->
<div class="config-section" id="image-section">
<h2
<!-- 图像生成相关配置 -->
<div class="config-section" id="image-section">
<h2
class="text-xl font-bold mb-6 pb-3 border-b flex items-center gap-2 text-gray-800 border-violet-300 border-opacity-30"
>
<i class="fas fa-image text-violet-400"></i> 图像生成配置
@@ -1602,6 +1802,25 @@ endblock %} {% block head_extra_styles %}
/>
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
</div>
<!-- Cloudflare上传文件夹 -->
<div class="mb-6 provider-config" data-provider="cloudflare_imgbed">
<label
for="CLOUDFLARE_IMGBED_UPLOAD_FOLDER"
class="block font-semibold mb-2 text-gray-700"
>Cloudflare上传文件夹</label
>
<input
type="text"
id="CLOUDFLARE_IMGBED_UPLOAD_FOLDER"
name="CLOUDFLARE_IMGBED_UPLOAD_FOLDER"
placeholder=""
class="w-full px-4 py-3 rounded-lg form-input-themed"
/>
<small class="text-gray-500 mt-1 block"
>Cloudflare图床的上传文件夹路径可选</small
>
</div>
</div>
<!-- 流式输出优化配置 -->
@@ -1956,12 +2175,17 @@ endblock %} {% block head_extra_styles %}
</div>
<!-- Action Buttons -->
<div class="flex flex-col md:flex-row justify-center gap-4 mt-8 pt-4 pb-2">
<div
class="flex flex-col md:flex-row justify-center gap-4 mt-8 pt-4 pb-2"
>
<button
type="button"
id="saveBtn"
class="action-btn text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 hover:shadow-lg flex items-center justify-center gap-2"
style="background-color: #3b82f6 !important; color: #ffffff !important;"
style="
background-color: #3b82f6 !important;
color: #ffffff !important;
"
>
<i class="fas fa-save"></i> 保存配置
</button>
@@ -1969,7 +2193,10 @@ endblock %} {% block head_extra_styles %}
type="button"
id="resetBtn"
class="action-btn bg-gradient-to-r from-gray-600 to-gray-700 text-white px-8 py-3 rounded-xl font-semibold transition-all duration-300 hover:shadow-lg flex items-center justify-center gap-2"
style="background-color: #6b7280 !important; color: #ffffff !important;"
style="
background-color: #6b7280 !important;
color: #ffffff !important;
"
>
<i class="fas fa-undo"></i> 重置配置
</button>
@@ -2230,8 +2457,8 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<!-- Vertex API Key Add Modal -->
<!-- Vertex Express API Key Add Modal -->
<div id="vertexApiKeyModal" class="modal">
<div
class="w-full max-w-lg mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
@@ -2243,7 +2470,9 @@ endblock %} {% block head_extra_styles %}
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量添加 Vertex API 密钥</h2>
<h2 class="text-xl font-bold text-gray-800">
批量添加 Vertex Express API 密钥
</h2>
<button
id="closeVertexApiKeyModalBtn"
class="text-gray-300 hover:text-gray-800 text-xl"
@@ -2252,12 +2481,13 @@ endblock %} {% block head_extra_styles %}
</button>
</div>
<p class="text-gray-300 mb-4">
每行粘贴一个或多个密钥,将自动提取有效密钥 (格式: AQ.开头共53位) 并去重。
每行粘贴一个或多个密钥,将自动提取有效密钥 (格式: AQ.开头共53位)
并去重。
</p>
<textarea
id="vertexApiKeyBulkInput"
rows="10"
placeholder="在此处粘贴 Vertex API 密钥..."
placeholder="在此处粘贴 Vertex Express API 密钥..."
class="w-full px-4 py-3 rounded-lg font-mono text-sm form-input-themed"
></textarea>
<div class="flex justify-end gap-3 mt-6">
@@ -2279,8 +2509,8 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<!-- Bulk Delete Vertex API Key Modal -->
<!-- Bulk Delete Vertex Express API Key Modal -->
<div id="bulkDeleteVertexApiKeyModal" class="modal">
<div
class="w-full max-w-lg mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
@@ -2292,7 +2522,9 @@ endblock %} {% block head_extra_styles %}
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold text-gray-800">批量删除 Vertex API 密钥</h2>
<h2 class="text-xl font-bold text-gray-800">
批量删除 Vertex Express API 密钥
</h2>
<button
id="closeBulkDeleteVertexModalBtn"
class="text-gray-300 hover:text-gray-800 text-xl"
@@ -2306,7 +2538,7 @@ endblock %} {% block head_extra_styles %}
<textarea
id="bulkDeleteVertexApiKeyInput"
rows="10"
placeholder="在此处粘贴要删除的 Vertex API 密钥..."
placeholder="在此处粘贴要删除的 Vertex Express API 密钥..."
class="w-full px-4 py-3 rounded-lg font-mono text-sm form-input-themed"
></textarea>
<div class="flex justify-end gap-3 mt-6">
@@ -2328,7 +2560,7 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
</div>
<!-- Model Helper Modal -->
<div id="modelHelperModal" class="modal">
<div
@@ -2456,8 +2688,10 @@ endblock %} {% block head_extra_styles %}
input.addEventListener("focus", function () {
const parentItem = this.closest(".map-item");
if (parentItem) {
parentItem.style.backgroundColor = "rgba(243, 244, 246, 1)"; /* gray-100 */
parentItem.style.borderColor = "rgba(59, 130, 246, 0.5)"; /* blue-500 */
parentItem.style.backgroundColor =
"rgba(243, 244, 246, 1)"; /* gray-100 */
parentItem.style.borderColor =
"rgba(59, 130, 246, 0.5)"; /* blue-500 */
}
});

View File

@@ -1245,6 +1245,7 @@ endblock %} {% block head_extra_styles %}
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="500">500</option>
</select>
<span class="text-sm select-none font-semibold" style="color: #1f2937 !important;"></span>
</div>

View File

@@ -261,18 +261,20 @@ class PicGoUploader(ImageUploader):
class CloudFlareImgBedUploader(ImageUploader):
"""CloudFlare图床上传器"""
def __init__(self, auth_code: str, api_url: str):
def __init__(self, auth_code: str, api_url: str, upload_folder: str = ""):
"""
初始化CloudFlare图床上传器
Args:
auth_code: 认证码
api_url: 上传API地址
upload_folder: 上传文件夹路径(可选)
"""
self.auth_code = auth_code
self.api_url = api_url
self.upload_folder = upload_folder
def upload(self, file: bytes, filename: str) -> UploadResponse:
"""
上传图片到CloudFlare图床
@@ -288,12 +290,16 @@ class CloudFlareImgBedUploader(ImageUploader):
UploadError: 上传失败时抛出异常
"""
try:
# 准备请求URL(添加认证码参数,如果存在)
# 准备请求URL参数
params = []
if self.upload_folder:
params.append(f"uploadFolder={self.upload_folder}")
if self.auth_code:
request_url = f"{self.api_url}?authCode={self.auth_code}&uploadNameType=origin"
else:
request_url = f"{self.api_url}?uploadNameType=origin"
params.append(f"authCode={self.auth_code}")
params.append("uploadNameType=origin")
request_url = f"{self.api_url}?{'&'.join(params)}"
# 准备文件数据
files = {
"file": (filename, file)
@@ -388,6 +394,7 @@ class ImageUploaderFactory:
elif provider == "cloudflare_imgbed":
return CloudFlareImgBedUploader(
credentials["auth_code"],
credentials["base_url"]
credentials["base_url"],
credentials.get("upload_folder", ""),
)
raise ValueError(f"Unknown provider: {provider}")