Compare commits

..

38 Commits

Author SHA1 Message Date
snaily
c5d57e97b1 chore: 更新版本号至2.1.8 2025-07-07 14:21:41 +08:00
lc631017672
64a68f1176 refactor: Remove debug logging for security checks 2025-07-07 10:27:48 +08:00
lc631017672
1199d7cc3c feat: Add support for countTokens API and improve debug logging 2025-07-07 10:08:57 +08:00
ry
8a827d2acb feat: 支持CloudFlare图床自定义上传文件夹路径
- 新增CLOUDFLARE_IMGBED_UPLOAD_FOLDER环境变量配置
- 用户可通过该配置项指定图片在CloudFlare图床中的上传路径
2025-07-05 23:32:45 +08:00
snaily
0e8a943d7f chore:更新 README 和 README_ZH 文件,调整徽章的 HTML 结构,使其居中显示。 2025-07-05 16:49:57 +08:00
snaily
4f62658440 Update README.md 2025-07-05 16:39:18 +08:00
snaily
6e7c3d5f6a Update README.md 2025-07-05 16:38:35 +08:00
snaily
d5062db9b6 Update README_ZH.md 2025-07-05 16:27:20 +08:00
snaily
a6ad006a49 Update README.md 2025-07-05 16:26:59 +08:00
snaily
57d593fa17 chore: 更新版本号至2.1.7 2025-07-05 00:48:50 +08:00
snaily
f38b5ae870 feat: 添加TTS相关配置和功能
- 在.env.example中添加TTS模型、语音名称和语速的配置选项
- 更新README文件,增加TTS相关配置的说明
- 在配置类中添加TTS相关设置
- 新增TTS请求模型以支持文本转语音功能
- 更新智能路由中间件以支持音频请求
- 在路由中添加处理TTS请求的API接口
- 更新前端配置编辑器以支持TTS配置选项
2025-07-05 00:47:55 +08:00
snaily
418b3ca13c Merge branch 'pr/BigLiao/172' 2025-07-03 23:44:02 +08:00
jesonliao
09bfa85e69 fix: 修复api中对role的校验
官方给的demo是不传role的
2025-07-03 23:08:31 +08:00
jesonliao
62b132208b fix: 修复数据库密码中包含特殊字符串时的问题 2025-07-03 22:23:47 +08:00
snaily
fc28f4f74e Merge branch 'pr/chinrain/167' 2025-07-03 17:28:58 +08:00
snaily
f79a52f839 fix:优化智能路由中间件,增强URL处理逻辑
- 增加对新路径模式的支持,包括对`v1beta/models`的处理
- 统一日志记录格式,提升调试信息的可读性
- 规范化代码风格,确保一致性和可维护性
- 修复了请求体和查询参数的模型名称提取逻辑
2025-07-03 17:25:50 +08:00
chinrain
94d1041961 Merge branch 'feat/AutoRoute' of https://github.com/chinrain/gemini-balance into feat/AutoRoute 2025-07-03 03:05:39 +08:00
chinrain
ada32d526a refactor: 简化智能路由中间件,优化混合格式URL处理
- 重构智能路由逻辑,在保证聊天的同时尽量简化
- 只会修改常见错误,其余的透传(方便以后维护或者不用维护)
- 常见错误都能正常聊天
- 统一前端样式
2025-07-03 03:01:10 +08:00
snaily
ef1e38aba1 fix: 在智能路由中间件中添加对请求体的JSON解析异常处理,确保在提取模型时的稳定性 2025-07-03 00:56:57 +08:00
snaily
60b2d59e25 fix:修正Gemini路径模式,移除末尾的斜杠以确保路径匹配的一致性 2025-07-03 00:45:11 +08:00
chinrain
e18aa73456 添加gemini前缀模型列表 2025-07-02 23:52:03 +08:00
chinrain
24747a5f09 移除重复配置 2025-07-02 23:41:48 +08:00
chinrain
621dac22dc Merge remote-tracking branch 'origin/main' into feat/AutoRoute 2025-07-01 02:41:18 +08:00
chinrain
23d7004b60 - 增加vertex-express支持
- 移除了不必要的判断流式请求的方法
2025-07-01 02:25:32 +08:00
snaily
c3b3d34127 Merge branch 'pr/stevessr/160' 2025-06-30 23:54:42 +08:00
chchchchc1023
18a166afb0 feat: 添加智能路由中间件,支持API路径自动规范化
- 新增SmartRoutingMiddleware智能路由中间件
- 支持OpenAI/HF/Gemini/默认格式的自动检测和转换
- 修复错误URL路径格式,提升API兼容性
- 添加URL_NORMALIZATION_ENABLED配置开关,默认关闭
- 智能路由功能默认关闭,需手动启用
2025-06-30 22:58:58 +08:00
stevessr
a41447a96d fix: 更新 thinkingBudget 的最大值限制至32767 , 最小值为 -1 2025-06-30 20:43:27 +08:00
Wangnov
df8d543539 删除ruff导致的格式化换行 2025-06-30 17:52:10 +08:00
Wangnov
5ecce8e0fe fix: 使用Union替代类型注解中的管道符号,使python3.9版本不报错 2025-06-30 17:37:02 +08:00
snaily
00f423a622 Update README.md 2025-06-28 00:00:22 +08:00
snaily
05ce04de69 Update README.md 2025-06-27 23:49:05 +08:00
snaily
cd5549e1aa chore: 更新版本号至2.1.6 2025-06-26 17:13:22 +08:00
snaily
f573c0255a Update README.md 2025-06-18 23:59:48 +08:00
snaily
060d7fffe6 docs: 在README中添加对支持者的感谢,并新增DigitalOcean的logo文件 2025-06-18 22:49:18 +08:00
snaily
38dbcd1643 fix: 更新API请求URL,增加pageSize参数以支持更大模型列表的获取 2025-06-17 23:30:36 +08:00
snaily
241d97027c Update README.md 2025-06-15 18:29:18 +08:00
snaily
d18689fe9f Merge pull request #151 from sk163/main 2025-06-14 15:12:33 +08:00
sk163
b72298fef4 feat: 增加了代理列表使用策略选项,对于同一个API_KEY可以使用固定代理 2025-06-14 14:36:11 +08:00
25 changed files with 934 additions and 162 deletions

View File

@@ -33,6 +33,8 @@ TIME_OUT=300
# 代理服务器配置 (支持 http 和 socks5) # 代理服务器配置 (支持 http 和 socks5)
# 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"] # 示例: PROXIES=["http://user:pass@host:port", "socks5://host:port"]
PROXIES=[] PROXIES=[]
# 对同一个API_KEY使用代理列表中固定的IP策略
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY=true
#########################image_generate 相关配置########################### #########################image_generate 相关配置###########################
PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx PAID_KEY=AIzaSyxxxxxxxxxxxxxxxxxxx
CREATE_IMAGE_MODEL=imagen-3.0-generate-002 CREATE_IMAGE_MODEL=imagen-3.0-generate-002
@@ -41,6 +43,7 @@ SMMS_SECRET_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PICGO_API_KEY=xxxx PICGO_API_KEY=xxxx
CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload CLOUDFLARE_IMGBED_URL=https://xxxxxxx.pages.dev/upload
CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx CLOUDFLARE_IMGBED_AUTH_CODE=xxxxxxxxx
CLOUDFLARE_IMGBED_UPLOAD_FOLDER=
########################################################################## ##########################################################################
#########################stream_optimizer 相关配置######################## #########################stream_optimizer 相关配置########################
STREAM_OPTIMIZER_ENABLED=false STREAM_OPTIMIZER_ENABLED=false
@@ -72,3 +75,8 @@ FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS=5
# 安全设置 (JSON 字符串格式) # 安全设置 (JSON 字符串格式)
# 注意:这里的示例值可能需要根据实际模型支持情况调整 # 注意:这里的示例值可能需要根据实际模型支持情况调整
SAFETY_SETTINGS=[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}] SAFETY_SETTINGS=[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]
URL_NORMALIZATION_ENABLED=false
# tts配置
TTS_MODEL=gemini-2.5-flash-preview-tts
TTS_VOICE_NAME=Zephyr
TTS_SPEED=normal

View File

@@ -14,6 +14,8 @@ ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
ENV TOOLS_CODE_EXECUTION_ENABLED=false ENV TOOLS_CODE_EXECUTION_ENABLED=false
ENV IMAGE_MODELS='["gemini-2.0-flash-exp"]' ENV IMAGE_MODELS='["gemini-2.0-flash-exp"]'
ENV SEARCH_MODELS='["gemini-2.0-flash-exp","gemini-2.0-pro-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 port
EXPOSE 8000 EXPOSE 8000

140
README.md
View File

@@ -2,6 +2,12 @@
# Gemini Balance - Gemini API Proxy and Load Balancer # 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. > ⚠️ 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. > 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.
@@ -11,7 +17,7 @@
[![Uvicorn](https://img.shields.io/badge/Uvicorn-running-purple.svg)](https://www.uvicorn.org/) [![Uvicorn](https://img.shields.io/badge/Uvicorn-running-purple.svg)](https://www.uvicorn.org/)
[![Telegram Group](https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram)](https://t.me/+soaHax5lyI0wZDVl) [![Telegram Group](https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram)](https://t.me/+soaHax5lyI0wZDVl)
> Telegram Group: https://t.me/+soaHax5lyI0wZDVl > Telegram Group: <https://t.me/+soaHax5lyI0wZDVl>
## Project Introduction ## Project Introduction
@@ -40,39 +46,39 @@ app/
## ✨ Feature Highlights ## ✨ Feature Highlights
* **Multi-Key Load Balancing**: Supports configuring multiple Gemini API Keys (`API_KEYS`) for automatic sequential polling, improving availability and concurrency. * **Multi-Key Load Balancing**: Supports configuring multiple Gemini API Keys (`API_KEYS`) for automatic sequential polling, improving availability and concurrency.
* **Visual Configuration Takes Effect Immediately**: Configurations modified through the admin backend take effect without restarting the service. Remember to click save for changes to apply. * **Visual Configuration Takes Effect Immediately**: Configurations modified through the admin backend take effect without restarting the service. Remember to click save for changes to apply.
![Configuration Panel](files/image4.png) ![Configuration Panel](files/image4.png)
* **Dual Protocol API Compatibility**: Supports forwarding CHAT API requests in both Gemini and OpenAI formats. * **Dual Protocol API Compatibility**: Supports forwarding CHAT API requests in both Gemini and OpenAI formats.
```plaintext ```plaintext
openai baseurl `http://localhost:8000(/hf)/v1` openai baseurl `http://localhost:8000(/hf)/v1`
gemini baseurl `http://localhost:8000(/gemini)/v1beta` gemini baseurl `http://localhost:8000(/gemini)/v1beta`
``` ```
* **Supports Image-Text Chat and Image Modification**: `IMAGE_MODELS` configures which models can perform image-text chat and image editing. When actually calling, use the `configured_model-image` model name to use this feature. * **Supports Image-Text Chat and Image Modification**: `IMAGE_MODELS` configures which models can perform image-text chat and image editing. When actually calling, use the `configured_model-image` model name to use this feature.
![Chat with Image Generation](files/image6.png) ![Chat with Image Generation](files/image6.png)
![Modify Image](files/image7.png) ![Modify Image](files/image7.png)
* **Supports Web Search**: Supports web search. `SEARCH_MODELS` configures which models can perform web searches. When actually calling, use the `configured_model-search` model name to use this feature. * **Supports Web Search**: Supports web search. `SEARCH_MODELS` configures which models can perform web searches. When actually calling, use the `configured_model-search` model name to use this feature.
![Web Search](files/image8.png) ![Web Search](files/image8.png)
* **Key Status Monitoring**: Provides a `/keys_status` page (requires authentication) to view the status and usage of each Key in real-time. * **Key Status Monitoring**: Provides a `/keys_status` page (requires authentication) to view the status and usage of each Key in real-time.
![Monitoring Panel](files/image.png) ![Monitoring Panel](files/image.png)
* **Detailed Logging**: Provides detailed error logs for easy troubleshooting. * **Detailed Logging**: Provides detailed error logs for easy troubleshooting.
![Call Details](files/image1.png) ![Call Details](files/image1.png)
![Log List](files/image2.png) ![Log List](files/image2.png)
![Log Details](files/image3.png) ![Log Details](files/image3.png)
* **Support for Custom Gemini Proxy**: Supports custom Gemini proxies, such as those built on Deno or Cloudflare. * **Support for Custom Gemini Proxy**: Supports custom Gemini proxies, such as those built on Deno or Cloudflare.
* **OpenAI Image Generation API Compatibility**: Adapts the `imagen-3.0-generate-002` model interface to be compatible with the OpenAI image generation API, supporting client calls. * **OpenAI Image Generation API Compatibility**: Adapts the `imagen-3.0-generate-002` model interface to be compatible with the OpenAI image generation API, supporting client calls.
* **Flexible Key Addition**: Flexible way to add keys using regex matching for `gemini_key`, with key deduplication. * **Flexible Key Addition**: Flexible way to add keys using regex matching for `gemini_key`, with key deduplication.
![Add Key](files/image5.png) ![Add Key](files/image5.png)
* **OpenAI Format Embeddings API Compatibility**: Perfectly adapts to the OpenAI format `embeddings` interface, usable for local document vectorization. * **OpenAI Format Embeddings API Compatibility**: Perfectly adapts to the OpenAI format `embeddings` interface, usable for local document vectorization.
* **Streamlined Response Optimization**: Optional stream output optimizer (`STREAM_OPTIMIZER_ENABLED`) to improve the experience of long-text stream responses. * **Streamlined Response Optimization**: Optional stream output optimizer (`STREAM_OPTIMIZER_ENABLED`) to improve the experience of long-text stream responses.
* **Failure Retry and Key Management**: Automatically handles API request failures, retries (`MAX_RETRIES`), automatically disables Keys after too many failures (`MAX_FAILURES`), and periodically checks for recovery (`CHECK_INTERVAL_HOURS`). * **Failure Retry and Key Management**: Automatically handles API request failures, retries (`MAX_RETRIES`), automatically disables Keys after too many failures (`MAX_FAILURES`), and periodically checks for recovery (`CHECK_INTERVAL_HOURS`).
* **Docker Support**: Supports AMD and ARM architecture Docker deployments. You can also build your own Docker image. * **Docker Support**: Supports AMD and ARM architecture Docker deployments. You can also build your own Docker image.
> Image address: docker pull ghcr.io/snailyp/gemini-balance:latest > Image address: docker pull ghcr.io/snailyp/gemini-balance:latest
* **Automatic Model List Maintenance**: Supports fetching OpenAI and Gemini model lists, perfectly compatible with NewAPI's automatic model list fetching, no manual entry required. * **Automatic Model List Maintenance**: Supports fetching OpenAI and Gemini model lists, perfectly compatible with NewAPI's automatic model list fetching, no manual entry required.
* **Support for Removing Unused Models**: Too many default models are provided, many of which are not used. You can filter them out using `FILTERED_MODELS`. * **Support for Removing Unused Models**: Too many default models are provided, many of which are not used. You can filter them out using `FILTERED_MODELS`.
* **Proxy Support**: Supports configuring HTTP/SOCKS5 proxy servers (`PROXIES`) for accessing the Gemini API, convenient for use in special network environments. Supports batch adding proxies. * **Proxy Support**: Supports configuring HTTP/SOCKS5 proxy servers (`PROXIES`) for accessing the Gemini API, convenient for use in special network environments. Supports batch adding proxies.
## 🚀 Quick Start ## 🚀 Quick Start
@@ -80,79 +86,83 @@ app/
#### a) Build with Dockerfile #### a) Build with Dockerfile
1. **Build Image**: 1. **Build Image**:
```bash ```bash
docker build -t gemini-balance . docker build -t gemini-balance .
``` ```
2. **Run Container**: 2. **Run Container**:
```bash ```bash
docker run -d -p 8000:8000 --env-file .env gemini-balance docker run -d -p 8000:8000 --env-file .env gemini-balance
``` ```
* `-d`: Run in detached mode. * `-d`: Run in detached mode.
* `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host. * `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host.
* `--env-file .env`: Use the `.env` file to set environment variables. * `--env-file .env`: Use the `.env` file to set environment variables.
> Note: If using an SQLite database, you need to mount a data volume to persist > Note: If using an SQLite database, you need to mount a data volume to persist
>
> ```bash > ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance > docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance
> ``` > ```
>
> Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container. > Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container.
#### b) Deploy with an Existing Docker Image #### b) Deploy with an Existing Docker Image
1. **Pull Image**: 1. **Pull Image**:
```bash ```bash
docker pull ghcr.io/snailyp/gemini-balance:latest docker pull ghcr.io/snailyp/gemini-balance:latest
``` ```
2. **Run Container**: 2. **Run Container**:
```bash ```bash
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
``` ```
* `-d`: Run in detached mode. * `-d`: Run in detached mode.
* `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host (adjust as needed). * `-p 8000:8000`: Map port 8000 of the container to port 8000 of the host (adjust as needed).
* `--env-file .env`: Use the `.env` file to set environment variables (ensure the `.env` file exists in the directory where the command is executed). * `--env-file .env`: Use the `.env` file to set environment variables (ensure the `.env` file exists in the directory where the command is executed).
> Note: If using an SQLite database, you need to mount a data volume to persist > Note: If using an SQLite database, you need to mount a data volume to persist
>
> ```bash > ```bash
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest > docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest
> ``` > ```
>
> Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container. > Where `/path/to/data` is the data storage path on the host, and `/app/data` is the data directory inside the container.
### Run Locally (Suitable for Development and Testing) ### Run Locally (Suitable for Development and Testing)
If you want to run the source code directly locally for development or testing, follow these steps: If you want to run the source code directly locally for development or testing, follow these steps:
1. **Ensure Prerequisites are Met**: 1. **Ensure Prerequisites are Met**:
* Clone the repository locally. * Clone the repository locally.
* Install Python 3.9 or higher. * Install Python 3.9 or higher.
* Create and configure the `.env` file in the project root directory (refer to the "Configure Environment Variables" section above). * Create and configure the `.env` file in the project root directory (refer to the "Configure Environment Variables" section above).
* Install project dependencies: * Install project dependencies:
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
``` ```
2. **Start Application**: 2. **Start Application**:
Run the following command in the project root directory: Run the following command in the project root directory:
```bash ```bash
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
``` ```
* `app.main:app`: Specifies the location of the FastAPI application instance (the `app` object in the `main.py` file within the `app` module). * `app.main:app`: Specifies the location of the FastAPI application instance (the `app` object in the `main.py` file within the `app` module).
* `--host 0.0.0.0`: Makes the application accessible from any IP address on the local network. * `--host 0.0.0.0`: Makes the application accessible from any IP address on the local network.
* `--port 8000`: Specifies the port number the application listens on (you can change this as needed). * `--port 8000`: Specifies the port number the application listens on (you can change this as needed).
* `--reload`: Enables automatic reloading. When you modify the code, the service will automatically restart, which is very suitable for development environments (remove this option in production environments). * `--reload`: Enables automatic reloading. When you modify the code, the service will automatically restart, which is very suitable for development environments (remove this option in production environments).
3. **Access Application**: 3. **Access Application**:
After the application starts, you can access `http://localhost:8000` (or the host and port you specified) through a browser or API tool. After the application starts, you can access `http://localhost:8000` (or the host and port you specified) through a browser or API tool.
### Complete Configuration List ### Complete Configuration List
@@ -181,6 +191,7 @@ If you want to run the source code directly locally for development or testing,
| `SHOW_THINKING_PROCESS` | Optional, whether to display the model's thinking process | `true` | | `SHOW_THINKING_PROCESS` | Optional, whether to display the model's thinking process | `true` |
| `THINKING_MODELS` | Optional, list of models that support thinking functions | `[]` | | `THINKING_MODELS` | Optional, list of models that support thinking functions | `[]` |
| `THINKING_BUDGET_MAP` | Optional, thinking function budget mapping (model_name:budget_value) | `{}` | | `THINKING_BUDGET_MAP` | Optional, thinking function budget mapping (model_name:budget_value) | `{}` |
| `URL_NORMALIZATION_ENABLED` | Optional, whether to enable intelligent URL routing mapping | `false` |
| `BASE_URL` | Optional, Gemini API base URL, no modification needed by default | `https://generativelanguage.googleapis.com/v1beta` | | `BASE_URL` | Optional, Gemini API base URL, no modification needed by default | `https://generativelanguage.googleapis.com/v1beta` |
| `MAX_FAILURES` | Optional, number of times a single key is allowed to fail | `3` | | `MAX_FAILURES` | Optional, number of times a single key is allowed to fail | `3` |
| `MAX_RETRIES` | Optional, maximum number of retries for failed API requests | `3` | | `MAX_RETRIES` | Optional, maximum number of retries for failed API requests | `3` |
@@ -194,6 +205,10 @@ If you want to run the source code directly locally for development or testing,
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Optional, whether to enable automatic deletion of request logs | `false` | | `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Optional, whether to enable automatic deletion of request logs | `false` |
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | Optional, automatically delete request logs older than this many days (e.g., 1, 7, 30) | `30` | | `AUTO_DELETE_REQUEST_LOGS_DAYS` | Optional, automatically delete request logs older than this many days (e.g., 1, 7, 30) | `30` |
| `SAFETY_SETTINGS` | Optional, safety settings (JSON string format), used to configure content safety thresholds. Example values may need adjustment based on actual model support. | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]` | | `SAFETY_SETTINGS` | Optional, safety settings (JSON string format), used to configure content safety thresholds. Example values may need adjustment based on actual model support. | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]` |
| **TTS Related** | | |
| `TTS_MODEL` | Optional, TTS model name | `gemini-2.5-flash-preview-tts` |
| `TTS_VOICE_NAME` | Optional, TTS voice name | `Zephyr` |
| `TTS_SPEED` | Optional, TTS speed | `normal` |
| **Image Generation Related** | | | | **Image Generation Related** | | |
| `PAID_KEY` | Optional, paid API Key for advanced features like image generation | `your-paid-api-key` | | `PAID_KEY` | Optional, paid API Key for advanced features like image generation | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | Optional, image generation model | `imagen-3.0-generate-002` | | `CREATE_IMAGE_MODEL` | Optional, image generation model | `imagen-3.0-generate-002` |
@@ -202,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` | | `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_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_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 Related** | | |
| `STREAM_OPTIMIZER_ENABLED` | Optional, whether to enable stream output optimization | `false` | | `STREAM_OPTIMIZER_ENABLED` | Optional, whether to enable stream output optimization | `false` |
| `STREAM_MIN_DELAY` | Optional, minimum delay for stream output | `0.016` | | `STREAM_MIN_DELAY` | Optional, minimum delay for stream output | `0.016` |
@@ -219,20 +235,20 @@ The following are the main API endpoints provided by the service:
### Gemini API Related (`(/gemini)/v1beta`) ### Gemini API Related (`(/gemini)/v1beta`)
* `GET /models`: List available Gemini models. * `GET /models`: List available Gemini models.
* `POST /models/{model_name}:generateContent`: Generate content using the specified Gemini model. * `POST /models/{model_name}:generateContent`: Generate content using the specified Gemini model.
* `POST /models/{model_name}:streamGenerateContent`: Stream content generation using the specified Gemini model. * `POST /models/{model_name}:streamGenerateContent`: Stream content generation using the specified Gemini model.
### OpenAI API Related ### OpenAI API Related
* `GET (/hf)/v1/models`: List available models (uses Gemini format underneath). * `GET (/hf)/v1/models`: List available models (uses Gemini format underneath).
* `POST (/hf)/v1/chat/completions`: Perform chat completion (uses Gemini format underneath, supports streaming). * `POST (/hf)/v1/chat/completions`: Perform chat completion (uses Gemini format underneath, supports streaming).
* `POST (/hf)/v1/embeddings`: Create text embeddings (uses Gemini format underneath). * `POST (/hf)/v1/embeddings`: Create text embeddings (uses Gemini format underneath).
* `POST (/hf)/v1/images/generations`: Generate images (uses Gemini format underneath). * `POST (/hf)/v1/images/generations`: Generate images (uses Gemini format underneath).
* `GET /openai/v1/models`: List available models (uses OpenAI format underneath). * `GET /openai/v1/models`: List available models (uses OpenAI format underneath).
* `POST /openai/v1/chat/completions`: Perform chat completion (uses OpenAI format underneath, supports streaming, can prevent truncation, and is faster). * `POST /openai/v1/chat/completions`: Perform chat completion (uses OpenAI format underneath, supports streaming, can prevent truncation, and is faster).
* `POST /openai/v1/embeddings`: Create text embeddings (uses OpenAI format underneath). * `POST /openai/v1/embeddings`: Create text embeddings (uses OpenAI format underneath).
* `POST /openai/v1/images/generations`: Generate images (uses OpenAI format underneath). * `POST /openai/v1/images/generations`: Generate images (uses OpenAI format underneath).
## 🤝 Contributing ## 🤝 Contributing
@@ -242,9 +258,9 @@ Pull Requests or Issues are welcome.
Special thanks to the following projects and platforms for providing image hosting services for this project: Special thanks to the following projects and platforms for providing image hosting services for this project:
* [PicGo](https://www.picgo.net/) * [PicGo](https://www.picgo.net/)
* [SM.MS](https://smms.app/) * [SM.MS](https://smms.app/)
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) open source project * [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) open source project
## 🙏 Thanks to Contributors ## 🙏 Thanks to Contributors
@@ -252,22 +268,26 @@ Thanks to all developers who contributed to this project!
[![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors) [![Contributors](https://contrib.rocks/image?repo=snailyp/gemini-balance)](https://github.com/snailyp/gemini-balance/graphs/contributors)
## Thanks to Our Supporters
A special shout-out to DigitalOcean for providing the rock-solid and dependable cloud infrastructure that keeps this project humming!
[![DigitalOcean Logo](files/dataocean.svg)](https://m.do.co/c/b249dd7f3b4c)
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
[![EdgeOne Logo](https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png)](https://edgeone.ai/?from=github)
## ⭐ Star History ## ⭐ Star History
[![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date) [![Star History Chart](https://api.star-history.com/svg?repos=snailyp/gemini-balance&type=Date)](https://star-history.com/#snailyp/gemini-balance&Date)
## 💖 Friendly Projects ## 💖 Friendly Projects
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine: AI-driven hot event timeline generation tool * **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine: AI-driven hot event timeline generation tool
## 🎁 Project Support ## 🎁 Project Support
If you find this project helpful, consider supporting me via [Afdian](https://afdian.com/a/snaily). If you find this project helpful, consider supporting me via [Afdian](https://afdian.com/a/snaily).
## VPS Recommend
[![Powered by DartNode](https://dartnode.com/branding/DN-Open-Source-sm.png)](https://dartnode.com "Powered by DartNode - Free VPS for Open Source")
## License ## License
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. 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.

View File

@@ -1,5 +1,11 @@
# Gemini Balance - Gemini API 代理和负载均衡器 # 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 文件。 > ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。 > 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
@@ -178,6 +184,7 @@ app/
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` | | `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
| `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` | | `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` |
| `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` | | `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
| `URL_NORMALIZATION_ENABLED` | 可选,是否启用智能路由映射功能 | `false` |
| `BASE_URL` | 可选Gemini API 基础 URL默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` | | `BASE_URL` | 可选Gemini API 基础 URL默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
| `MAX_FAILURES` | 可选允许单个key失败的次数 | `3` | | `MAX_FAILURES` | 可选允许单个key失败的次数 | `3` |
| `MAX_RETRIES` | 可选API 请求失败时的最大重试次数 | `3` | | `MAX_RETRIES` | 可选API 请求失败时的最大重试次数 | `3` |
@@ -191,6 +198,10 @@ app/
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 可选,是否开启自动删除请求日志 | `false` | | `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 可选,是否开启自动删除请求日志 | `false` |
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | 可选,自动删除多少天前的请求日志 (例如 1, 7, 30) | `30` | | `AUTO_DELETE_REQUEST_LOGS_DAYS` | 可选,自动删除多少天前的请求日志 (例如 1, 7, 30) | `30` |
| `SAFETY_SETTINGS` | 可选,安全设置 (JSON 字符串格式),用于配置内容安全阈值。示例值可能需要根据实际模型支持情况调整。 | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]` | | `SAFETY_SETTINGS` | 可选,安全设置 (JSON 字符串格式),用于配置内容安全阈值。示例值可能需要根据实际模型支持情况调整。 | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"}, {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "BLOCK_NONE"}]` |
| **TTS 相关** | | |
| `TTS_MODEL` | 可选TTS 模型名称 | `gemini-2.5-flash-preview-tts` |
| `TTS_VOICE_NAME` | 可选TTS 语音名称 | `Zephyr` |
| `TTS_SPEED` | 可选TTS 语速 | `normal` |
| **图像生成相关** | | | | **图像生成相关** | | |
| `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` | | `PAID_KEY` | 可选付费版API Key用于图片生成等高级功能 | `your-paid-api-key` |
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` | | `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
@@ -199,6 +210,7 @@ app/
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` | | `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_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_AUTH_CODE`| 可选CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| 可选CloudFlare图床的上传文件夹路径 | `""` |
| **流式优化器相关** | | | | **流式优化器相关** | | |
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` | | `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
| `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` | | `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` |

View File

@@ -1 +1 @@
2.1.5 2.1.8

View File

@@ -60,9 +60,13 @@ class Settings(BaseSettings):
TIME_OUT: int = DEFAULT_TIMEOUT TIME_OUT: int = DEFAULT_TIMEOUT
MAX_RETRIES: int = MAX_RETRIES MAX_RETRIES: int = MAX_RETRIES
PROXIES: List[str] = [] PROXIES: List[str] = []
PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: bool = True # 是否使用一致性哈希来选择代理
VERTEX_API_KEYS: List[str] = [] VERTEX_API_KEYS: List[str] = []
VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google" VERTEX_EXPRESS_BASE_URL: str = "https://aiplatform.googleapis.com/v1beta1/publishers/google"
# 智能路由配置
URL_NORMALIZATION_ENABLED: bool = False # 是否启用智能路由映射功能
# 模型相关配置 # 模型相关配置
SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"] SEARCH_MODELS: List[str] = ["gemini-2.0-flash-exp"]
IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"] IMAGE_MODELS: List[str] = ["gemini-2.0-flash-exp"]
@@ -73,6 +77,11 @@ class Settings(BaseSettings):
THINKING_MODELS: List[str] = [] THINKING_MODELS: List[str] = []
THINKING_BUDGET_MAP: Dict[str, float] = {} THINKING_BUDGET_MAP: Dict[str, float] = {}
# TTS相关配置
TTS_MODEL: str = "gemini-2.5-flash-preview-tts"
TTS_VOICE_NAME: str = "Zephyr"
TTS_SPEED: str = "normal"
# 图像生成相关配置 # 图像生成相关配置
PAID_KEY: str = "" PAID_KEY: str = ""
CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL CREATE_IMAGE_MODEL: str = DEFAULT_CREATE_IMAGE_MODEL
@@ -81,6 +90,7 @@ class Settings(BaseSettings):
PICGO_API_KEY: str = "" PICGO_API_KEY: str = ""
CLOUDFLARE_IMGBED_URL: str = "" CLOUDFLARE_IMGBED_URL: str = ""
CLOUDFLARE_IMGBED_AUTH_CODE: str = "" CLOUDFLARE_IMGBED_AUTH_CODE: str = ""
CLOUDFLARE_IMGBED_UPLOAD_FOLDER: str = ""
# 流式输出优化器配置 # 流式输出优化器配置
STREAM_OPTIMIZER_ENABLED: bool = False STREAM_OPTIMIZER_ENABLED: bool = False
@@ -110,6 +120,7 @@ class Settings(BaseSettings):
AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30 AUTO_DELETE_REQUEST_LOGS_DAYS: int = 30
SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS SAFETY_SETTINGS: List[Dict[str, str]] = DEFAULT_SAFETY_SETTINGS
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
# 设置默认AUTH_TOKEN如果未提供 # 设置默认AUTH_TOKEN如果未提供

View File

@@ -2,6 +2,7 @@
数据库连接池模块 数据库连接池模块
""" """
from pathlib import Path from pathlib import Path
from urllib.parse import quote_plus
from databases import Database from databases import Database
from sqlalchemy import create_engine, MetaData from sqlalchemy import create_engine, MetaData
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@@ -20,9 +21,9 @@ if settings.DATABASE_TYPE == "sqlite":
DATABASE_URL = f"sqlite:///{db_path}" DATABASE_URL = f"sqlite:///{db_path}"
elif settings.DATABASE_TYPE == "mysql": elif settings.DATABASE_TYPE == "mysql":
if settings.MYSQL_SOCKET: if settings.MYSQL_SOCKET:
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@/{settings.MYSQL_DATABASE}?unix_socket={settings.MYSQL_SOCKET}" DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@/{settings.MYSQL_DATABASE}?unix_socket={settings.MYSQL_SOCKET}"
else: else:
DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{settings.MYSQL_PASSWORD}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}" DATABASE_URL = f"mysql+pymysql://{settings.MYSQL_USER}:{quote_plus(settings.MYSQL_PASSWORD)}@{settings.MYSQL_HOST}:{settings.MYSQL_PORT}/{settings.MYSQL_DATABASE}"
else: else:
raise ValueError("Unsupported database type. Please set DATABASE_TYPE to 'sqlite' or 'mysql'.") raise ValueError("Unsupported database type. Please set DATABASE_TYPE to 'sqlite' or 'mysql'.")

View File

@@ -44,12 +44,12 @@ class GenerationConfig(BaseModel):
class SystemInstruction(BaseModel): class SystemInstruction(BaseModel):
role: str = "system" role: Optional[str] = "system"
parts: List[Dict[str, Any]] | Dict[str, Any] parts: Union[List[Dict[str, Any]], Dict[str, Any]]
class GeminiContent(BaseModel): class GeminiContent(BaseModel):
role: str role: Optional[str] = None
parts: List[Dict[str, Any]] parts: List[Dict[str, Any]]

View File

@@ -1,23 +1,20 @@
from typing import Union
class ImageMetadata: class ImageMetadata:
def __init__(self, width: int, height: int, filename: str, size: int, url: str, delete_url: str | None = None): def __init__(self, width: int, height: int, filename: str, size: int, url: str, delete_url: Union[str, None] = None):
self.width = width self.width = width
self.height = height self.height = height
self.filename = filename self.filename = filename
self.size = size self.size = size
self.url = url self.url = url
self.delete_url = delete_url self.delete_url = delete_url
class UploadResponse: class UploadResponse:
def __init__(self, success: bool, code: str, message: str, data: ImageMetadata): def __init__(self, success: bool, code: str, message: str, data: ImageMetadata):
self.success = success self.success = success
self.code = code self.code = code
self.message = message self.message = message
self.data = data self.data = data
class ImageUploader: class ImageUploader:
def upload(self, file: bytes, filename: str) -> UploadResponse: def upload(self, file: bytes, filename: str) -> UploadResponse:
raise NotImplementedError raise NotImplementedError

View File

@@ -33,3 +33,10 @@ class ImageGenerationRequest(BaseModel):
quality: Optional[str] = None quality: Optional[str] = None
style: Optional[str] = None style: Optional[str] = None
response_format: Optional[str] = "url" response_format: Optional[str] = "url"
class TTSRequest(BaseModel):
model: str = "gemini-2.5-flash-preview-tts"
input: str
voice: str = "Kore"
response_format: Optional[str] = "wav"

View File

@@ -238,6 +238,7 @@ def _extract_image_data(part: dict) -> str:
provider=settings.UPLOAD_PROVIDER, provider=settings.UPLOAD_PROVIDER,
base_url=settings.CLOUDFLARE_IMGBED_URL, base_url=settings.CLOUDFLARE_IMGBED_URL,
auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE, auth_code=settings.CLOUDFLARE_IMGBED_AUTH_CODE,
upload_folder=settings.CLOUDFLARE_IMGBED_UPLOAD_FOLDER,
) )
current_date = time.strftime("%Y/%m/%d") current_date = time.strftime("%Y/%m/%d")
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png" filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"

View File

@@ -8,6 +8,7 @@ from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
# from app.middleware.request_logging_middleware import RequestLoggingMiddleware # from app.middleware.request_logging_middleware import RequestLoggingMiddleware
from app.middleware.smart_routing_middleware import SmartRoutingMiddleware
from app.core.constants import API_VERSION from app.core.constants import API_VERSION
from app.core.security import verify_auth_token from app.core.security import verify_auth_token
from app.log.logger import get_middleware_logger from app.log.logger import get_middleware_logger
@@ -52,6 +53,9 @@ def setup_middlewares(app: FastAPI) -> None:
Args: Args:
app: FastAPI应用程序实例 app: FastAPI应用程序实例
""" """
# 添加智能路由中间件(必须在认证中间件之前)
app.add_middleware(SmartRoutingMiddleware)
# 添加认证中间件 # 添加认证中间件
app.add_middleware(AuthMiddleware) app.add_middleware(AuthMiddleware)

View File

@@ -0,0 +1,210 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.config.config import settings
from app.log.logger import get_main_logger
import re
logger = get_main_logger()
class SmartRoutingMiddleware(BaseHTTPMiddleware):
def __init__(self, app):
super().__init__(app)
# 简化的路由规则 - 直接根据检测结果路由
pass
async def dispatch(self, request: Request, call_next):
if not settings.URL_NORMALIZATION_ENABLED:
return await call_next(request)
logger.debug(f"request: {request}")
original_path = str(request.url.path)
method = request.method
# 尝试修复URL
fixed_path, fix_info = self.fix_request_url(original_path, method, request)
if fixed_path != original_path:
logger.info(f"URL fixed: {method} {original_path}{fixed_path}")
if fix_info:
logger.debug(f"Fix details: {fix_info}")
# 重写请求路径
request.scope["path"] = fixed_path
request.scope["raw_path"] = fixed_path.encode()
return await call_next(request)
def fix_request_url(self, path: str, method: str, request: Request) -> tuple:
"""简化的URL修复逻辑"""
# 首先检查是否已经是正确的格式,如果是则不处理
if self.is_already_correct_format(path):
return path, None
# 1. 最高优先级包含generateContent → Gemini格式
if "generatecontent" in path.lower() or "v1beta/models" in path.lower():
return self.fix_gemini_by_operation(path, method, request)
# 2. 第二优先级:包含/openai/ → OpenAI格式
if "/openai/" in path.lower():
return self.fix_openai_by_operation(path, method)
# 3. 第三优先级:包含/v1/ → v1格式
if "/v1/" in path.lower():
return self.fix_v1_by_operation(path, method)
# 4. 第四优先级:包含/chat/completions → chat功能
if "/chat/completions" in path.lower():
return "/v1/chat/completions", {"type": "v1_chat"}
# 5. 默认:原样传递
return path, None
def is_already_correct_format(self, path: str) -> bool:
"""检查是否已经是正确的API格式"""
# 检查是否已经是正确的端点格式
correct_patterns = [
r"^/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Gemini原生
r"^/gemini/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Gemini带前缀
r"^/v1beta/models$", # Gemini模型列表
r"^/gemini/v1beta/models$", # Gemini带前缀的模型列表
r"^/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # v1格式
r"^/openai/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # OpenAI格式
r"^/hf/v1/(chat/completions|models|embeddings|images/generations|audio/speech)$", # HF格式
r"^/vertex-express/v1beta/models/[^/:]+:(generate|streamGenerate)Content$", # Vertex Express Gemini格式
r"^/vertex-express/v1beta/models$", # Vertex Express模型列表
r"^/vertex-express/v1/(chat/completions|models|embeddings|images/generations)$", # Vertex Express OpenAI格式
]
for pattern in correct_patterns:
if re.match(pattern, path):
return True
return False
def fix_gemini_by_operation(
self, path: str, method: str, request: Request
) -> tuple:
"""根据Gemini操作修复考虑端点偏好"""
if method == "GET":
return "/v1beta/models", {
"role": "gemini_models",
}
# 提取模型名称
try:
model_name = self.extract_model_name(path, request)
except ValueError:
# 无法提取模型名称,返回原路径不做处理
return path, None
# 检测是否为流式请求
is_stream = self.detect_stream_request(path, request)
# 检查是否有vertex-express偏好
if "/vertex-express/" in path.lower():
if is_stream:
target_url = (
f"/vertex-express/v1beta/models/{model_name}:streamGenerateContent"
)
else:
target_url = (
f"/vertex-express/v1beta/models/{model_name}:generateContent"
)
fix_info = {
"rule": (
"vertex_express_generate"
if not is_stream
else "vertex_express_stream"
),
"preference": "vertex_express_format",
"is_stream": is_stream,
"model": model_name,
}
else:
# 标准Gemini端点
if is_stream:
target_url = f"/v1beta/models/{model_name}:streamGenerateContent"
else:
target_url = f"/v1beta/models/{model_name}:generateContent"
fix_info = {
"rule": "gemini_generate" if not is_stream else "gemini_stream",
"preference": "gemini_format",
"is_stream": is_stream,
"model": model_name,
}
return target_url, fix_info
def fix_openai_by_operation(self, path: str, method: str) -> tuple:
"""根据操作类型修复OpenAI格式"""
if method == "POST":
if "chat" in path.lower() or "completion" in path.lower():
return "/openai/v1/chat/completions", {"type": "openai_chat"}
elif "embedding" in path.lower():
return "/openai/v1/embeddings", {"type": "openai_embeddings"}
elif "image" in path.lower():
return "/openai/v1/images/generations", {"type": "openai_images"}
elif "audio" in path.lower():
return "/openai/v1/audio/speech", {"type": "openai_audio"}
elif method == "GET":
if "model" in path.lower():
return "/openai/v1/models", {"type": "openai_models"}
return path, None
def fix_v1_by_operation(self, path: str, method: str) -> tuple:
"""根据操作类型修复v1格式"""
if method == "POST":
if "chat" in path.lower() or "completion" in path.lower():
return "/v1/chat/completions", {"type": "v1_chat"}
elif "embedding" in path.lower():
return "/v1/embeddings", {"type": "v1_embeddings"}
elif "image" in path.lower():
return "/v1/images/generations", {"type": "v1_images"}
elif "audio" in path.lower():
return "/v1/audio/speech", {"type": "v1_audio"}
elif method == "GET":
if "model" in path.lower():
return "/v1/models", {"type": "v1_models"}
return path, None
def detect_stream_request(self, path: str, request: Request) -> bool:
"""检测是否为流式请求"""
# 1. 路径中包含stream关键词
if "stream" in path.lower():
return True
# 2. 查询参数
if request.query_params.get("stream") == "true":
return True
return False
def extract_model_name(self, path: str, request: Request) -> str:
"""从请求中提取模型名称用于构建Gemini API URL"""
# 1. 从请求体中提取
try:
if hasattr(request, "_body") and request._body:
import json
body = json.loads(request._body.decode())
if "model" in body and body["model"]:
return body["model"]
except Exception:
pass
# 2. 从查询参数中提取
model_param = request.query_params.get("model")
if model_param:
return model_param
# 3. 从路径中提取(用于已包含模型名称的路径)
match = re.search(r"/models/([^/:]+)", path, re.IGNORECASE)
if match:
return match.group(1)
# 4. 如果无法提取模型名称,抛出异常
raise ValueError("Unable to extract model name from request")

View File

@@ -151,6 +151,35 @@ async def stream_generate_content(
return StreamingResponse(response_stream, media_type="text/event-stream") 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") @router.post("/reset-all-fail-counts")
async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)): async def reset_all_key_fail_counts(key_type: str = None, key_manager: KeyManager = Depends(get_key_manager)):
"""批量重置Gemini API密钥的失败计数可选择性地仅重置有效或无效密钥""" """批量重置Gemini API密钥的失败计数可选择性地仅重置有效或无效密钥"""

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.config.config import settings from app.config.config import settings
@@ -7,6 +7,7 @@ from app.domain.openai_models import (
ChatRequest, ChatRequest,
EmbeddingRequest, EmbeddingRequest,
ImageGenerationRequest, ImageGenerationRequest,
TTSRequest,
) )
from app.handler.retry_handler import RetryHandler from app.handler.retry_handler import RetryHandler
from app.handler.error_handler import handle_route_errors from app.handler.error_handler import handle_route_errors
@@ -14,6 +15,7 @@ from app.log.logger import get_openai_logger
from app.service.chat.openai_chat_service import OpenAIChatService from app.service.chat.openai_chat_service import OpenAIChatService
from app.service.embedding.embedding_service import EmbeddingService from app.service.embedding.embedding_service import EmbeddingService
from app.service.image.image_create_service import ImageCreateService from app.service.image.image_create_service import ImageCreateService
from app.service.tts.tts_service import TTSService
from app.service.key.key_manager import KeyManager, get_key_manager_instance from app.service.key.key_manager import KeyManager, get_key_manager_instance
from app.service.model.model_service import ModelService from app.service.model.model_service import ModelService
@@ -24,6 +26,7 @@ security_service = SecurityService()
model_service = ModelService() model_service = ModelService()
embedding_service = EmbeddingService() embedding_service = EmbeddingService()
image_create_service = ImageCreateService() image_create_service = ImageCreateService()
tts_service = TTSService()
async def get_key_manager(): async def get_key_manager():
@@ -41,6 +44,11 @@ async def get_openai_chat_service(key_manager: KeyManager = Depends(get_key_mana
return OpenAIChatService(settings.BASE_URL, key_manager) return OpenAIChatService(settings.BASE_URL, key_manager)
async def get_tts_service():
"""获取TTS服务实例"""
return tts_service
@router.get("/v1/models") @router.get("/v1/models")
@router.get("/hf/v1/models") @router.get("/hf/v1/models")
async def list_models( async def list_models(
@@ -147,3 +155,21 @@ async def get_keys_list(
}, },
"total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]), "total": len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"]),
} }
@router.post("/v1/audio/speech")
@router.post("/hf/v1/audio/speech")
async def text_to_speech(
request: TTSRequest,
_=Depends(security_service.verify_authorization),
api_key: str = Depends(get_next_working_key_wrapper),
tts_service: TTSService = Depends(get_tts_service),
):
"""处理 OpenAI TTS 请求。"""
operation_name = "text_to_speech"
async with handle_route_errors(logger, operation_name):
logger.info(f"Handling TTS request for model: {request.model}")
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
logger.info(f"Using API key: {api_key}")
audio_data = await tts_service.create_tts(request, api_key)
return Response(content=audio_data, media_type="audio/wav")

View File

@@ -195,6 +195,54 @@ class GeminiChatService:
request_time=request_datetime 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": 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( async def stream_generate_content(
self, model: str, request: GeminiRequest, api_key: str self, model: str, request: GeminiRequest, api_key: str
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:

View File

@@ -21,6 +21,10 @@ class ApiClient(ABC):
async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]: async def stream_generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> AsyncGenerator[str, None]:
pass pass
@abstractmethod
async def count_tokens(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
pass
class GeminiApiClient(ApiClient): class GeminiApiClient(ApiClient):
"""Gemini API客户端""" """Gemini API客户端"""
@@ -46,11 +50,14 @@ class GeminiApiClient(ApiClient):
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(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 getting models: {proxy_to_use}") logger.info(f"Using proxy for getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models?key={api_key}" url = f"{self.base_url}/models?key={api_key}&pageSize=1000"
try: try:
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
@@ -69,8 +76,11 @@ class GeminiApiClient(ApiClient):
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}" url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
@@ -86,8 +96,11 @@ class GeminiApiClient(ApiClient):
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: 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}" url = f"{self.base_url}/models/{model}:streamGenerateContent?alt=sse&key={api_key}"
@@ -99,6 +112,26 @@ class GeminiApiClient(ApiClient):
async for line in response.aiter_lines(): async for line in response.aiter_lines():
yield line 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}")
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)
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): class OpenaiApiClient(ApiClient):
"""OpenAI API客户端""" """OpenAI API客户端"""
@@ -109,7 +142,16 @@ class OpenaiApiClient(ApiClient):
async def get_models(self, api_key: str) -> Dict[str, Any]: async def get_models(self, api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout) timeout = httpx.Timeout(self.timeout, read=self.timeout)
async with httpx.AsyncClient(timeout=timeout) as client:
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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/models" url = f"{self.base_url}/openai/models"
headers = {"Authorization": f"Bearer {api_key}"} headers = {"Authorization": f"Bearer {api_key}"}
response = await client.get(url, headers=headers) response = await client.get(url, headers=headers)
@@ -120,11 +162,14 @@ class OpenaiApiClient(ApiClient):
async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]: async def generate_content(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout) timeout = httpx.Timeout(self.timeout, read=self.timeout)
logger.info(f"settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY: {settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY}")
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/chat/completions" url = f"{self.base_url}/openai/chat/completions"
@@ -137,11 +182,13 @@ class OpenaiApiClient(ApiClient):
async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]: async def stream_generate_content(self, payload: Dict[str, Any], api_key: str) -> AsyncGenerator[str, None]:
timeout = httpx.Timeout(self.timeout, read=self.timeout) timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/chat/completions" url = f"{self.base_url}/openai/chat/completions"
@@ -159,8 +206,11 @@ class OpenaiApiClient(ApiClient):
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/embeddings" url = f"{self.base_url}/openai/embeddings"
@@ -177,11 +227,14 @@ class OpenaiApiClient(ApiClient):
async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]: async def generate_images(self, payload: Dict[str, Any], api_key: str) -> Dict[str, Any]:
timeout = httpx.Timeout(self.timeout, read=self.timeout) timeout = httpx.Timeout(self.timeout, read=self.timeout)
proxy_to_use = None proxy_to_use = None
if settings.PROXIES: if settings.PROXIES:
proxy_to_use = random.choice(settings.PROXIES) if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
logger.info(f"Using proxy: {proxy_to_use}") 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 getting models: {proxy_to_use}")
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client: async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
url = f"{self.base_url}/openai/images/generations" url = f"{self.base_url}/openai/images/generations"

View File

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

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
from itertools import cycle from itertools import cycle
from typing import Dict from typing import Dict, Union
from app.config.config import settings from app.config.config import settings
from app.log.logger import get_key_manager_logger from app.log.logger import get_key_manager_logger
@@ -178,19 +178,20 @@ class KeyManager:
if self.api_keys: if self.api_keys:
return self.api_keys[0] return self.api_keys[0]
if not self.api_keys: 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 ""
return self.api_keys[0] return self.api_keys[0]
_singleton_instance = None _singleton_instance = None
_singleton_lock = asyncio.Lock() _singleton_lock = asyncio.Lock()
_preserved_failure_counts: Dict[str, int] | None = None _preserved_failure_counts: Union[Dict[str, int], None] = None
_preserved_vertex_failure_counts: Dict[str, int] | None = None _preserved_vertex_failure_counts: Union[Dict[str, int], None] = None
_preserved_old_api_keys_for_reset: list | None = None _preserved_old_api_keys_for_reset: Union[list, None] = None
_preserved_vertex_old_api_keys_for_reset: list | None = None _preserved_vertex_old_api_keys_for_reset: Union[list, None] = None
_preserved_next_key_in_cycle: str | None = None _preserved_next_key_in_cycle: Union[str, None] = None
_preserved_vertex_next_key_in_cycle: str | None = None _preserved_vertex_next_key_in_cycle: Union[str, None] = None
async def get_key_manager_instance( async def get_key_manager_instance(
@@ -252,7 +253,8 @@ async def get_key_manager_instance(
_singleton_instance.vertex_key_failure_counts = ( _singleton_instance.vertex_key_failure_counts = (
current_vertex_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 _preserved_vertex_failure_counts = None
# 2. 调整 key_cycle 的起始点 # 2. 调整 key_cycle 的起始点
@@ -357,7 +359,7 @@ async def get_key_manager_instance(
f"Error determining start key for new Vertex key cycle from preserved state: {e}. " f"Error determining start key for new Vertex key cycle from preserved state: {e}. "
"New cycle will start from the beginning." "New cycle will start from the beginning."
) )
if start_key_for_new_vertex_cycle and _singleton_instance.vertex_api_keys: if start_key_for_new_vertex_cycle and _singleton_instance.vertex_api_keys:
try: try:
target_idx = _singleton_instance.vertex_api_keys.index( target_idx = _singleton_instance.vertex_api_keys.index(
@@ -380,7 +382,7 @@ async def get_key_manager_instance(
except Exception as e: except Exception as e:
logger.error( logger.error(
f"Error advancing new Vertex key cycle: {e}. Cycle will start from beginning." f"Error advancing new Vertex key cycle: {e}. Cycle will start from beginning."
) )
else: else:
if _singleton_instance.vertex_api_keys: if _singleton_instance.vertex_api_keys:
logger.info( logger.info(
@@ -418,7 +420,7 @@ async def reset_key_manager_instance():
# 3. 保存 key_cycle 的下一个 key 提示 # 3. 保存 key_cycle 的下一个 key 提示
try: try:
if _singleton_instance.api_keys: if _singleton_instance.api_keys:
_preserved_next_key_in_cycle = ( _preserved_next_key_in_cycle = (
await _singleton_instance.get_next_key() await _singleton_instance.get_next_key()
) )
else: else:
@@ -427,9 +429,10 @@ async def reset_key_manager_instance():
logger.warning( logger.warning(
"Could not preserve next key hint: key cycle was empty or exhausted in old instance." "Could not preserve next key hint: key cycle was empty or exhausted in old instance."
) )
_preserved_next_key_in_cycle = None _preserved_next_key_in_cycle = None
except Exception as e: 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 _preserved_next_key_in_cycle = None
# 4. 保存 vertex_key_cycle 的下一个 key 提示 # 4. 保存 vertex_key_cycle 的下一个 key 提示
@@ -443,12 +446,12 @@ async def reset_key_manager_instance():
except StopIteration: except StopIteration:
logger.warning( logger.warning(
"Could not preserve next key hint: Vertex key cycle was empty or exhausted in old instance." "Could not preserve next key hint: Vertex key cycle was empty or exhausted in old instance."
) )
_preserved_vertex_next_key_in_cycle = None _preserved_vertex_next_key_in_cycle = None
except Exception as e: 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 _preserved_vertex_next_key_in_cycle = None
_singleton_instance = None _singleton_instance = None
logger.info( logger.info(

View File

@@ -1,6 +1,7 @@
# app/service/stats_service.py # app/service/stats_service.py
import datetime import datetime
from typing import Union
from sqlalchemy import and_, case, func, or_, select from sqlalchemy import and_, case, func, or_, select
@@ -195,10 +196,11 @@ class StatsService:
return details return details
except Exception as e: except Exception as e:
logger.error(f"Failed to get API call details for period '{period}': {e}") logger.error(
f"Failed to get API call details for period '{period}': {e}")
raise raise
async def get_key_usage_details_last_24h(self, key: str) -> dict | None: async def get_key_usage_details_last_24h(self, key: str) -> Union[dict, None]:
""" """
获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。 获取指定 API 密钥在过去 24 小时内按模型统计的调用次数。
@@ -218,7 +220,8 @@ class StatsService:
try: try:
query = ( query = (
select( select(
RequestLog.model_name, func.count(RequestLog.id).label("call_count") RequestLog.model_name, func.count(
RequestLog.id).label("call_count")
) )
.where( .where(
RequestLog.api_key == key, RequestLog.api_key == key,
@@ -237,7 +240,8 @@ class StatsService:
) )
return {} return {}
usage_details = {row["model_name"]: row["call_count"] for row in results} usage_details = {row["model_name"]: row["call_count"]
for row in results}
logger.info( logger.info(
f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}" f"Successfully fetched usage details for key ending in ...{key[-4:]}: {usage_details}"
) )

View File

@@ -0,0 +1,94 @@
import datetime
import io
import re
import time
import wave
from typing import Optional
from google import genai
from app.config.config import settings
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
logger = get_openai_logger()
def _create_wav_file(audio_data: bytes) -> bytes:
"""Creates a WAV file in memory from raw audio data."""
with io.BytesIO() as wav_file:
with wave.open(wav_file, "wb") as wf:
wf.setnchannels(1) # Mono
wf.setsampwidth(2) # 16-bit
wf.setframerate(24000) # 24kHz sample rate
wf.writeframes(audio_data)
return wav_file.getvalue()
class TTSService:
async def create_tts(self, request: TTSRequest, api_key: str) -> Optional[bytes]:
"""
使用 Google Gemini SDK 创建音频。
"""
start_time = time.perf_counter()
request_datetime = datetime.datetime.now()
is_success = False
status_code = None
response = None
error_log_msg = ""
try:
client = genai.Client(api_key=api_key)
response =await client.aio.models.generate_content(
model=settings.TTS_MODEL,
contents=f"Speak in a {settings.TTS_SPEED} speed voice: {request.input}",
config={
"response_modalities": ["Audio"],
"speech_config": {
"voice_config": {
"prebuilt_voice_config": {
"voice_name": settings.TTS_VOICE_NAME
}
}
},
},
)
if (
response.candidates
and response.candidates[0].content.parts
and response.candidates[0].content.parts[0].inline_data
):
raw_audio_data = response.candidates[0].content.parts[0].inline_data.data
is_success = True
status_code = 200
return _create_wav_file(raw_audio_data)
except Exception as e:
is_success = False
error_log_msg = f"Generic error: {e}"
logger.error(f"An error occurred in TTSService: {error_log_msg}")
match = re.search(r"status code (\d+)", str(e))
if match:
status_code = int(match.group(1))
else:
status_code = 500
raise
finally:
end_time = time.perf_counter()
latency_ms = int((end_time - start_time) * 1000)
if not is_success:
await add_error_log(
gemini_key=api_key,
model_name=settings.TTS_MODEL,
error_type="google-tts",
error_log=error_log_msg,
error_code=status_code,
request_msg=request.input
)
await add_request_log(
model_name=settings.TTS_MODEL,
api_key=api_key,
is_success=is_success,
status_code=status_code,
latency_ms=latency_ms,
request_time=request_datetime
)

View File

@@ -12,7 +12,7 @@ const PROXY_REGEX =
/(?:https?|socks5):\/\/(?:[^:@\/]+(?::[^@\/]+)?@)?(?:[^:\/\s]+)(?::\d+)?/g; /(?: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 API Key 正则
const MASKED_VALUE = "••••••••"; const MASKED_VALUE = "••••••••";
// DOM Elements - Global Scope for frequently accessed elements // DOM Elements - Global Scope for frequently accessed elements
const safetySettingsContainer = document.getElementById( const safetySettingsContainer = document.getElementById(
"SAFETY_SETTINGS_container" "SAFETY_SETTINGS_container"
@@ -31,7 +31,7 @@ const bulkDeleteProxyModal = document.getElementById("bulkDeleteProxyModal");
const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput"); const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput");
const resetConfirmModal = document.getElementById("resetConfirmModal"); const resetConfirmModal = document.getElementById("resetConfirmModal");
const configForm = document.getElementById("configForm"); // Added for frequent use const configForm = document.getElementById("configForm"); // Added for frequent use
// Vertex API Key Modal Elements // Vertex API Key Modal Elements
const vertexApiKeyModal = document.getElementById("vertexApiKeyModal"); const vertexApiKeyModal = document.getElementById("vertexApiKeyModal");
const vertexApiKeyBulkInput = document.getElementById("vertexApiKeyBulkInput"); const vertexApiKeyBulkInput = document.getElementById("vertexApiKeyBulkInput");
@@ -41,7 +41,7 @@ const bulkDeleteVertexApiKeyModal = document.getElementById(
const bulkDeleteVertexApiKeyInput = document.getElementById( const bulkDeleteVertexApiKeyInput = document.getElementById(
"bulkDeleteVertexApiKeyInput" "bulkDeleteVertexApiKeyInput"
); );
// Model Helper Modal Elements // Model Helper Modal Elements
const modelHelperModal = document.getElementById("modelHelperModal"); const modelHelperModal = document.getElementById("modelHelperModal");
const modelHelperTitleElement = document.getElementById("modelHelperTitle"); const modelHelperTitleElement = document.getElementById("modelHelperTitle");
@@ -384,7 +384,7 @@ document.addEventListener("DOMContentLoaded", function () {
} }
initializeSensitiveFields(); // Initialize sensitive field handling initializeSensitiveFields(); // Initialize sensitive field handling
// Vertex API Key Modal Elements and Events // Vertex API Key Modal Elements and Events
const addVertexApiKeyBtn = document.getElementById("addVertexApiKeyBtn"); const addVertexApiKeyBtn = document.getElementById("addVertexApiKeyBtn");
const closeVertexApiKeyModalBtn = document.getElementById( const closeVertexApiKeyModalBtn = document.getElementById(
@@ -408,7 +408,7 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmBulkDeleteVertexApiKeyBtn = document.getElementById( const confirmBulkDeleteVertexApiKeyBtn = document.getElementById(
"confirmBulkDeleteVertexApiKeyBtn" "confirmBulkDeleteVertexApiKeyBtn"
); );
if (addVertexApiKeyBtn) { if (addVertexApiKeyBtn) {
addVertexApiKeyBtn.addEventListener("click", () => { addVertexApiKeyBtn.addEventListener("click", () => {
openModal(vertexApiKeyModal); openModal(vertexApiKeyModal);
@@ -428,7 +428,7 @@ document.addEventListener("DOMContentLoaded", function () {
"click", "click",
handleBulkAddVertexApiKeys handleBulkAddVertexApiKeys
); );
if (bulkDeleteVertexApiKeyBtn) { if (bulkDeleteVertexApiKeyBtn) {
bulkDeleteVertexApiKeyBtn.addEventListener("click", () => { bulkDeleteVertexApiKeyBtn.addEventListener("click", () => {
openModal(bulkDeleteVertexApiKeyModal); openModal(bulkDeleteVertexApiKeyModal);
@@ -448,7 +448,7 @@ document.addEventListener("DOMContentLoaded", function () {
"click", "click",
handleBulkDeleteVertexApiKeys handleBulkDeleteVertexApiKeys
); );
// Model Helper Modal Event Listeners // Model Helper Modal Event Listeners
if (closeModelHelperModalBtn) { if (closeModelHelperModalBtn) {
closeModelHelperModalBtn.addEventListener("click", () => closeModelHelperModalBtn.addEventListener("click", () =>
@@ -765,7 +765,7 @@ async function initConfig() {
FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: 5, FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS: 5,
// --- 结束:处理假流式配置的默认值 --- // --- 结束:处理假流式配置的默认值 ---
}; };
populateForm(defaultConfig); populateForm(defaultConfig);
if (configForm) { if (configForm) {
// Ensure form exists // Ensure form exists
@@ -1177,7 +1177,7 @@ function handleBulkDeleteProxies() {
} }
bulkDeleteProxyInput.value = ""; bulkDeleteProxyInput.value = "";
} }
/** /**
* Handles the bulk addition of Vertex API keys from the modal input. * Handles the bulk addition of Vertex API keys from the modal input.
*/ */
@@ -1192,10 +1192,10 @@ function handleBulkAddVertexApiKeys() {
) { ) {
return; return;
} }
const bulkText = vertexApiKeyBulkInput.value; const bulkText = vertexApiKeyBulkInput.value;
const extractedKeys = bulkText.match(VERTEX_API_KEY_REGEX) || []; const extractedKeys = bulkText.match(VERTEX_API_KEY_REGEX) || [];
const currentKeyInputs = vertexApiKeyContainer.querySelectorAll( const currentKeyInputs = vertexApiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}` `.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
); );
@@ -1206,16 +1206,16 @@ function handleBulkAddVertexApiKeys() {
: input.value; : input.value;
}) })
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE); .filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
const combinedKeys = new Set([...currentKeys, ...extractedKeys]); const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
const uniqueKeys = Array.from(combinedKeys); const uniqueKeys = Array.from(combinedKeys);
vertexApiKeyContainer.innerHTML = ""; // Clear existing items vertexApiKeyContainer.innerHTML = ""; // Clear existing items
uniqueKeys.forEach((key) => { uniqueKeys.forEach((key) => {
addArrayItemWithValue("VERTEX_API_KEYS", key); // VERTEX_API_KEYS are sensitive addArrayItemWithValue("VERTEX_API_KEYS", key); // VERTEX_API_KEYS are sensitive
}); });
// Ensure new sensitive inputs are masked // Ensure new sensitive inputs are masked
const newKeyInputs = vertexApiKeyContainer.querySelectorAll( const newKeyInputs = vertexApiKeyContainer.querySelectorAll(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}` `.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
@@ -1229,7 +1229,7 @@ function handleBulkAddVertexApiKeys() {
input.dispatchEvent(focusoutEvent); input.dispatchEvent(focusoutEvent);
} }
}); });
closeModal(vertexApiKeyModal); closeModal(vertexApiKeyModal);
showNotification( showNotification(
`添加/更新了 ${uniqueKeys.length} 个唯一 Vertex 密钥`, `添加/更新了 ${uniqueKeys.length} 个唯一 Vertex 密钥`,
@@ -1237,7 +1237,7 @@ function handleBulkAddVertexApiKeys() {
); );
vertexApiKeyBulkInput.value = ""; vertexApiKeyBulkInput.value = "";
} }
/** /**
* Handles the bulk deletion of Vertex API keys based on input from the modal. * Handles the bulk deletion of Vertex API keys based on input from the modal.
*/ */
@@ -1252,15 +1252,15 @@ function handleBulkDeleteVertexApiKeys() {
) { ) {
return; return;
} }
const bulkText = bulkDeleteVertexApiKeyInput.value; const bulkText = bulkDeleteVertexApiKeyInput.value;
if (!bulkText.trim()) { if (!bulkText.trim()) {
showNotification("请粘贴需要删除的 Vertex API 密钥", "warning"); showNotification("请粘贴需要删除的 Vertex API 密钥", "warning");
return; return;
} }
const keysToDelete = new Set(bulkText.match(VERTEX_API_KEY_REGEX) || []); const keysToDelete = new Set(bulkText.match(VERTEX_API_KEY_REGEX) || []);
if (keysToDelete.size === 0) { if (keysToDelete.size === 0) {
showNotification( showNotification(
"未在输入内容中提取到有效的 Vertex API 密钥格式", "未在输入内容中提取到有效的 Vertex API 密钥格式",
@@ -1268,10 +1268,10 @@ function handleBulkDeleteVertexApiKeys() {
); );
return; return;
} }
const keyItems = vertexApiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`); const keyItems = vertexApiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
let deleteCount = 0; let deleteCount = 0;
keyItems.forEach((item) => { keyItems.forEach((item) => {
const input = item.querySelector( const input = item.querySelector(
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}` `.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
@@ -1286,9 +1286,9 @@ function handleBulkDeleteVertexApiKeys() {
deleteCount++; deleteCount++;
} }
}); });
closeModal(bulkDeleteVertexApiKeyModal); closeModal(bulkDeleteVertexApiKeyModal);
if (deleteCount > 0) { if (deleteCount > 0) {
showNotification(`成功删除了 ${deleteCount} 个匹配的 Vertex 密钥`, "success"); showNotification(`成功删除了 ${deleteCount} 个匹配的 Vertex 密钥`, "success");
} else { } else {
@@ -1296,7 +1296,7 @@ function handleBulkDeleteVertexApiKeys() {
} }
bulkDeleteVertexApiKeyInput.value = ""; bulkDeleteVertexApiKeyInput.value = "";
} }
/** /**
* Switches the active configuration tab. * Switches the active configuration tab.
* @param {string} tabId - The ID of the tab to switch to. * @param {string} tabId - The ID of the tab to switch to.
@@ -1442,7 +1442,7 @@ function addArrayItemWithValue(key, value) {
const isSensitive = const isSensitive =
key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断 key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
const modelId = isThinkingModel ? generateUUID() : null; const modelId = isThinkingModel ? generateUUID() : null;
const arrayItem = document.createElement("div"); const arrayItem = document.createElement("div");
arrayItem.className = `${ARRAY_ITEM_CLASS} flex items-center mb-2 gap-2`; arrayItem.className = `${ARRAY_ITEM_CLASS} flex items-center mb-2 gap-2`;
if (isThinkingModel) { if (isThinkingModel) {
@@ -1535,14 +1535,14 @@ function createAndAppendBudgetMapItem(mapKey, mapValue, modelId) {
valueInput.value = isNaN(intValue) ? 0 : intValue; valueInput.value = isNaN(intValue) ? 0 : intValue;
valueInput.placeholder = "预算 (整数)"; valueInput.placeholder = "预算 (整数)";
valueInput.className = `${MAP_VALUE_INPUT_CLASS} w-24 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`; valueInput.className = `${MAP_VALUE_INPUT_CLASS} w-24 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`;
valueInput.min = 0; valueInput.min = -1;
valueInput.max = 24576; valueInput.max = 32767;
valueInput.addEventListener("input", function () { valueInput.addEventListener("input", function () {
let val = this.value.replace(/[^0-9]/g, ""); let val = this.value.replace(/[^0-9-]/g, "");
if (val !== "") { if (val !== "") {
val = parseInt(val, 10); val = parseInt(val, 10);
if (val < 0) val = 0; if (val < -1) val = -1;
if (val > 24576) val = 24576; if (val > 32767) val = 32767;
} }
this.value = val; // Corrected variable name this.value = val; // Corrected variable name
}); });

View File

@@ -745,6 +745,13 @@ endblock %} {% block head_extra_styles %}
> >
模型配置 模型配置
</button> </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;"
>
TTS 配置
</button>
<button <button
class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200" class="tab-btn px-5 py-2 rounded-full font-medium text-sm transition-all duration-200"
data-tab="image" data-tab="image"
@@ -935,7 +942,33 @@ endblock %} {% block head_extra_styles %}
/> />
<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>
<!-- 智能路由配置 -->
<div class="mb-6">
<div class="flex items-center justify-between">
<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>
<small class="text-gray-500 mt-1 block">
自动客户端请求的url拼接为正确格式仅保证正常聊天出现问题请关闭
</small>
</div>
<!-- 最大失败次数 --> <!-- 最大失败次数 -->
<div class="mb-6"> <div class="mb-6">
<label <label
@@ -1021,6 +1054,33 @@ endblock %} {% block head_extra_styles %}
socks5://host:port。点击按钮可批量添加或删除。</small socks5://host:port。点击按钮可批量添加或删除。</small
> >
</div> </div>
<!-- 代理使用策略 -->
<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
>
<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
>
</div>
</div> </div>
<!-- 模型相关配置 --> <!-- 模型相关配置 -->
@@ -1272,7 +1332,7 @@ endblock %} {% block head_extra_styles %}
</div> --> </div> -->
<small class="text-gray-500 mt-1 block" <small class="text-gray-500 mt-1 block"
>为每个思考模型设置预算(整数,最大值 >为每个思考模型设置预算(整数,最大值
24576此项与上方模型列表自动关联。</small 32767),此项与上方模型列表自动关联。</small
> >
</div> </div>
<!-- 安全设置 --> <!-- 安全设置 -->
@@ -1317,11 +1377,97 @@ endblock %} {% block head_extra_styles %}
</div> </div>
</div> </div>
</div> </div>
<!-- 图像生成相关配置 --> <!-- TTS配置 -->
<div class="config-section" id="image-section"> <div class="config-section" id="tts-section">
<h2 <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" 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-volume-up text-violet-400"></i> TTS 相关配置
</h2>
<!-- TTS 模型 -->
<div class="mb-6">
<label for="TTS_MODEL" class="block font-semibold mb-2 text-gray-700"
>TTS 模型</label
>
<select
id="TTS_MODEL"
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>
</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"
>TTS 语音名称</label
>
<select
id="TTS_VOICE_NAME"
name="TTS_VOICE_NAME"
class="w-full px-4 py-3 rounded-lg form-select-themed"
>
<option value="Zephyr">Zephyr (明亮)</option>
<option value="Puck">Puck (欢快)</option>
<option value="Charon">Charon (信息丰富)</option>
<option value="Kore">Kore (坚定)</option>
<option value="Fenrir">Fenrir (易激动)</option>
<option value="Leda">Leda (年轻)</option>
<option value="Orus">Orus (坚定)</option>
<option value="Aoede">Aoede (轻松)</option>
<option value="Callirhoe">Callirhoe (随和)</option>
<option value="Autonoe">Autonoe (明亮)</option>
<option value="Enceladus">Enceladus (呼吸感)</option>
<option value="Iapetus">Iapetus (清晰)</option>
<option value="Umbriel">Umbriel (随和)</option>
<option value="Algieba">Algieba (平滑)</option>
<option value="Despina">Despina (平滑)</option>
<option value="Erinome">Erinome (清晰)</option>
<option value="Algenib">Algenib (沙哑)</option>
<option value="Rasalgethi">Rasalgethi (信息丰富)</option>
<option value="Laomedeia">Laomedeia (欢快)</option>
<option value="Achernar">Achernar (轻柔)</option>
<option value="Alnilam">Alnilam (坚定)</option>
<option value="Schedar">Schedar (平稳)</option>
<option value="Gacrux">Gacrux (成熟)</option>
<option value="Pulcherrima">Pulcherrima (向前)</option>
<option value="Achird">Achird (友好)</option>
<option value="Zubenelgenubi">Zubenelgenubi (休闲)</option>
<option value="Vindemiatrix">Vindemiatrix (温柔)</option>
<option value="Sadachbia">Sadachbia (活泼)</option>
<option value="Sadaltager">Sadaltager (博学)</option>
<option value="Sulafat">Sulafat (温暖)</option>
</select>
<small class="text-gray-500 mt-1 block">TTS 的语音名称,控制风格、语调、口音和节奏</small>
</div>
<!-- TTS 语速 -->
<div class="mb-6">
<label for="TTS_SPEED" class="block font-semibold mb-2 text-gray-700"
>TTS 语速</label
>
<select
id="TTS_SPEED"
name="TTS_SPEED"
class="w-full px-4 py-3 rounded-lg form-select-themed"
>
<option value="slow"></option>
<option value="normal">正常</option>
<option value="fast"></option>
</select>
<small class="text-gray-500 mt-1 block">选择 TTS 的语速</small>
</div>
</div>
<!-- 图像生成相关配置 -->
<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> 图像生成配置 <i class="fas fa-image text-violet-400"></i> 图像生成配置
</h2> </h2>
@@ -1456,14 +1602,31 @@ endblock %} {% block head_extra_styles %}
/> />
<small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small> <small class="text-gray-500 mt-1 block">Cloudflare图床的认证码</small>
</div> </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> </div>
<!-- 流式输出优化配置 --> <!-- 流式输出优化配置 -->
<div class="config-section" id="stream-section"> <div class="config-section" id="stream-section">
<h2 <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" 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-stream text-violet-400"></i> 流式输出优化器 <i class="fas fa-stream text-violet-400"></i> 流式输出相关配置
</h2> </h2>
<!-- 启用流式输出优化 --> <!-- 启用流式输出优化 -->
@@ -2328,7 +2491,7 @@ endblock %} {% block head_extra_styles %}
let val = this.value.replace(/[^0-9]/g, ""); let val = this.value.replace(/[^0-9]/g, "");
if (val !== "") { if (val !== "") {
val = parseInt(val, 10); val = parseInt(val, 10);
if (val > 24576) val = 24576; if (val > 32767) val = 32767;
} }
this.value = val; this.value = val;

View File

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

71
files/dataocean.svg Normal file
View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 603 103" style="enable-background:new 0 0 603 103;" xml:space="preserve">
<style type="text/css">
.st0{fill:#0080FF;}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#0080FF;}
</style>
<g id="XMLID_2369_">
<g id="XMLID_2638_">
<g id="XMLID_2639_">
<g>
<g id="XMLID_44_">
<g id="XMLID_48_">
<path id="XMLID_49_" class="st0" d="M52.1,102.1l0-19.6c20.8,0,36.8-20.6,28.9-42.4C78,32,71.6,25.5,63.5,22.6
c-21.8-7.9-42.4,8.1-42.4,28.9c0,0,0,0,0,0l-19.6,0c0-33.1,32-58.9,66.7-48.1c15.2,4.7,27.2,16.8,31.9,31.9
C110.9,70.1,85.2,102.1,52.1,102.1z"/>
</g>
<polygon id="XMLID_47_" class="st1" points="52.1,82.5 32.6,82.5 32.6,63 32.6,63 52.1,63 52.1,63 "/>
<polygon id="XMLID_46_" class="st1" points="32.6,97.5 17.6,97.5 17.6,97.5 17.6,82.5 32.6,82.5 32.6,97.5 "/>
<polygon id="XMLID_45_" class="st1" points="17.6,82.5 5,82.5 5,82.5 5,70 5,70 17.6,70 17.6,70 "/>
</g>
</g>
</g>
</g>
<g id="XMLID_2370_">
<path id="XMLID_2635_" class="st0" d="M181.5,30.2c-5.8-4-13-6.1-21.4-6.1h-18.3v58.1h18.3c8.4,0,15.6-2.1,21.4-6.4
c3.2-2.2,5.7-5.4,7.4-9.3c1.7-3.9,2.6-8.5,2.6-13.7c0-5.1-0.9-9.7-2.6-13.6C187.2,35.4,184.7,32.3,181.5,30.2z M152.5,34h5.8
c6.4,0,11.7,1.3,15.7,3.7c4.4,2.7,6.7,7.8,6.7,15.1c0,7.6-2.3,12.9-6.7,15.8h0c-3.8,2.5-9.1,3.8-15.6,3.8h-5.8V34z"/>
<path id="XMLID_2634_" class="st0" d="M204.3,23.4c-1.8,0-3.3,0.6-4.5,1.8c-1.2,1.2-1.9,2.7-1.9,4.4c0,1.8,0.6,3.3,1.9,4.5
c1.2,1.2,2.7,1.9,4.5,1.9c1.8,0,3.3-0.6,4.5-1.9c1.2-1.2,1.9-2.8,1.9-4.5c0-1.8-0.6-3.3-1.9-4.4C207.6,24,206,23.4,204.3,23.4z"/>
<rect id="XMLID_2564_" x="199" y="41.3" class="st0" width="10.3" height="41"/>
<path id="XMLID_2561_" class="st0" d="M246.8,44.7c-3.1-2.8-6.6-4.4-10.3-4.4c-5.7,0-10.4,2-14.1,5.8c-3.7,3.8-5.5,8.8-5.5,14.7
c0,5.8,1.8,10.7,5.5,14.7c3.7,3.8,8.4,5.8,14.1,5.8c4,0,7.4-1.1,10.2-3.3V79c0,3.4-0.9,6-2.7,7.9c-1.8,1.8-4.3,2.7-7.4,2.7
c-4.8,0-7.7-1.9-11.4-6.8l-7,6.7l0.2,0.3c1.5,2.1,3.8,4.2,6.9,6.2c3.1,2,6.9,3,11.5,3c6.1,0,11.1-1.9,14.7-5.6
c3.7-3.7,5.5-8.7,5.5-14.9V41.3h-10.1V44.7z M244.1,68.9c-1.8,2-4.1,3-7.1,3c-3,0-5.3-1-7-3c-1.8-2-2.7-4.7-2.7-8
c0-3.3,0.9-6.1,2.7-8.1c1.8-2,4.1-3.1,7-3.1c3,0,5.3,1,7.1,3.1c1.8,2,2.7,4.8,2.7,8.1C246.8,64.2,245.8,66.9,244.1,68.9z"/>
<rect id="XMLID_2560_" x="265.7" y="41.3" class="st0" width="10.3" height="41"/>
<path id="XMLID_2552_" class="st0" d="M271,23.4c-1.8,0-3.3,0.6-4.5,1.8c-1.2,1.2-1.9,2.7-1.9,4.4c0,1.8,0.6,3.3,1.9,4.5
c1.2,1.2,2.7,1.9,4.5,1.9c1.8,0,3.3-0.6,4.5-1.9c1.2-1.2,1.9-2.8,1.9-4.5c0-1.8-0.6-3.3-1.9-4.4C274.3,24,272.7,23.4,271,23.4z"/>
<path id="XMLID_2509_" class="st0" d="M298.6,30.3h-10.1v11.1h-5.9v9.4h5.9v17c0,5.3,1.1,9.1,3.2,11.3c2.1,2.2,5.8,3.3,11.1,3.3
c1.7,0,3.4-0.1,5-0.2l0.5,0v-9.4l-3.5,0.2c-2.5,0-4.1-0.4-4.9-1.3c-0.8-0.9-1.2-2.7-1.2-5.4V50.7h9.6v-9.4h-9.6V30.3z"/>
<rect id="XMLID_2508_" x="356.5" y="24.1" class="st0" width="10.3" height="58.1"/>
<path id="XMLID_2470_" class="st0" d="M470.9,67.6c-1.8,2.1-3.7,3.9-5.2,4.8v0c-1.4,0.9-3.2,1.4-5.3,1.4c-3,0-5.5-1.1-7.5-3.4
c-2-2.3-3-5.2-3-8.7s1-6.4,2.9-8.6c2-2.3,4.4-3.4,7.4-3.4c3.3,0,6.8,2.1,9.8,5.6l6.8-6.5l0,0c-4.4-5.8-10.1-8.5-16.9-8.5
c-5.7,0-10.6,2.1-14.6,6.1c-4,4-6,9.2-6,15.3s2,11.2,6,15.3c4,4.1,8.9,6.1,14.6,6.1c7.5,0,13.5-3.2,17.5-9.1L470.9,67.6z"/>
<path id="XMLID_2460_" class="st0" d="M513.2,47c-1.5-2-3.5-3.7-5.9-4.9c-2.5-1.2-5.3-1.8-8.5-1.8c-5.8,0-10.5,2.1-14,6.3
c-3.4,4.2-5.2,9.3-5.2,15.4c0,6.2,1.9,11.3,5.7,15.3c3.7,3.9,8.8,5.9,14.9,5.9c6.9,0,12.7-2.8,16.9-8.4l0.2-0.3l-6.7-6.5l0,0
c-0.6,0.8-1.5,1.6-2.3,2.4c-1,1-2,1.7-3,2.2c-1.5,0.8-3.3,1.1-5.2,1.1c-2.9,0-5.2-0.8-7-2.5c-1.7-1.5-2.7-3.6-2.9-6.2h27.3
l0.1-3.8c0-2.7-0.4-5.2-1.1-7.6C515.8,51.3,514.7,49.1,513.2,47z M490.7,56.7c0.5-2,1.4-3.6,2.7-4.9c1.4-1.4,3.2-2.1,5.4-2.1
c2.5,0,4.4,0.7,5.7,2.1c1.2,1.3,1.9,2.9,2.1,4.8H490.7z"/>
<path id="XMLID_2456_" class="st0" d="M552.8,44.4L552.8,44.4c-3.1-2.7-7.4-4-12.8-4c-3.4,0-6.6,0.8-9.5,2.2
c-2.7,1.4-5.3,3.6-7,6.6l0.1,0.1l6.6,6.3c2.7-4.3,5.7-5.8,9.7-5.8c2.2,0,3.9,0.6,5.3,1.7c1.4,1.1,2,2.6,2,4.4v2
c-2.6-0.8-5.1-1.2-7.6-1.2c-5.1,0-9.3,1.2-12.4,3.6c-3.1,2.4-4.7,5.9-4.7,10.2c0,3.8,1.3,7,4,9.3c2.7,2.2,6,3.4,9.9,3.4
c3.9,0,7.6-1.6,10.9-4.3v3.4h10.1V55.9C557.6,51,556,47.1,552.8,44.4z M534.5,66.6c1.2-0.8,2.8-1.2,4.9-1.2c2.5,0,5.1,0.5,7.8,1.5
v4C545,73,542,74,538.3,74c-1.8,0-3.2-0.4-4.1-1.2c-0.9-0.8-1.4-1.7-1.4-3C532.8,68.5,533.4,67.4,534.5,66.6z"/>
<path id="XMLID_2454_" class="st0" d="M597.2,45.2c-2.9-3.2-6.9-4.8-12-4.8c-4.1,0-7.4,1.2-9.9,3.5v-2.5h-10.1v41h10.3V59.7
c0-3.1,0.7-5.6,2.2-7.3c1.5-1.8,3.4-2.6,6.1-2.6c2.3,0,4.1,0.8,5.4,2.3c1.3,1.6,2,3.7,2,6.4v23.7h10.3V58.5
C601.5,52.9,600.1,48.4,597.2,45.2z"/>
<path id="XMLID_2450_" class="st0" d="M343.6,44.4L343.6,44.4c-3.1-2.7-7.4-4-12.8-4c-3.4,0-6.6,0.8-9.5,2.2
c-2.7,1.4-5.3,3.6-7,6.6l0.1,0.1l6.6,6.3c2.7-4.3,5.7-5.8,9.7-5.8c2.2,0,3.9,0.6,5.3,1.7c1.4,1.1,2,2.6,2,4.4v2
c-2.6-0.8-5.1-1.2-7.6-1.2c-5.1,0-9.3,1.2-12.4,3.6c-3.1,2.4-4.7,5.9-4.7,10.2c0,3.8,1.3,7,4,9.3c2.7,2.2,6,3.4,9.9,3.4
c3.9,0,7.6-1.6,10.9-4.3v3.4h10.1V55.9C348.3,51,346.7,47.1,343.6,44.4z M325.3,66.6c1.2-0.8,2.8-1.2,4.9-1.2
c2.5,0,5.1,0.5,7.8,1.5v4c-2.2,2.1-5.2,3.1-8.9,3.1c-1.8,0-3.2-0.4-4.1-1.2c-0.9-0.8-1.4-1.7-1.4-3
C323.6,68.5,324.1,67.4,325.3,66.6z"/>
<path id="XMLID_2371_" class="st0" d="M404.2,83.1c-16.5,0-30-13.4-30-30s13.4-30,30-30c16.5,0,30,13.4,30,30
S420.7,83.1,404.2,83.1z M404.2,33.8c-10.7,0-19.4,8.7-19.4,19.4s8.7,19.4,19.4,19.4c10.7,0,19.4-8.7,19.4-19.4
S414.9,33.8,404.2,33.8z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB