mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 06:11:32 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b837c3f80 | ||
|
|
a6cfc12443 | ||
|
|
f6d64dd850 | ||
|
|
eed62caa78 | ||
|
|
204d41d6f3 | ||
|
|
858df0548e | ||
|
|
b3da021803 | ||
|
|
d234f826f4 | ||
|
|
231b69ecf8 | ||
|
|
0a08913677 | ||
|
|
49d32813ea | ||
|
|
c5d57e97b1 | ||
|
|
da8f7539a1 | ||
|
|
64a68f1176 | ||
|
|
1199d7cc3c | ||
|
|
8a827d2acb | ||
|
|
0e8a943d7f | ||
|
|
4f62658440 | ||
|
|
6e7c3d5f6a | ||
|
|
d5062db9b6 | ||
|
|
a6ad006a49 |
@@ -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
|
||||
|
||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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密钥的失败计数,可选择性地仅重置有效或无效密钥"""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user