mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 06:11:32 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892392742d | ||
|
|
380e6426ed | ||
|
|
d2906d89a6 | ||
|
|
13e1db7d69 | ||
|
|
40c9689eae | ||
|
|
548dcccf2f | ||
|
|
b52092a72b | ||
|
|
67efd067c6 | ||
|
|
f58ae2b340 | ||
|
|
f51a4d20ad | ||
|
|
b89d3ea144 | ||
|
|
3d6b5063d5 | ||
|
|
a6558b4668 | ||
|
|
6f714649a7 | ||
|
|
ae775760dd | ||
|
|
d475ccdece | ||
|
|
4eed3a48db | ||
|
|
26f3dbd12b | ||
|
|
7af53de782 | ||
|
|
2270f6d998 | ||
|
|
9f5892a987 | ||
|
|
ccd4722a77 | ||
|
|
feb57d7cf2 | ||
|
|
e7394776af | ||
|
|
0fa9638dd5 | ||
|
|
9d4d6464bf | ||
|
|
f3d9cb2b85 | ||
|
|
6abda7d902 | ||
|
|
b25cf7d978 | ||
|
|
07481ca972 | ||
|
|
9c285e38ef | ||
|
|
ebfa1d247c | ||
|
|
cdb85ef9b7 | ||
|
|
7006522c13 |
@@ -21,7 +21,7 @@ IMAGE_MODELS=["gemini-2.0-flash-exp"]
|
||||
SEARCH_MODELS=["gemini-2.0-flash-exp","gemini-2.0-pro-exp"]
|
||||
FILTERED_MODELS=["gemini-1.0-pro-vision-latest", "gemini-pro-vision", "chat-bison-001", "text-bison-001", "embedding-gecko-001"]
|
||||
# 是否启用网址上下文,默认启用
|
||||
URL_CONTEXT_ENABLED=true
|
||||
URL_CONTEXT_ENABLED=false
|
||||
URL_CONTEXT_MODELS=["gemini-2.5-pro","gemini-2.5-flash","gemini-2.5-flash-lite","gemini-2.0-flash","gemini-2.0-flash-live-001"]
|
||||
TOOLS_CODE_EXECUTION_ENABLED=false
|
||||
SHOW_SEARCH_LINK=true
|
||||
|
||||
392
README.md
392
README.md
@@ -8,31 +8,33 @@
|
||||
</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.
|
||||
<p align="center">
|
||||
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.9%2B-blue.svg" alt="Python"></a>
|
||||
<a href="https://fastapi.tiangolo.com/"><img src="https://img.shields.io/badge/FastAPI-0.100%2B-green.svg" alt="FastAPI"></a>
|
||||
<a href="https://www.uvicorn.org/"><img src="https://img.shields.io/badge/Uvicorn-running-purple.svg" alt="Uvicorn"></a>
|
||||
<a href="https://t.me/+soaHax5lyI0wZDVl"><img src="https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram" alt="Telegram Group"></a>
|
||||
</p>
|
||||
|
||||
> 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.
|
||||
> ⚠️ **Important**: This project is licensed under the [CC BY-NC 4.0](LICENSE) license. **Any form of commercial resale service is prohibited**.
|
||||
> I have never sold this service on any platform. If you encounter someone selling this service, they are a reseller. Please do not be deceived.
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://www.uvicorn.org/)
|
||||
[](https://t.me/+soaHax5lyI0wZDVl)
|
||||
---
|
||||
|
||||
> Telegram Group: <https://t.me/+soaHax5lyI0wZDVl>
|
||||
## 📖 Project Introduction
|
||||
|
||||
## Project Introduction
|
||||
**Gemini Balance** is an application built with Python FastAPI, designed to provide proxy and load balancing functions for the Google Gemini API. It allows you to manage multiple Gemini API Keys and implement key rotation, authentication, model filtering, and status monitoring through simple configuration. Additionally, the project integrates image generation and multiple image hosting upload functions, and supports proxying in the OpenAI API format.
|
||||
|
||||
Gemini Balance is an application built with Python FastAPI, designed to provide proxy and load balancing functions for the Google Gemini API. It allows you to manage multiple Gemini API Keys and implement key rotation, authentication, model filtering, and status monitoring through simple configuration. Additionally, the project integrates image generation and multiple image hosting upload functions, and supports proxying in the OpenAI API format.
|
||||
|
||||
**Project Structure:**
|
||||
<details>
|
||||
<summary>📂 View Project Structure</summary>
|
||||
|
||||
```plaintext
|
||||
app/
|
||||
├── config/ # Configuration management
|
||||
├── core/ # Core application logic (FastAPI instance creation, middleware, etc.)
|
||||
├── database/ # Database models and connections
|
||||
├── domain/ # Business domain objects (optional)
|
||||
├── domain/ # Business domain objects
|
||||
├── exception/ # Custom exceptions
|
||||
├── handler/ # Request handlers (optional, or handled in router)
|
||||
├── handler/ # Request handlers
|
||||
├── log/ # Logging configuration
|
||||
├── main.py # Application entry point
|
||||
├── middleware/ # FastAPI middleware
|
||||
@@ -41,250 +43,228 @@ app/
|
||||
├── service/ # Business logic services (chat, Key management, statistics, etc.)
|
||||
├── static/ # Static files (CSS, JS)
|
||||
├── templates/ # HTML templates (e.g., Key status page)
|
||||
├── utils/ # Utility functions
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Feature Highlights
|
||||
|
||||
* **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.
|
||||
* **Multi-Key Load Balancing**: Supports configuring multiple Gemini API Keys (`API_KEYS`) for automatic sequential polling.
|
||||
* **Visual Configuration**: Configurations modified through the admin backend take effect immediately without restarting.
|
||||

|
||||
* **Dual Protocol API Compatibility**: Supports forwarding CHAT API requests in both Gemini and OpenAI formats.
|
||||
|
||||
```plaintext
|
||||
openai baseurl `http://localhost:8000(/hf)/v1`
|
||||
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.
|
||||
* **Dual Protocol API Compatibility**: Supports both Gemini and OpenAI CHAT API formats.
|
||||
* OpenAI Base URL: `http://localhost:8000(/hf)/v1`
|
||||
* Gemini Base URL: `http://localhost:8000(/gemini)/v1beta`
|
||||
* **Image-Text Chat & Modification**: Configure models with `IMAGE_MODELS` to support image-text chat and editing. Use the `configured_model-image` model name to invoke.
|
||||

|
||||

|
||||
* **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**: Configure models with `SEARCH_MODELS` to support web search. Use the `configured_model-search` model name to invoke.
|
||||

|
||||
* **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 (authentication required) for real-time monitoring.
|
||||

|
||||
* **Detailed Logging**: Provides detailed error logs for easy troubleshooting.
|
||||
* **Detailed Logging**: Provides detailed error logs for easy troubleshooting.
|
||||

|
||||

|
||||

|
||||
* **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.
|
||||
* **Flexible Key Addition**: Flexible way to add keys using regex matching for `gemini_key`, with key deduplication.
|
||||
* **Flexible Key Addition**: Add keys in batches using the `gemini_key` regex, with automatic deduplication.
|
||||

|
||||
* **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.
|
||||
* **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.
|
||||
> 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.
|
||||
* **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.
|
||||
* **Failure Retry & Auto-Disable**: Automatically retries failed API requests (`MAX_RETRIES`) and disables keys after excessive failures (`MAX_FAILURES`).
|
||||
* **Comprehensive API Compatibility**:
|
||||
* **Embeddings API**: Fully compatible with the OpenAI `embeddings` API format.
|
||||
* **Image Generation API**: Adapts the `imagen-3.0-generate-002` model to the OpenAI image generation API format.
|
||||
* **Automatic Model List Maintenance**: Automatically fetches and syncs the latest model lists from Gemini and OpenAI.
|
||||
* **Proxy Support**: Supports HTTP/SOCKS5 proxies (`PROXIES`).
|
||||
* **Docker Support**: Provides Docker images for both AMD and ARM architectures.
|
||||
* Image Address: `ghcr.io/snailyp/gemini-balance:latest`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Build Docker Yourself (Recommended)
|
||||
|
||||
#### a) Build with Dockerfile
|
||||
|
||||
1. **Build Image**:
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
1. **Get `docker-compose.yml`**:
|
||||
Download the `docker-compose.yml` file from the project repository.
|
||||
2. **Prepare `.env` file**:
|
||||
Copy `.env.example` to `.env` and configure it. Ensure `DATABASE_TYPE` is set to `mysql` and fill in the `MYSQL_*` details.
|
||||
3. **Start Services**:
|
||||
In the directory containing `docker-compose.yml` and `.env`, run:
|
||||
```bash
|
||||
docker build -t gemini-balance .
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
2. **Run Container**:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --env-file .env gemini-balance
|
||||
```
|
||||
|
||||
* `-d`: Run in detached mode.
|
||||
* `-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.
|
||||
|
||||
> Note: If using an SQLite database, you need to mount a data volume to persist
|
||||
>
|
||||
> ```bash
|
||||
> 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.
|
||||
|
||||
#### b) Deploy with an Existing Docker Image
|
||||
|
||||
1. **Pull Image**:
|
||||
### Option 2: Docker Command
|
||||
|
||||
1. **Pull Image**:
|
||||
```bash
|
||||
docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
|
||||
2. **Run Container**:
|
||||
|
||||
2. **Prepare `.env` file**:
|
||||
Copy `.env.example` to `.env` and configure it.
|
||||
3. **Run Container**:
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
|
||||
docker run -d -p 8000:8000 --name gemini-balance \
|
||||
-v ./data:/app/data \
|
||||
--env-file .env \
|
||||
ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
* `-d`: Detached mode.
|
||||
* `-p 8000:8000`: Map container port 8000 to host.
|
||||
* `-v ./data:/app/data`: Mount volume for persistent data.
|
||||
* `--env-file .env`: Load environment variables.
|
||||
|
||||
* `-d`: Run in detached mode.
|
||||
* `-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).
|
||||
|
||||
> Note: If using an SQLite database, you need to mount a data volume to persist
|
||||
>
|
||||
> ```bash
|
||||
> 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.
|
||||
|
||||
### Run Locally (Suitable for Development and Testing)
|
||||
|
||||
If you want to run the source code directly locally for development or testing, follow these steps:
|
||||
|
||||
1. **Ensure Prerequisites are Met**:
|
||||
* Clone the repository locally.
|
||||
* 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).
|
||||
* Install project dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Start Application**:
|
||||
Run the following command in the project root directory:
|
||||
### Option 3: Local Development
|
||||
|
||||
1. **Clone and Install**:
|
||||
```bash
|
||||
git clone https://github.com/snailyp/gemini-balance.git
|
||||
cd gemini-balance
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. **Configure Environment**:
|
||||
Copy `.env.example` to `.env` and configure it.
|
||||
3. **Start Application**:
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
Access the application at `http://localhost:8000`.
|
||||
|
||||
* `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.
|
||||
* `--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).
|
||||
|
||||
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.
|
||||
|
||||
### Complete Configuration List
|
||||
|
||||
| Configuration Item | Description | Default Value |
|
||||
| :----------------------------- | :-------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Database Configuration** | | |
|
||||
| `DATABASE_TYPE` | Optional, database type, supports `mysql` or `sqlite` | `mysql` |
|
||||
| `SQLITE_DATABASE` | Optional, required when using `sqlite`, SQLite database file path | `default_db` |
|
||||
| `MYSQL_HOST` | Required when using `mysql`, MySQL database host address | `localhost` |
|
||||
| `MYSQL_SOCKET` | Optional, MySQL database socket address | `/var/run/mysqld/mysqld.sock` |
|
||||
| `MYSQL_PORT` | Required when using `mysql`, MySQL database port | `3306` |
|
||||
| `MYSQL_USER` | Required when using `mysql`, MySQL database username | `your_db_user` |
|
||||
| `MYSQL_PASSWORD` | Required when using `mysql`, MySQL database password | `your_db_password` |
|
||||
| `MYSQL_DATABASE` | Required when using `mysql`, MySQL database name | `defaultdb` |
|
||||
| **API Related Configuration** | | |
|
||||
| `API_KEYS` | Required, list of Gemini API keys for load balancing | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
|
||||
| `ALLOWED_TOKENS` | Required, list of tokens allowed to access | `["your-access-token-1", "your-access-token-2"]` |
|
||||
| `AUTH_TOKEN` | Optional, super admin token with all permissions, defaults to the first of `ALLOWED_TOKENS` if not set | `sk-123456` |
|
||||
| `TEST_MODEL` | Optional, model name used to test if a key is usable | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | Optional, list of models that support drawing functions | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | Optional, list of models that support search functions | `["gemini-2.0-flash-exp"]` |
|
||||
| `FILTERED_MODELS` | Optional, list of disabled models | `["gemini-1.0-pro-vision-latest", ...]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | Optional, whether to enable the code execution tool | `false` |
|
||||
| `SHOW_SEARCH_LINK` | Optional, whether to display search result links in the response | `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_BUDGET_MAP` | Optional, thinking function budget mapping (model_name:budget_value) | `{}` |
|
||||
| `URL_NORMALIZATION_ENABLED` | Optional, whether to enable intelligent URL routing mapping | `false` |
|
||||
| `URL_CONTEXT_ENABLED` | Optional, whether to enable URL context understanding | `false` |
|
||||
| `URL_CONTEXT_MODELS` | Optional, list of models that support URL context understanding | `[]` |
|
||||
| `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_RETRIES` | Optional, maximum number of retries for failed API requests | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | Optional, time interval (hours) to check if a disabled Key has recovered | `1` |
|
||||
| `TIMEZONE` | Optional, timezone used by the application | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | Optional, request timeout (seconds) | `300` |
|
||||
| `PROXIES` | Optional, list of proxy servers (e.g., `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
|
||||
| `LOG_LEVEL` | Optional, log level, e.g., DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | Optional, whether to enable automatic deletion of error logs | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | Optional, automatically delete error logs older than this many days (e.g., 1, 7, 30) | `7` |
|
||||
| `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` |
|
||||
| `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** | | |
|
||||
| `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` |
|
||||
| `UPLOAD_PROVIDER` | Optional, image upload provider: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | Optional, API Token for SM.MS image hosting | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | Optional, API Key for [PicoGo](https://www.picgo.net/) image hosting | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | Optional, [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) image hosting upload address | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE` | Optional, authentication key for CloudFlare image hosting | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER` | Optional, upload folder path for CloudFlare image hosting | `""` |
|
||||
| **Stream Optimizer Related** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | Optional, whether to enable stream output optimization | `false` |
|
||||
| `STREAM_MIN_DELAY` | Optional, minimum delay for stream output | `0.016` |
|
||||
| `STREAM_MAX_DELAY` | Optional, maximum delay for stream output | `0.024` |
|
||||
| `STREAM_SHORT_TEXT_THRESHOLD` | Optional, short text threshold | `10` |
|
||||
| `STREAM_LONG_TEXT_THRESHOLD` | Optional, long text threshold | `50` |
|
||||
| `STREAM_CHUNK_SIZE` | Optional, stream output chunk size | `5` |
|
||||
| **Fake Stream Related** | | |
|
||||
| `FAKE_STREAM_ENABLED` | Optional, whether to enable fake streaming for models or scenarios that don't support streaming | `false` |
|
||||
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | Optional, interval in seconds for sending heartbeat empty data during fake streaming | `5` |
|
||||
---
|
||||
|
||||
## ⚙️ API Endpoints
|
||||
|
||||
The following are the main API endpoints provided by the service:
|
||||
### Gemini API Format (`/gemini/v1beta`)
|
||||
|
||||
### Gemini API Related (`(/gemini)/v1beta`)
|
||||
* `GET /models`: List available Gemini models.
|
||||
* `POST /models/{model_name}:generateContent`: Generate content.
|
||||
* `POST /models/{model_name}:streamGenerateContent`: Stream content generation.
|
||||
|
||||
* `GET /models`: List available Gemini models.
|
||||
* `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.
|
||||
### OpenAI API Format
|
||||
|
||||
### OpenAI API Related
|
||||
#### Hugging Face (HF) Compatible
|
||||
|
||||
* `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/embeddings`: Create text embeddings (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).
|
||||
* `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/images/generations`: Generate images (uses OpenAI format underneath).
|
||||
* `GET /hf/v1/models`: List models.
|
||||
* `POST /hf/v1/chat/completions`: Chat completion.
|
||||
* `POST /hf/v1/embeddings`: Create text embeddings.
|
||||
* `POST /hf/v1/images/generations`: Generate images.
|
||||
|
||||
#### Standard OpenAI
|
||||
|
||||
* `GET /openai/v1/models`: List models.
|
||||
* `POST /openai/v1/chat/completions`: Chat completion (Recommended).
|
||||
* `POST /openai/v1/embeddings`: Create text embeddings.
|
||||
* `POST /openai/v1/images/generations`: Generate images.
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>📋 View Full Configuration List</summary>
|
||||
|
||||
| Configuration Item | Description | Default Value |
|
||||
| :--- | :--- | :--- |
|
||||
| **Database** | | |
|
||||
| `DATABASE_TYPE` | `mysql` or `sqlite` | `mysql` |
|
||||
| `SQLITE_DATABASE` | Path for SQLite database file | `default_db` |
|
||||
| `MYSQL_HOST` | MySQL host address | `localhost` |
|
||||
| `MYSQL_SOCKET` | MySQL socket address | `/var/run/mysqld/mysqld.sock` |
|
||||
| `MYSQL_PORT` | MySQL port | `3306` |
|
||||
| `MYSQL_USER` | MySQL username | `your_db_user` |
|
||||
| `MYSQL_PASSWORD` | MySQL password | `your_db_password` |
|
||||
| `MYSQL_DATABASE` | MySQL database name | `defaultdb` |
|
||||
| **API** | | |
|
||||
| `API_KEYS` | **Required**, list of Gemini API keys | `[]` |
|
||||
| `ALLOWED_TOKENS` | **Required**, list of access tokens | `[]` |
|
||||
| `AUTH_TOKEN` | Super admin token, defaults to the first of `ALLOWED_TOKENS` | `sk-123456` |
|
||||
| `ADMIN_SESSION_EXPIRE` | Admin session expiration time in seconds (5 minutes to 24 hours) | `3600` |
|
||||
| `TEST_MODEL` | Model for testing key validity | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | Models supporting image generation | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | Models supporting web search | `["gemini-2.0-flash-exp"]` |
|
||||
| `FILTERED_MODELS` | Disabled models | `[]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | Enable code execution tool | `false` |
|
||||
| `SHOW_SEARCH_LINK` | Display search result links in response | `true` |
|
||||
| `SHOW_THINKING_PROCESS` | Display model's thinking process | `true` |
|
||||
| `THINKING_MODELS` | Models supporting thinking process | `[]` |
|
||||
| `THINKING_BUDGET_MAP` | Budget map for thinking function (model:budget) | `{}` |
|
||||
| `URL_NORMALIZATION_ENABLED` | Enable smart URL routing | `false` |
|
||||
| `URL_CONTEXT_ENABLED` | Enable URL context understanding | `false` |
|
||||
| `URL_CONTEXT_MODELS` | Models supporting URL context | `[]` |
|
||||
| `BASE_URL` | Gemini API base URL | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `MAX_FAILURES` | Max failures allowed per key | `3` |
|
||||
| `MAX_RETRIES` | Max retries for failed API requests | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | Interval (hours) to re-check disabled keys | `1` |
|
||||
| `TIMEZONE` | Application timezone | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | Request timeout (seconds) | `300` |
|
||||
| `PROXIES` | List of proxy servers | `[]` |
|
||||
| **Logging & Security** | | |
|
||||
| `LOG_LEVEL` | Log level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | Auto-delete error logs | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | Error log retention period (days) | `7` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| Auto-delete request logs | `false` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | Request log retention period (days) | `30` |
|
||||
| `SAFETY_SETTINGS` | Content safety thresholds (JSON string) | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, ...]` |
|
||||
| **TTS** | | |
|
||||
| `TTS_MODEL` | TTS model name | `gemini-2.5-flash-preview-tts` |
|
||||
| `TTS_VOICE_NAME` | TTS voice name | `Zephyr` |
|
||||
| `TTS_SPEED` | TTS speed | `normal` |
|
||||
| **Image Generation** | | |
|
||||
| `PAID_KEY` | Paid API Key for advanced features | `your-paid-api-key` |
|
||||
| `CREATE_IMAGE_MODEL` | Image generation model | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | Image upload provider: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | SM.MS API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | PicoGo API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | CloudFlare ImgBed upload URL | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare ImgBed auth key | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare ImgBed upload folder | `""` |
|
||||
| **Stream Optimizer** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | Enable stream output optimization | `false` |
|
||||
| `STREAM_MIN_DELAY` | Minimum stream output delay | `0.016` |
|
||||
| `STREAM_MAX_DELAY` | Maximum stream output delay | `0.024` |
|
||||
| `STREAM_SHORT_TEXT_THRESHOLD`| Short text threshold | `10` |
|
||||
| `STREAM_LONG_TEXT_THRESHOLD` | Long text threshold | `50` |
|
||||
| `STREAM_CHUNK_SIZE` | Stream output chunk size | `5` |
|
||||
| **Fake Stream** | | |
|
||||
| `FAKE_STREAM_ENABLED` | Enable fake streaming | `false` |
|
||||
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | Heartbeat interval for fake streaming (seconds) | `5` |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Pull Requests or Issues are welcome.
|
||||
|
||||
## 🎉 Special Thanks
|
||||
|
||||
Special thanks to the following projects and platforms for providing image hosting services for this project:
|
||||
|
||||
* [PicGo](https://www.picgo.net/)
|
||||
* [SM.MS](https://smms.app/)
|
||||
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) open source project
|
||||
|
||||
## 🙏 Thanks to Contributors
|
||||
|
||||
Thanks to all developers who contributed to this project!
|
||||
|
||||
[](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!
|
||||
[](https://m.do.co/c/b249dd7f3b4c)
|
||||
|
||||
CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
|
||||
[](https://edgeone.ai/?from=github)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
[](https://star-history.com/#snailyp/gemini-balance&Date)
|
||||
|
||||
## 🎉 Special Thanks
|
||||
|
||||
* [PicGo](https://www.picgo.net/)
|
||||
* [SM.MS](https://smms.app/)
|
||||
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed)
|
||||
|
||||
## 🙏 Our Supporters
|
||||
|
||||
A special shout-out to [DigitalOcean](https://m.do.co/c/b249dd7f3b4c) for providing the rock-solid and dependable cloud infrastructure that keeps this project humming!
|
||||
|
||||
<a href="https://m.do.co/c/b249dd7f3b4c">
|
||||
<img src="files/dataocean.svg" alt="DigitalOcean Logo" width="200"/>
|
||||
</a>
|
||||
|
||||
CDN acceleration and security protection for this project are sponsored by [Tencent EdgeOne](https://edgeone.ai/?from=github).
|
||||
|
||||
<a href="https://edgeone.ai/?from=github">
|
||||
<img src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" alt="EdgeOne Logo" width="200"/>
|
||||
</a>
|
||||
|
||||
## 💖 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) - AI-driven hot event timeline generation tool.
|
||||
|
||||
## 🎁 Project Support
|
||||
|
||||
@@ -292,4 +272,4 @@ If you find this project helpful, consider supporting me via [Afdian](https://af
|
||||
|
||||
## 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](LICENSE) (Attribution-NonCommercial) license.
|
||||
|
||||
404
README_ZH.md
404
README_ZH.md
@@ -6,30 +6,33 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> ⚠️ 本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
<p align="center">
|
||||
<a href="https://www.python.org/"><img src="https://img.shields.io/badge/Python-3.9%2B-blue.svg" alt="Python"></a>
|
||||
<a href="https://fastapi.tiangolo.com/"><img src="https://img.shields.io/badge/FastAPI-0.100%2B-green.svg" alt="FastAPI"></a>
|
||||
<a href="https://www.uvicorn.org/"><img src="https://img.shields.io/badge/Uvicorn-running-purple.svg" alt="Uvicorn"></a>
|
||||
<a href="https://t.me/+soaHax5lyI0wZDVl"><img src="https://img.shields.io/badge/Telegram-Group-blue.svg?logo=telegram" alt="Telegram Group"></a>
|
||||
</p>
|
||||
|
||||
> 本人从未在各个平台售卖服务,如有遇到售卖此服务者,那一定是倒卖狗,大家切记不要上当受骗。
|
||||
> ⚠️ **重要声明**: 本项目采用 [CC BY-NC 4.0](LICENSE) 协议,**禁止任何形式的商业倒卖服务**。
|
||||
> 本人从未在任何平台售卖服务,如遇售卖,均为倒卖行为,请勿上当受骗。
|
||||
|
||||
[](https://www.python.org/)
|
||||
[](https://fastapi.tiangolo.com/)
|
||||
[](https://www.uvicorn.org/)
|
||||
[](https://t.me/+soaHax5lyI0wZDVl)
|
||||
> 交流群:https://t.me/+soaHax5lyI0wZDVl
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
## 📖 项目简介
|
||||
|
||||
Gemini Balance 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key,并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理。
|
||||
**Gemini Balance** 是一个基于 Python FastAPI 构建的应用程序,旨在提供 Google Gemini API 的代理和负载均衡功能。它允许您管理多个 Gemini API Key,并通过简单的配置实现 Key 的轮询、认证、模型过滤和状态监控。此外,项目还集成了图像生成和多种图床上传功能,并支持 OpenAI API 格式的代理。
|
||||
|
||||
**项目结构:**
|
||||
<details>
|
||||
<summary>📂 查看项目结构</summary>
|
||||
|
||||
```plaintext
|
||||
app/
|
||||
├── config/ # 配置管理
|
||||
├── core/ # 核心应用逻辑 (FastAPI 实例创建, 中间件等)
|
||||
├── database/ # 数据库模型和连接
|
||||
├── domain/ # 业务领域对象 (可选)
|
||||
├── domain/ # 业务领域对象
|
||||
├── exception/ # 自定义异常
|
||||
├── handler/ # 请求处理器 (可选, 或在 router 中处理)
|
||||
├── handler/ # 请求处理器
|
||||
├── log/ # 日志配置
|
||||
├── main.py # 应用入口
|
||||
├── middleware/ # FastAPI 中间件
|
||||
@@ -38,228 +41,200 @@ app/
|
||||
├── service/ # 业务逻辑服务 (聊天, Key 管理, 统计等)
|
||||
├── static/ # 静态文件 (CSS, JS)
|
||||
├── templates/ # HTML 模板 (如 Key 状态页)
|
||||
├── utils/ # 工具函数
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能亮点
|
||||
|
||||
* **多 Key 负载均衡**: 支持配置多个 Gemini API Key (`API_KEYS`),自动按顺序轮询使用,提高可用性和并发能力。
|
||||
* **可视化配置即时生效**: 通过管理后台修改配置后,无需重启服务即可生效,切记要点击保存才会生效。
|
||||

|
||||
* **双协议API 兼容**: 同时支持 Gemini 和 OpenAI 格式的 CHAT API 请求转发。
|
||||
* **多 Key 负载均衡**: 支持配置多个 Gemini API Key (`API_KEYS`),自动按顺序轮询使用,提高可用性和并发能力。
|
||||
* **可视化配置即时生效**: 通过管理后台修改配置后,无需重启服务即可生效。
|
||||

|
||||
* **双协议 API 兼容**: 同时支持 Gemini 和 OpenAI 格式的 CHAT API 请求转发。
|
||||
* OpenAI Base URL: `http://localhost:8000(/hf)/v1`
|
||||
* Gemini Base URL: `http://localhost:8000(/gemini)/v1beta`
|
||||
* **图文对话与修图**: 通过 `IMAGE_MODELS` 配置支持图文对话和修图功能的模型,调用时使用 `配置模型-image` 模型名。
|
||||

|
||||

|
||||
* **联网搜索**: 通过 `SEARCH_MODELS` 配置支持联网搜索的模型,调用时使用 `配置模型-search` 模型名。
|
||||

|
||||
* **Key 状态监控**: 提供 `/keys_status` 页面(需要认证),实时查看各 Key 的状态和使用情况。
|
||||

|
||||
* **详细日志记录**: 提供详细的错误日志,方便排查问题。
|
||||

|
||||

|
||||

|
||||
* **灵活的密钥添加**: 支持通过正则表达式 `gemini_key` 批量添加密钥,并自动去重。
|
||||

|
||||
* **失败重试与自动禁用**: 自动处理 API 请求失败,进行重试 (`MAX_RETRIES`),并在 Key 失效次数过多时自动禁用 (`MAX_FAILURES`),定时检查恢复 (`CHECK_INTERVAL_HOURS`)。
|
||||
* **全面的 API 兼容**:
|
||||
* **Embeddings 接口**: 完美适配 OpenAI 格式的 `embeddings` 接口。
|
||||
* **画图接口**: 将 `imagen-3.0-generate-002` 模型接口改造为 OpenAI 画图接口格式。
|
||||
* **模型列表自动维护**: 自动获取并同步 Gemini 和 OpenAI 的最新模型列表,兼容 New API。
|
||||
* **代理支持**: 支持配置 HTTP/SOCKS5 代理 (`PROXIES`),方便在特殊网络环境下使用。
|
||||
* **Docker 支持**: 提供 AMD 和 ARM 架构的 Docker 镜像,方便快速部署。
|
||||
* 镜像地址: `ghcr.io/snailyp/gemini-balance:latest`
|
||||
|
||||
```palintext
|
||||
openai baseurl `http://localhost:8000(/hf)/v1`
|
||||
gemini baseurl `http://localhost:8000(/gemini)/v1beta`
|
||||
```
|
||||
|
||||
* **支持图文对话和修改图片**: `IMAGE_MODELS`配置哪个模型可以图文对话和修图的功能,实际调用的时候,用 `配置模型-image`这个模型名对话使用该功能。
|
||||

|
||||

|
||||
* **支持联网搜索**: 支持联网搜索,`SEARCH_MODELS`配置哪些模型可以联网搜索,实际调用的时候,用 `配置模型-search`这个模型名对话使用该功能
|
||||

|
||||
* **Key 状态监控**: 提供 `/keys_status` 页面(需要认证),实时查看各 Key 的状态和使用情况。
|
||||

|
||||
* **详细的日志记录**: 提供详细的错误日志,方便排查。
|
||||

|
||||

|
||||

|
||||
* **支持自定义gemini代理**: 支持自定义gemini代理,比如自行在deno或者cloudflare上搭建gemini代理
|
||||
* **openai画图接口兼容**: 将`imagen-3.0-generate-002`模型接口改造成openai画图接口,支持客户端调用。
|
||||
* **灵活的添加密钥方式**: 灵活的添加密钥方式,采用正则匹配`gemini_key`,密钥去重
|
||||

|
||||
* **兼容openai格式embeddings接口**:完美适配openai格式的`embeddings`接口,可用于本地文档向量化。
|
||||
* **流式响应优化**: 可选的流式输出优化器 (`STREAM_OPTIMIZER_ENABLED`),改善长文本流式响应的体验。
|
||||
* **失败重试与 Key 管理**: 自动处理 API 请求失败,进行重试 (`MAX_RETRIES`),并在 Key 失效次数过多时自动禁用 (`MAX_FAILURES`),定时检查恢复 (`CHECK_INTERVAL_HOURS`)。
|
||||
* **Docker 支持**: 支持AMD,ARM架构的docker部署,也可自行构建docker镜像。
|
||||
>镜像地址: docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
* **模型列表自动维护**: 支持openai和gemini模型列表获取,与newapi自动获取模型列表完美兼容,无需手动填写。
|
||||
* **支持移除不使用的模型**: 默认提供的模型太多,很多用不上,可以通过`FILTERED_MODELS`过滤掉。
|
||||
* **代理支持**: 支持配置 HTTP/SOCKS5 代理服务器 (`PROXIES`),用于访问 Gemini API,方便在特殊网络环境下使用。支持批量添加代理。
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 自行构建 Docker (推荐)
|
||||
### 方式一:使用 Docker Compose (推荐)
|
||||
|
||||
#### a) dockerfile构建
|
||||
|
||||
1. **构建镜像**:
|
||||
这是最推荐的部署方式,可以一键启动应用和数据库。
|
||||
|
||||
1. **下载 `docker-compose.yml`**:
|
||||
从项目仓库获取 `docker-compose.yml` 文件。
|
||||
2. **准备 `.env` 文件**:
|
||||
从 `.env.example` 复制一份并重命名为 `.env`,然后根据需求修改配置。特别注意,`DATABASE_TYPE` 应设置为 `mysql`,并填写 `MYSQL_*` 相关配置。
|
||||
3. **启动服务**:
|
||||
在 `docker-compose.yml` 和 `.env` 文件所在的目录下,运行以下命令:
|
||||
```bash
|
||||
docker build -t gemini-balance .
|
||||
docker-compose up -d
|
||||
```
|
||||
该命令会以后台模式启动 `gemini-balance` 应用和 `mysql` 数据库。
|
||||
|
||||
2. **运行容器**:
|
||||
### 方式二:使用 Docker 命令
|
||||
|
||||
1. **拉取镜像**:
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --env-file .env gemini-balance
|
||||
docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
2. **准备 `.env` 文件**:
|
||||
从 `.env.example` 复制一份并重命名为 `.env`,然后根据需求修改配置。
|
||||
3. **运行容器**:
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --name gemini-balance \
|
||||
-v ./data:/app/data \
|
||||
--env-file .env \
|
||||
ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
* `-d`: 后台运行。
|
||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机。
|
||||
* `-v ./data:/app/data`: 挂载数据卷以持久化 SQLite 数据和日志。
|
||||
* `--env-file .env`: 加载环境变量配置文件。
|
||||
|
||||
* `-d`: 后台运行。
|
||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口。
|
||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量。
|
||||
|
||||
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
|
||||
> ```bash
|
||||
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data gemini-balance
|
||||
> ```
|
||||
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
|
||||
|
||||
#### b) 用现有的docker镜像部署
|
||||
|
||||
1. **拉取镜像**:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
|
||||
2. **运行容器**:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8000:8000 --env-file .env ghcr.io/snailyp/gemini-balance:latest
|
||||
```
|
||||
|
||||
* `-d`: 后台运行。
|
||||
* `-p 8000:8000`: 将容器的 8000 端口映射到主机的 8000 端口 (根据需要调整)。
|
||||
* `--env-file .env`: 使用 `.env` 文件设置环境变量 (确保 `.env` 文件存在于执行命令的目录)。
|
||||
|
||||
> 注意:如果使用 SQLite 数据库,需要挂载数据卷以持久化数据:
|
||||
> ```bash
|
||||
> docker run -d -p 8000:8000 --env-file .env -v /path/to/data:/app/data ghcr.io/snailyp/gemini-balance:latest
|
||||
> ```
|
||||
> 其中 `/path/to/data` 是主机上的数据存储路径,`/app/data` 是容器内的数据目录。
|
||||
|
||||
### 本地运行 (适用于开发和测试)
|
||||
|
||||
如果您想在本地直接运行源代码进行开发或测试,请按照以下步骤操作:
|
||||
|
||||
1. **确保已完成准备工作**:
|
||||
* 克隆仓库到本地。
|
||||
* 安装 Python 3.9 或更高版本。
|
||||
* 在项目根目录下创建并配置好 `.env` 文件 (参考前面的"配置环境变量"部分)。
|
||||
* 安装项目依赖:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **启动应用**:
|
||||
在项目根目录下运行以下命令:
|
||||
### 方式三:本地运行 (适用于开发)
|
||||
|
||||
1. **克隆仓库并安装依赖**:
|
||||
```bash
|
||||
git clone https://github.com/snailyp/gemini-balance.git
|
||||
cd gemini-balance
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
2. **配置环境变量**:
|
||||
从 `.env.example` 复制一份并重命名为 `.env`,然后根据需求修改配置。
|
||||
3. **启动应用**:
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
应用启动后,访问 `http://localhost:8000`。
|
||||
|
||||
* `app.main:app`: 指定 FastAPI 应用实例的位置 (`app` 模块中的 `main.py` 文件里的 `app` 对象)。
|
||||
* `--host 0.0.0.0`: 使应用可以从本地网络中的任何 IP 地址访问。
|
||||
* `--port 8000`: 指定应用监听的端口号 (您可以根据需要修改)。
|
||||
* `--reload`: 启用自动重载功能。当您修改代码时,服务会自动重启,非常适合开发环境 (生产环境请移除此选项)。
|
||||
|
||||
3. **访问应用**:
|
||||
应用启动后,您可以通过浏览器或 API 工具访问 `http://localhost:8000` (或您指定的主机和端口)。
|
||||
|
||||
### 完整配置项列表
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
| :--------------------------- | :------------------------------------------------------- | :---------------------------------------------------- |
|
||||
| **数据库配置** | | |
|
||||
| `DATABASE_TYPE` | 可选,数据库类型,支持 `mysql` 或 `sqlite` | `mysql` |
|
||||
| `SQLITE_DATABASE` | 可选,当使用 `sqlite` 时必填,SQLite 数据库文件路径 | `default_db` |
|
||||
| `MYSQL_HOST` | 当使用 `mysql` 时必填,MySQL 数据库主机地址 | `localhost` |
|
||||
| `MYSQL_SOCKET` | 可选,MySQL 数据库 socket 地址 | `/var/run/mysqld/mysqld.sock` |
|
||||
| `MYSQL_PORT` | 当使用 `mysql` 时必填,MySQL 数据库端口 | `3306` |
|
||||
| `MYSQL_USER` | 当使用 `mysql` 时必填,MySQL 数据库用户名 | `your_db_user` |
|
||||
| `MYSQL_PASSWORD` | 当使用 `mysql` 时必填,MySQL 数据库密码 | `your_db_password` |
|
||||
| `MYSQL_DATABASE` | 当使用 `mysql` 时必填,MySQL 数据库名称 | `defaultdb` |
|
||||
| **API 相关配置** | | |
|
||||
| `API_KEYS` | 必填,Gemini API 密钥列表,用于负载均衡 | `["your-gemini-api-key-1", "your-gemini-api-key-2"]` |
|
||||
| `ALLOWED_TOKENS` | 必填,允许访问的 Token 列表 | `["your-access-token-1", "your-access-token-2"]` |
|
||||
| `AUTH_TOKEN` | 可选,超级管理员token,具有所有权限,不填默认使用 ALLOWED_TOKENS 的第一个 | `sk-123456` |
|
||||
| `TEST_MODEL` | 可选,用于测试密钥是否可用的模型名 | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | 可选,支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | 可选,支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `FILTERED_MODELS` | 可选,被禁用的模型列表 | `["gemini-1.0-pro-vision-latest", ...]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | 可选,是否启用代码执行工具 | `false` |
|
||||
| `SHOW_SEARCH_LINK` | 可选,是否在响应中显示搜索结果链接 | `true` |
|
||||
| `SHOW_THINKING_PROCESS` | 可选,是否显示模型思考过程 | `true` |
|
||||
| `THINKING_MODELS` | 可选,支持思考功能的模型列表 | `[]` |
|
||||
| `THINKING_BUDGET_MAP` | 可选,思考功能预算映射 (模型名:预算值) | `{}` |
|
||||
| `URL_NORMALIZATION_ENABLED` | 可选,是否启用智能路由映射功能 | `false` |
|
||||
| `URL_CONTEXT_ENABLED` | 可选,是否启用URL上下文理解功能 | `false` |
|
||||
| `URL_CONTEXT_MODELS` | 可选,支持URL上下文理解功能的模型列表 | `[]` |
|
||||
| `BASE_URL` | 可选,Gemini API 基础 URL,默认无需修改 | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `MAX_FAILURES` | 可选,允许单个key失败的次数 | `3` |
|
||||
| `MAX_RETRIES` | 可选,API 请求失败时的最大重试次数 | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | 可选,检查禁用 Key 是否恢复的时间间隔 (小时) | `1` |
|
||||
| `TIMEZONE` | 可选,应用程序使用的时区 | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | 可选,请求超时时间 (秒) | `300` |
|
||||
| `PROXIES` | 可选,代理服务器列表 (例如 `http://user:pass@host:port`, `socks5://host:port`) | `[]` |
|
||||
| `LOG_LEVEL` | 可选,日志级别,例如 DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 可选,是否开启自动删除错误日志 | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 可选,自动删除多少天前的错误日志 (例如 1, 7, 30) | `7` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 可选,是否开启自动删除请求日志 | `false` |
|
||||
| `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"}]` |
|
||||
| **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` |
|
||||
| `CREATE_IMAGE_MODEL` | 可选,图片生成模型 | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | 可选,图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | 可选,SM.MS图床的API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | 可选,[PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | 可选,[CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| 可选,CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| 可选,CloudFlare图床的上传文件夹路径 | `""` |
|
||||
| **流式优化器相关** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | 可选,是否启用流式输出优化 | `false` |
|
||||
| `STREAM_MIN_DELAY` | 可选,流式输出最小延迟 | `0.016` |
|
||||
| `STREAM_MAX_DELAY` | 可选,流式输出最大延迟 | `0.024` |
|
||||
| `STREAM_SHORT_TEXT_THRESHOLD`| 可选,短文本阈值 | `10` |
|
||||
| `STREAM_LONG_TEXT_THRESHOLD` | 可选,长文本阈值 | `50` |
|
||||
| `STREAM_CHUNK_SIZE` | 可选,流式输出块大小 | `5` |
|
||||
| **伪流式 (Fake Stream) 相关** | | |
|
||||
| `FAKE_STREAM_ENABLED` | 可选,是否启用伪流式传输,用于不支持流式的模型或场景 | `false` |
|
||||
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | 可选,伪流式传输时发送心跳空数据的间隔秒数 | `5` |
|
||||
---
|
||||
|
||||
## ⚙️ API 端点
|
||||
|
||||
以下是服务提供的主要 API 端点:
|
||||
### Gemini API 格式 (`/gemini/v1beta`)
|
||||
|
||||
### Gemini API 相关 (`(/gemini)/v1beta`)
|
||||
* `GET /models`: 列出可用的 Gemini 模型。
|
||||
* `POST /models/{model_name}:generateContent`: 生成内容。
|
||||
* `POST /models/{model_name}:streamGenerateContent`: 流式生成内容。
|
||||
|
||||
* `GET /models`: 列出可用的 Gemini 模型。
|
||||
* `POST /models/{model_name}:generateContent`: 使用指定的 Gemini 模型生成内容。
|
||||
* `POST /models/{model_name}:streamGenerateContent`: 使用指定的 Gemini 模型流式生成内容。
|
||||
### OpenAI API 格式
|
||||
|
||||
### OpenAI API 相关
|
||||
#### 兼容 huggingface (HF) 格式
|
||||
|
||||
* `GET (/hf)/v1/models`: 列出可用的模型 (底层用的gemini格式)。
|
||||
* `POST (/hf)/v1/chat/completions`: 进行聊天补全 (底层用的gemini格式, 支持流式传输)。
|
||||
* `POST (/hf)/v1/embeddings`: 创建文本嵌入 (底层用的gemini格式)。
|
||||
* `POST (/hf)/v1/images/generations`: 生成图像 (底层用的gemini格式)。
|
||||
* `GET /openai/v1/models`: 列出可用的模型 (底层用的openai格式)。
|
||||
* `POST /openai/v1/chat/completions`: 进行聊天补全 (底层用的openai格式, 支持流式传输, 可防止截断,速度也快)。
|
||||
* `POST /openai/v1/embeddings`: 创建文本嵌入 (底层用的openai格式)。
|
||||
* `POST /openai/v1/images/generations`: 生成图像 (底层用的openai格式)。
|
||||
* `GET /hf/v1/models`: 列出模型。
|
||||
* `POST /hf/v1/chat/completions`: 聊天补全。
|
||||
* `POST /hf/v1/embeddings`: 创建文本嵌入。
|
||||
* `POST /hf/v1/images/generations`: 生成图像。
|
||||
|
||||
#### 标准 OpenAI 格式
|
||||
|
||||
* `GET /openai/v1/models`: 列出模型。
|
||||
* `POST /openai/v1/chat/completions`: 聊天补全 (推荐,速度更快,防截断)。
|
||||
* `POST /openai/v1/embeddings`: 创建文本嵌入。
|
||||
* `POST /openai/v1/images/generations`: 生成图像。
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>📋 查看完整配置项列表</summary>
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
| :--- | :--- | :--- |
|
||||
| **数据库配置** | | |
|
||||
| `DATABASE_TYPE` | 数据库类型: `mysql` 或 `sqlite` | `mysql` |
|
||||
| `SQLITE_DATABASE` | 当使用 `sqlite` 时必填,SQLite 数据库文件路径 | `default_db` |
|
||||
| `MYSQL_HOST` | 当使用 `mysql` 时必填,MySQL 数据库主机地址 | `localhost` |
|
||||
| `MYSQL_SOCKET` | 可选,MySQL 数据库 socket 地址 | `/var/run/mysqld/mysqld.sock` |
|
||||
| `MYSQL_PORT` | 当使用 `mysql` 时必填,MySQL 数据库端口 | `3306` |
|
||||
| `MYSQL_USER` | 当使用 `mysql` 时必填,MySQL 数据库用户名 | `your_db_user` |
|
||||
| `MYSQL_PASSWORD` | 当使用 `mysql` 时必填,MySQL 数据库密码 | `your_db_password` |
|
||||
| `MYSQL_DATABASE` | 当使用 `mysql` 时必填,MySQL 数据库名称 | `defaultdb` |
|
||||
| **API 相关配置** | | |
|
||||
| `API_KEYS` | **必填**, Gemini API 密钥列表,用于负载均衡 | `[]` |
|
||||
| `ALLOWED_TOKENS` | **必填**, 允许访问的 Token 列表 | `[]` |
|
||||
| `AUTH_TOKEN` | 超级管理员 Token,不填则使用 `ALLOWED_TOKENS` 的第一个 | `sk-123456` |
|
||||
| `TEST_MODEL` | 用于测试密钥可用性的模型 | `gemini-1.5-flash` |
|
||||
| `IMAGE_MODELS` | 支持绘图功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `SEARCH_MODELS` | 支持搜索功能的模型列表 | `["gemini-2.0-flash-exp"]` |
|
||||
| `FILTERED_MODELS` | 被禁用的模型列表 | `[]` |
|
||||
| `TOOLS_CODE_EXECUTION_ENABLED` | 是否启用代码执行工具 | `false` |
|
||||
| `SHOW_SEARCH_LINK` | 是否在响应中显示搜索结果链接 | `true` |
|
||||
| `SHOW_THINKING_PROCESS` | 是否显示模型思考过程 | `true` |
|
||||
| `THINKING_MODELS` | 支持思考功能的模型列表 | `[]` |
|
||||
| `THINKING_BUDGET_MAP` | 思考功能预算映射 (模型名:预算值) | `{}` |
|
||||
| `URL_NORMALIZATION_ENABLED` | 是否启用智能路由映射功能 | `false` |
|
||||
| `URL_CONTEXT_ENABLED` | 是否启用URL上下文理解功能 | `false` |
|
||||
| `URL_CONTEXT_MODELS` | 支持URL上下文理解功能的模型列表 | `[]` |
|
||||
| `BASE_URL` | Gemini API 基础 URL | `https://generativelanguage.googleapis.com/v1beta` |
|
||||
| `MAX_FAILURES` | 单个 Key 允许的最大失败次数 | `3` |
|
||||
| `MAX_RETRIES` | API 请求失败时的最大重试次数 | `3` |
|
||||
| `CHECK_INTERVAL_HOURS` | 禁用 Key 恢复检查间隔 (小时) | `1` |
|
||||
| `TIMEZONE` | 应用程序使用的时区 | `Asia/Shanghai` |
|
||||
| `TIME_OUT` | 请求超时时间 (秒) | `300` |
|
||||
| `PROXIES` | 代理服务器列表 (例如 `http://user:pass@host:port`) | `[]` |
|
||||
| **日志与安全** | | |
|
||||
| `LOG_LEVEL` | 日志级别: `DEBUG`, `INFO`, `WARNING`, `ERROR` | `INFO` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_ENABLED` | 是否自动删除错误日志 | `true` |
|
||||
| `AUTO_DELETE_ERROR_LOGS_DAYS` | 错误日志保留天数 | `7` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_ENABLED`| 是否自动删除请求日志 | `false` |
|
||||
| `AUTO_DELETE_REQUEST_LOGS_DAYS` | 请求日志保留天数 | `30` |
|
||||
| `SAFETY_SETTINGS` | 内容安全阈值 (JSON 字符串) | `[{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"}, ...]` |
|
||||
| **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` |
|
||||
| `CREATE_IMAGE_MODEL` | 图片生成模型 | `imagen-3.0-generate-002` |
|
||||
| `UPLOAD_PROVIDER` | 图片上传提供商: `smms`, `picgo`, `cloudflare_imgbed` | `smms` |
|
||||
| `SMMS_SECRET_TOKEN` | SM.MS图床的API Token | `your-smms-token` |
|
||||
| `PICGO_API_KEY` | [PicoGo](https://www.picgo.net/)图床的API Key | `your-picogo-apikey` |
|
||||
| `CLOUDFLARE_IMGBED_URL` | [CloudFlare](https://github.com/MarSeventh/CloudFlare-ImgBed) 图床上传地址 | `https://xxxxxxx.pages.dev/upload` |
|
||||
| `CLOUDFLARE_IMGBED_AUTH_CODE`| CloudFlare图床的鉴权key | `your-cloudflare-imgber-auth-code` |
|
||||
| `CLOUDFLARE_IMGBED_UPLOAD_FOLDER`| CloudFlare图床的上传文件夹路径 | `""` |
|
||||
| **流式优化器相关** | | |
|
||||
| `STREAM_OPTIMIZER_ENABLED` | 是否启用流式输出优化 | `false` |
|
||||
| `STREAM_MIN_DELAY` | 流式输出最小延迟 | `0.016` |
|
||||
| `STREAM_MAX_DELAY` | 流式输出最大延迟 | `0.024` |
|
||||
| `STREAM_SHORT_TEXT_THRESHOLD`| 短文本阈值 | `10` |
|
||||
| `STREAM_LONG_TEXT_THRESHOLD` | 长文本阈值 | `50` |
|
||||
| `STREAM_CHUNK_SIZE` | 流式输出块大小 | `5` |
|
||||
| **伪流式 (Fake Stream) 相关** | | |
|
||||
| `FAKE_STREAM_ENABLED` | 是否启用伪流式传输 | `false` |
|
||||
| `FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS` | 伪流式传输时发送心跳空数据的间隔秒数 | `5` |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Pull Request 或 Issue。
|
||||
|
||||
## 🎉 特别鸣谢
|
||||
|
||||
特别鸣谢以下项目和平台为本项目提供图床服务:
|
||||
|
||||
* [PicGo](https://www.picgo.net/)
|
||||
* [SM.MS](https://smms.app/)
|
||||
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed) 开源项目
|
||||
|
||||
## 🙏 感谢贡献者
|
||||
|
||||
感谢所有为本项目做出贡献的开发者!
|
||||
欢迎通过提交 Pull Request 或 Issue 来为项目做出贡献。
|
||||
|
||||
[](https://github.com/snailyp/gemini-balance/graphs/contributors)
|
||||
|
||||
@@ -267,9 +242,15 @@ app/
|
||||
|
||||
[](https://star-history.com/#snailyp/gemini-balance&Date)
|
||||
|
||||
## 🎉 特别鸣谢
|
||||
|
||||
* [PicGo](https://www.picgo.net/)
|
||||
* [SM.MS](https://smms.app/)
|
||||
* [CloudFlare-ImgBed](https://github.com/MarSeventh/CloudFlare-ImgBed)
|
||||
|
||||
## 💖 友情项目
|
||||
|
||||
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - OneLine一线:AI驱动的热点事件时间轴生成工具
|
||||
* **[OneLine](https://github.com/chengtx809/OneLine)** by [chengtx809](https://github.com/chengtx809) - AI 驱动的热点事件时间轴生成工具。
|
||||
|
||||
## 🎁 项目支持
|
||||
|
||||
@@ -277,4 +258,19 @@ app/
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目采用 CC BY-NC 4.0(署名-非商业性使用)协议,禁止任何形式的商业倒卖服务,详见 LICENSE 文件。
|
||||
本项目采用 [CC BY-NC 4.0](LICENSE)(署名-非商业性使用)协议。
|
||||
|
||||
|
||||
## 赞助商
|
||||
|
||||
特别感谢 [DigitalOcean](https://m.do.co/c/b249dd7f3b4c) 为本项目提供稳定可靠的云基础设施支持。
|
||||
|
||||
<a href="https://m.do.co/c/b249dd7f3b4c">
|
||||
<img src="files/dataocean.svg" alt="DigitalOcean Logo" width="200"/>
|
||||
</a>
|
||||
|
||||
本项目的 CDN 加速和安全防护由 [Tencent EdgeOne](https://edgeone.ai/?from=github) 赞助。
|
||||
|
||||
<a href="https://edgeone.ai/?from=github">
|
||||
<img src="https://edgeone.ai/media/34fe3a45-492d-4ea4-ae5d-ea1087ca7b4b.png" alt="EdgeOne Logo" width="200"/>
|
||||
</a>
|
||||
|
||||
@@ -6,7 +6,7 @@ import datetime
|
||||
import json
|
||||
from typing import Any, Dict, List, Type, get_args, get_origin
|
||||
|
||||
from pydantic import ValidationError, ValidationInfo, field_validator
|
||||
from pydantic import ValidationError, ValidationInfo, field_validator, Field
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy import insert, select, update
|
||||
|
||||
@@ -76,7 +76,7 @@ class Settings(BaseSettings):
|
||||
FILTERED_MODELS: List[str] = DEFAULT_FILTER_MODELS
|
||||
TOOLS_CODE_EXECUTION_ENABLED: bool = False
|
||||
# 是否启用网址上下文
|
||||
URL_CONTEXT_ENABLED: bool = True
|
||||
URL_CONTEXT_ENABLED: bool = False
|
||||
URL_CONTEXT_MODELS: List[str] = ["gemini-2.5-pro","gemini-2.5-flash","gemini-2.5-flash-lite","gemini-2.0-flash","gemini-2.0-flash-live-001"]
|
||||
SHOW_SEARCH_LINK: bool = True
|
||||
SHOW_THINKING_PROCESS: bool = True
|
||||
@@ -131,6 +131,14 @@ class Settings(BaseSettings):
|
||||
FILES_CLEANUP_INTERVAL_HOURS: int = 1
|
||||
FILES_USER_ISOLATION_ENABLED: bool = True
|
||||
|
||||
# Admin Session Configuration
|
||||
ADMIN_SESSION_EXPIRE: int = Field(
|
||||
default=3600,
|
||||
ge=300,
|
||||
le=86400,
|
||||
description="Admin session expiration time in seconds (5 minutes to 24 hours)"
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# 设置默认AUTH_TOKEN(如果未提供)
|
||||
|
||||
@@ -9,7 +9,7 @@ from app.config.config import settings, sync_initial_settings
|
||||
from app.database.connection import connect_to_db, disconnect_from_db
|
||||
from app.database.initialization import initialize_database
|
||||
from app.exception.exceptions import setup_exception_handlers
|
||||
from app.log.logger import get_application_logger
|
||||
from app.log.logger import get_application_logger, setup_access_logging
|
||||
from app.middleware.middleware import setup_middlewares
|
||||
from app.router.routes import setup_routers
|
||||
from app.scheduler.scheduled_tasks import start_scheduler, stop_scheduler
|
||||
@@ -150,4 +150,7 @@ def create_app() -> FastAPI:
|
||||
# 配置路由
|
||||
setup_routers(app)
|
||||
|
||||
# 配置访问日志API密钥隐藏
|
||||
setup_access_logging()
|
||||
|
||||
return app
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""
|
||||
数据库服务模块
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import func, desc, asc, select, insert, update, delete
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from sqlalchemy import asc, delete, desc, func, insert, select, update
|
||||
|
||||
from app.database.connection import database
|
||||
from app.database.models import Settings, ErrorLog, RequestLog, FileRecord, FileState
|
||||
from app.database.models import ErrorLog, FileRecord, FileState, RequestLog, Settings
|
||||
from app.log.logger import get_database_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_database_logger()
|
||||
|
||||
@@ -15,7 +19,7 @@ logger = get_database_logger()
|
||||
async def get_all_settings() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有设置
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 设置列表
|
||||
"""
|
||||
@@ -31,10 +35,10 @@ async def get_all_settings() -> List[Dict[str, Any]]:
|
||||
async def get_setting(key: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取指定键的设置
|
||||
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 设置信息,如果不存在则返回None
|
||||
"""
|
||||
@@ -47,22 +51,24 @@ async def get_setting(key: str) -> Optional[Dict[str, Any]]:
|
||||
raise
|
||||
|
||||
|
||||
async def update_setting(key: str, value: str, description: Optional[str] = None) -> bool:
|
||||
async def update_setting(
|
||||
key: str, value: str, description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
更新设置
|
||||
|
||||
|
||||
Args:
|
||||
key: 设置键名
|
||||
value: 设置值
|
||||
description: 设置描述
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
try:
|
||||
# 检查设置是否存在
|
||||
setting = await get_setting(key)
|
||||
|
||||
|
||||
if setting:
|
||||
# 更新设置
|
||||
query = (
|
||||
@@ -71,7 +77,7 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
.values(
|
||||
value=value,
|
||||
description=description if description else setting["description"],
|
||||
updated_at=datetime.now()
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
)
|
||||
await database.execute(query)
|
||||
@@ -79,15 +85,12 @@ async def update_setting(key: str, value: str, description: Optional[str] = None
|
||||
return True
|
||||
else:
|
||||
# 插入设置
|
||||
query = (
|
||||
insert(Settings)
|
||||
.values(
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now()
|
||||
)
|
||||
query = insert(Settings).values(
|
||||
key=key,
|
||||
value=value,
|
||||
description=description,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Inserted setting: {key}")
|
||||
@@ -103,17 +106,17 @@ async def add_error_log(
|
||||
error_type: Optional[str] = None,
|
||||
error_log: Optional[str] = None,
|
||||
error_code: Optional[int] = None,
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None
|
||||
request_msg: Optional[Union[Dict[str, Any], str]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加错误日志
|
||||
|
||||
|
||||
Args:
|
||||
gemini_key: Gemini API密钥
|
||||
error_log: 错误日志
|
||||
error_code: 错误代码 (例如 HTTP 状态码)
|
||||
request_msg: 请求消息
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否添加成功
|
||||
"""
|
||||
@@ -128,22 +131,19 @@ async def add_error_log(
|
||||
request_msg_json = {"message": request_msg}
|
||||
else:
|
||||
request_msg_json = None
|
||||
|
||||
|
||||
# 插入错误日志
|
||||
query = (
|
||||
insert(ErrorLog)
|
||||
.values(
|
||||
gemini_key=gemini_key,
|
||||
error_type=error_type,
|
||||
error_log=error_log,
|
||||
model_name=model_name,
|
||||
error_code=error_code,
|
||||
request_msg=request_msg_json,
|
||||
request_time=datetime.now()
|
||||
)
|
||||
query = insert(ErrorLog).values(
|
||||
gemini_key=gemini_key,
|
||||
error_type=error_type,
|
||||
error_log=error_log,
|
||||
model_name=model_name,
|
||||
error_code=error_code,
|
||||
request_msg=request_msg_json,
|
||||
request_time=datetime.now(),
|
||||
)
|
||||
await database.execute(query)
|
||||
logger.info(f"Added error log for key: {gemini_key}")
|
||||
logger.info(f"Added error log for key: {redact_key_for_logging(gemini_key)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add error log: {str(e)}")
|
||||
@@ -158,8 +158,8 @@ async def get_error_logs(
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
sort_by: str = 'id',
|
||||
sort_order: str = 'desc'
|
||||
sort_by: str = "id",
|
||||
sort_order: str = "desc",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取错误日志,支持搜索、日期过滤和排序
|
||||
@@ -186,15 +186,15 @@ async def get_error_logs(
|
||||
ErrorLog.error_type,
|
||||
ErrorLog.error_log,
|
||||
ErrorLog.error_code,
|
||||
ErrorLog.request_time
|
||||
ErrorLog.request_time,
|
||||
)
|
||||
|
||||
|
||||
if key_search:
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%"))
|
||||
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
@@ -205,10 +205,12 @@ async def get_error_logs(
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
logger.warning(
|
||||
f"Invalid format for error_code_search: '{error_code_search}'. Expected an integer. Skipping error code filter."
|
||||
)
|
||||
|
||||
sort_column = getattr(ErrorLog, sort_by, ErrorLog.id)
|
||||
if sort_order.lower() == 'asc':
|
||||
if sort_order.lower() == "asc":
|
||||
query = query.order_by(asc(sort_column))
|
||||
else:
|
||||
query = query.order_by(desc(sort_column))
|
||||
@@ -227,7 +229,7 @@ async def get_error_logs_count(
|
||||
error_search: Optional[str] = None,
|
||||
error_code_search: Optional[str] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> int:
|
||||
"""
|
||||
获取符合条件的错误日志总数
|
||||
@@ -249,8 +251,8 @@ async def get_error_logs_count(
|
||||
query = query.where(ErrorLog.gemini_key.ilike(f"%{key_search}%"))
|
||||
if error_search:
|
||||
query = query.where(
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%")) |
|
||||
(ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
(ErrorLog.error_type.ilike(f"%{error_search}%"))
|
||||
| (ErrorLog.error_log.ilike(f"%{error_search}%"))
|
||||
)
|
||||
if start_date:
|
||||
query = query.where(ErrorLog.request_time >= start_date)
|
||||
@@ -261,8 +263,9 @@ async def get_error_logs_count(
|
||||
error_code_int = int(error_code_search)
|
||||
query = query.where(ErrorLog.error_code == error_code_int)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter.")
|
||||
|
||||
logger.warning(
|
||||
f"Invalid format for error_code_search in count: '{error_code_search}'. Expected an integer. Skipping error code filter."
|
||||
)
|
||||
|
||||
count_result = await database.fetch_one(query)
|
||||
return count_result[0] if count_result else 0
|
||||
@@ -288,12 +291,14 @@ async def get_error_log_details(log_id: int) -> Optional[Dict[str, Any]]:
|
||||
if result:
|
||||
# 将 request_msg (JSONB) 转换为字符串以便在 API 中返回
|
||||
log_dict = dict(result)
|
||||
if 'request_msg' in log_dict and log_dict['request_msg'] is not None:
|
||||
if "request_msg" in log_dict and log_dict["request_msg"] is not None:
|
||||
# 确保即使是 None 或非 JSON 数据也能处理
|
||||
try:
|
||||
log_dict['request_msg'] = json.dumps(log_dict['request_msg'], ensure_ascii=False, indent=2)
|
||||
log_dict["request_msg"] = json.dumps(
|
||||
log_dict["request_msg"], ensure_ascii=False, indent=2
|
||||
)
|
||||
except TypeError:
|
||||
log_dict['request_msg'] = str(log_dict['request_msg'])
|
||||
log_dict["request_msg"] = str(log_dict["request_msg"])
|
||||
return log_dict
|
||||
else:
|
||||
return None
|
||||
@@ -326,12 +331,15 @@ async def delete_error_logs_by_ids(log_ids: List[int]) -> int:
|
||||
# 注意:databases 的 execute 不返回 rowcount,所以我们不能直接返回删除的数量
|
||||
# 返回 log_ids 的长度作为尝试删除的数量,或者返回 0/1 表示操作尝试
|
||||
logger.info(f"Attempted bulk deletion for error logs with IDs: {log_ids}")
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
return len(log_ids) # 返回尝试删除的数量
|
||||
except Exception as e:
|
||||
# 数据库连接或执行错误
|
||||
logger.error(f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"Error during bulk deletion of error logs {log_ids}: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
"""
|
||||
根据 ID 删除单个错误日志 (异步)。
|
||||
@@ -348,7 +356,9 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
exists = await database.fetch_one(check_query)
|
||||
|
||||
if not exists:
|
||||
logger.warning(f"Attempted to delete non-existent error log with ID: {log_id}")
|
||||
logger.warning(
|
||||
f"Attempted to delete non-existent error log with ID: {log_id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 执行删除
|
||||
@@ -359,35 +369,31 @@ async def delete_error_log_by_id(log_id: int) -> bool:
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting error log with ID {log_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
async def delete_all_error_logs() -> int:
|
||||
"""
|
||||
删除所有错误日志条目。
|
||||
|
||||
|
||||
Returns:
|
||||
int: 被删除的错误日志数量。
|
||||
int: 被删除的错误日志数量。如果使用的数据库驱动不支持返回受影响行数,则返回 -1 表示操作成功。
|
||||
"""
|
||||
try:
|
||||
# 1. 获取删除前的总数
|
||||
count_query = select(func.count()).select_from(ErrorLog)
|
||||
total_to_delete = await database.fetch_val(count_query)
|
||||
|
||||
if total_to_delete == 0:
|
||||
logger.info("No error logs found to delete.")
|
||||
return 0
|
||||
|
||||
# 2. 执行删除操作
|
||||
# 直接执行删除操作,避免不必要的查询
|
||||
delete_query = delete(ErrorLog)
|
||||
await database.execute(delete_query)
|
||||
|
||||
logger.info(f"Successfully deleted all {total_to_delete} error logs.")
|
||||
return total_to_delete
|
||||
|
||||
logger.info("Successfully deleted all error logs.")
|
||||
|
||||
# 由于 databases 库的 execute 方法不返回受影响的行数,
|
||||
# 返回 -1 表示删除操作成功执行,但具体删除数量未知
|
||||
# 这比先查询再删除的方式更高效
|
||||
return -1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete all error logs: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
# 新增函数:添加请求日志
|
||||
async def add_request_log(
|
||||
model_name: Optional[str],
|
||||
@@ -395,7 +401,7 @@ async def add_request_log(
|
||||
is_success: bool,
|
||||
status_code: Optional[int] = None,
|
||||
latency_ms: Optional[int] = None,
|
||||
request_time: Optional[datetime] = None
|
||||
request_time: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加 API 请求日志
|
||||
@@ -420,7 +426,7 @@ async def add_request_log(
|
||||
api_key=api_key,
|
||||
is_success=is_success,
|
||||
status_code=status_code,
|
||||
latency_ms=latency_ms
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
await database.execute(query)
|
||||
return True
|
||||
@@ -431,6 +437,7 @@ async def add_request_log(
|
||||
|
||||
# ==================== 文件记录相关函数 ====================
|
||||
|
||||
|
||||
async def create_file_record(
|
||||
name: str,
|
||||
mime_type: str,
|
||||
@@ -444,11 +451,11 @@ async def create_file_record(
|
||||
display_name: Optional[str] = None,
|
||||
sha256_hash: Optional[str] = None,
|
||||
upload_url: Optional[str] = None,
|
||||
user_token: Optional[str] = None
|
||||
user_token: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
创建文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称(格式: files/{file_id})
|
||||
mime_type: MIME 类型
|
||||
@@ -462,7 +469,7 @@ async def create_file_record(
|
||||
sha256_hash: SHA256 哈希值
|
||||
upload_url: 临时上传 URL
|
||||
user_token: 上传用户的 token
|
||||
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 创建的文件记录
|
||||
"""
|
||||
@@ -480,10 +487,10 @@ async def create_file_record(
|
||||
uri=uri,
|
||||
api_key=api_key,
|
||||
upload_url=upload_url,
|
||||
user_token=user_token
|
||||
user_token=user_token,
|
||||
)
|
||||
await database.execute(query)
|
||||
|
||||
|
||||
# 返回创建的记录
|
||||
return await get_file_record_by_name(name)
|
||||
except Exception as e:
|
||||
@@ -494,10 +501,10 @@ async def create_file_record(
|
||||
async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
根据文件名获取文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称(格式: files/{file_id})
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[Dict[str, Any]]: 文件记录,如果不存在则返回 None
|
||||
"""
|
||||
@@ -510,24 +517,23 @@ async def get_file_record_by_name(name: str) -> Optional[Dict[str, Any]]:
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def update_file_record_state(
|
||||
file_name: str,
|
||||
state: FileState,
|
||||
update_time: Optional[datetime] = None,
|
||||
upload_completed: Optional[datetime] = None,
|
||||
sha256_hash: Optional[str] = None
|
||||
sha256_hash: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
更新文件记录状态
|
||||
|
||||
|
||||
Args:
|
||||
file_name: 文件名
|
||||
state: 新状态
|
||||
update_time: 更新时间
|
||||
upload_completed: 上传完成时间
|
||||
sha256_hash: SHA256 哈希值
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否更新成功
|
||||
"""
|
||||
@@ -539,14 +545,14 @@ async def update_file_record_state(
|
||||
values["upload_completed"] = upload_completed
|
||||
if sha256_hash:
|
||||
values["sha256_hash"] = sha256_hash
|
||||
|
||||
|
||||
query = update(FileRecord).where(FileRecord.name == file_name).values(**values)
|
||||
result = await database.execute(query)
|
||||
|
||||
|
||||
if result:
|
||||
logger.info(f"Updated file record state for {file_name} to {state}")
|
||||
return True
|
||||
|
||||
|
||||
logger.warning(f"File record not found for update: {file_name}")
|
||||
return False
|
||||
except Exception as e:
|
||||
@@ -558,31 +564,33 @@ async def list_file_records(
|
||||
user_token: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
page_size: int = 10,
|
||||
page_token: Optional[str] = None
|
||||
page_token: Optional[str] = None,
|
||||
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
列出文件记录
|
||||
|
||||
|
||||
Args:
|
||||
user_token: 用户 token(如果提供,只返回该用户的文件)
|
||||
api_key: API Key(如果提供,只返回使用该 key 的文件)
|
||||
page_size: 每页大小
|
||||
page_token: 分页标记(偏移量)
|
||||
|
||||
|
||||
Returns:
|
||||
tuple[List[Dict[str, Any]], Optional[str]]: (文件列表, 下一页标记)
|
||||
"""
|
||||
try:
|
||||
logger.debug(f"list_file_records called with page_size={page_size}, page_token={page_token}")
|
||||
logger.debug(
|
||||
f"list_file_records called with page_size={page_size}, page_token={page_token}"
|
||||
)
|
||||
query = select(FileRecord).where(
|
||||
FileRecord.expiration_time > datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
if user_token:
|
||||
query = query.where(FileRecord.user_token == user_token)
|
||||
if api_key:
|
||||
query = query.where(FileRecord.api_key == api_key)
|
||||
|
||||
|
||||
# 使用偏移量进行分页
|
||||
offset = 0
|
||||
if page_token:
|
||||
@@ -591,16 +599,18 @@ async def list_file_records(
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid page token: {page_token}")
|
||||
offset = 0
|
||||
|
||||
|
||||
# 按ID升序排列,使用 OFFSET 和 LIMIT
|
||||
query = query.order_by(FileRecord.id).offset(offset).limit(page_size + 1)
|
||||
|
||||
|
||||
results = await database.fetch_all(query)
|
||||
|
||||
|
||||
logger.debug(f"Query returned {len(results)} records")
|
||||
if results:
|
||||
logger.debug(f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}")
|
||||
|
||||
logger.debug(
|
||||
f"First record ID: {results[0]['id']}, Last record ID: {results[-1]['id']}"
|
||||
)
|
||||
|
||||
# 处理分页
|
||||
has_next = len(results) > page_size
|
||||
if has_next:
|
||||
@@ -608,11 +618,13 @@ async def list_file_records(
|
||||
# 下一页的偏移量是当前偏移量加上本页返回的记录数
|
||||
next_offset = offset + page_size
|
||||
next_page_token = str(next_offset)
|
||||
logger.debug(f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}")
|
||||
logger.debug(
|
||||
f"Has next page, offset={offset}, page_size={page_size}, next_page_token={next_page_token}"
|
||||
)
|
||||
else:
|
||||
next_page_token = None
|
||||
logger.debug(f"No next page, returning {len(results)} results")
|
||||
|
||||
|
||||
return [dict(row) for row in results], next_page_token
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list file records: {str(e)}")
|
||||
@@ -622,10 +634,10 @@ async def list_file_records(
|
||||
async def delete_file_record(name: str) -> bool:
|
||||
"""
|
||||
删除文件记录
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称
|
||||
|
||||
|
||||
Returns:
|
||||
bool: 是否删除成功
|
||||
"""
|
||||
@@ -641,7 +653,7 @@ async def delete_file_record(name: str) -> bool:
|
||||
async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
删除已过期的文件记录
|
||||
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 删除的记录列表
|
||||
"""
|
||||
@@ -651,16 +663,16 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
FileRecord.expiration_time <= datetime.now(timezone.utc)
|
||||
)
|
||||
expired_records = await database.fetch_all(query)
|
||||
|
||||
|
||||
if not expired_records:
|
||||
return []
|
||||
|
||||
|
||||
# 执行删除
|
||||
delete_query = delete(FileRecord).where(
|
||||
FileRecord.expiration_time <= datetime.now(timezone.utc)
|
||||
)
|
||||
await database.execute(delete_query)
|
||||
|
||||
|
||||
logger.info(f"Deleted {len(expired_records)} expired file records")
|
||||
return [dict(record) for record in expired_records]
|
||||
except Exception as e:
|
||||
@@ -671,17 +683,17 @@ async def delete_expired_file_records() -> List[Dict[str, Any]]:
|
||||
async def get_file_api_key(name: str) -> Optional[str]:
|
||||
"""
|
||||
获取文件对应的 API Key
|
||||
|
||||
|
||||
Args:
|
||||
name: 文件名称
|
||||
|
||||
|
||||
Returns:
|
||||
Optional[str]: API Key,如果文件不存在或已过期则返回 None
|
||||
"""
|
||||
try:
|
||||
query = select(FileRecord.api_key).where(
|
||||
(FileRecord.name == name) &
|
||||
(FileRecord.expiration_time > datetime.now(timezone.utc))
|
||||
(FileRecord.name == name)
|
||||
& (FileRecord.expiration_time > datetime.now(timezone.utc))
|
||||
)
|
||||
result = await database.fetch_one(query)
|
||||
return result["api_key"] if result else None
|
||||
|
||||
@@ -80,3 +80,36 @@ class ResetSelectedKeysRequest(BaseModel):
|
||||
|
||||
class VerifySelectedKeysRequest(BaseModel):
|
||||
keys: List[str]
|
||||
|
||||
|
||||
class GeminiEmbedContent(BaseModel):
|
||||
"""嵌入内容模型"""
|
||||
|
||||
parts: List[Dict[str, str]]
|
||||
|
||||
|
||||
class GeminiEmbedRequest(BaseModel):
|
||||
"""单一嵌入请求模型"""
|
||||
|
||||
content: GeminiEmbedContent
|
||||
taskType: Optional[
|
||||
Literal[
|
||||
"TASK_TYPE_UNSPECIFIED",
|
||||
"RETRIEVAL_QUERY",
|
||||
"RETRIEVAL_DOCUMENT",
|
||||
"SEMANTIC_SIMILARITY",
|
||||
"CLASSIFICATION",
|
||||
"CLUSTERING",
|
||||
"QUESTION_ANSWERING",
|
||||
"FACT_VERIFICATION",
|
||||
"CODE_RETRIEVAL_QUERY",
|
||||
]
|
||||
] = None
|
||||
title: Optional[str] = None
|
||||
outputDimensionality: Optional[int] = None
|
||||
|
||||
|
||||
class GeminiBatchEmbedRequest(BaseModel):
|
||||
"""批量嵌入请求模型"""
|
||||
|
||||
requests: List[GeminiEmbedRequest]
|
||||
|
||||
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
||||
max_tokens: Optional[int] = None
|
||||
top_p: Optional[float] = DEFAULT_TOP_P
|
||||
top_k: Optional[int] = DEFAULT_TOP_K
|
||||
n: Optional[int] = 1
|
||||
stop: Optional[Union[List[str],str]] = None
|
||||
reasoning_effort: Optional[str] = None
|
||||
tools: Optional[Union[List[Dict[str, Any]], Dict[str, Any]]] = []
|
||||
|
||||
@@ -42,21 +42,35 @@ class GeminiResponseHandler(ResponseHandler):
|
||||
def _handle_openai_stream_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
response, model, stream=True, gemini_format=False
|
||||
)
|
||||
if not text and not tool_calls and not reasoning_content:
|
||||
delta = {}
|
||||
else:
|
||||
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
|
||||
if tool_calls:
|
||||
delta["tool_calls"] = tool_calls
|
||||
choices = []
|
||||
candidates = response.get("candidates", [])
|
||||
|
||||
for candidate in candidates:
|
||||
index = candidate.get("index", 0)
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
{"candidates": [candidate]}, model, stream=True, gemini_format=False
|
||||
)
|
||||
|
||||
if not text and not tool_calls and not reasoning_content:
|
||||
delta = {}
|
||||
else:
|
||||
delta = {"content": text, "reasoning_content": reasoning_content, "role": "assistant"}
|
||||
if tool_calls:
|
||||
delta["tool_calls"] = tool_calls
|
||||
|
||||
choice = {
|
||||
"index": index,
|
||||
"delta": delta,
|
||||
"finish_reason": finish_reason
|
||||
}
|
||||
choices.append(choice)
|
||||
|
||||
template_chunk = {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||
"choices": choices,
|
||||
}
|
||||
if usage_metadata:
|
||||
template_chunk["usage"] = {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)}
|
||||
@@ -66,26 +80,31 @@ def _handle_openai_stream_response(
|
||||
def _handle_openai_normal_response(
|
||||
response: Dict[str, Any], model: str, finish_reason: str, usage_metadata: Optional[Dict[str, Any]]
|
||||
) -> Dict[str, Any]:
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
response, model, stream=False, gemini_format=False
|
||||
)
|
||||
choices = []
|
||||
candidates = response.get("candidates", [])
|
||||
|
||||
for i, candidate in enumerate(candidates):
|
||||
text, reasoning_content, tool_calls, _ = _extract_result(
|
||||
{"candidates": [candidate]}, model, stream=False, gemini_format=False
|
||||
)
|
||||
choice = {
|
||||
"index": i,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
"reasoning_content": reasoning_content,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
choices.append(choice)
|
||||
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
"reasoning_content": reasoning_content,
|
||||
"tool_calls": tool_calls,
|
||||
},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
"choices": choices,
|
||||
"usage": {"prompt_tokens": usage_metadata.get("promptTokenCount", 0), "completion_tokens": usage_metadata.get("candidatesTokenCount",0), "total_tokens": usage_metadata.get("totalTokenCount", 0)},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Callable, TypeVar
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_retry_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
T = TypeVar("T")
|
||||
logger = get_retry_logger()
|
||||
@@ -37,7 +38,7 @@ class RetryHandler:
|
||||
new_key = await key_manager.handle_api_failure(old_key, retries)
|
||||
if new_key:
|
||||
kwargs[self.key_arg] = new_key
|
||||
logger.info(f"Switched to new API key: {new_key}")
|
||||
logger.info(f"Switched to new API key: {redact_key_for_logging(new_key)}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import platform
|
||||
import sys
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
from app.utils.helpers import redact_key_for_logging as _redact_key_for_logging
|
||||
|
||||
# ANSI转义序列颜色代码
|
||||
COLORS = {
|
||||
@@ -12,6 +14,8 @@ COLORS = {
|
||||
"CRITICAL": "\033[1;31m", # 红色加粗
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Windows系统启用ANSI支持
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
@@ -35,6 +39,50 @@ class ColoredFormatter(logging.Formatter):
|
||||
return super().format(record)
|
||||
|
||||
|
||||
class AccessLogFormatter(logging.Formatter):
|
||||
"""
|
||||
Custom access log formatter that redacts API keys in URLs
|
||||
"""
|
||||
|
||||
# API key patterns to match in URLs
|
||||
API_KEY_PATTERNS = [
|
||||
r'\bAIza[0-9A-Za-z_-]{35}', # Google API keys (like Gemini)
|
||||
r'\bsk-[0-9A-Za-z_-]{20,}', # OpenAI and general sk- prefixed keys
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Compile regex patterns for better performance
|
||||
self.compiled_patterns = [re.compile(pattern) for pattern in self.API_KEY_PATTERNS]
|
||||
|
||||
def format(self, record):
|
||||
# Format the record normally first
|
||||
formatted_msg = super().format(record)
|
||||
|
||||
# Redact API keys in the formatted message
|
||||
return self._redact_api_keys_in_message(formatted_msg)
|
||||
|
||||
def _redact_api_keys_in_message(self, message: str) -> str:
|
||||
"""
|
||||
Replace API keys in log message with redacted versions
|
||||
"""
|
||||
try:
|
||||
for pattern in self.compiled_patterns:
|
||||
def replace_key(match):
|
||||
key = match.group(0)
|
||||
return _redact_key_for_logging(key)
|
||||
|
||||
message = pattern.sub(replace_key, message)
|
||||
|
||||
return message
|
||||
except Exception as e:
|
||||
# Log the error but don't expose the original message in case it contains keys
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error redacting API keys in access log: {e}")
|
||||
return "[LOG_REDACTION_ERROR]"
|
||||
|
||||
|
||||
# 日志格式 - 使用 fileloc 并设置固定宽度 (例如 30)
|
||||
FORMATTER = ColoredFormatter(
|
||||
"%(asctime)s | %(levelname)-17s | %(fileloc)-30s | %(message)s"
|
||||
@@ -235,3 +283,47 @@ def get_files_logger():
|
||||
def get_vertex_express_logger():
|
||||
return Logger.setup_logger("vertex_express")
|
||||
|
||||
|
||||
def get_gemini_embedding_logger():
|
||||
return Logger.setup_logger("gemini_embedding")
|
||||
|
||||
|
||||
def setup_access_logging():
|
||||
"""
|
||||
Configure uvicorn access logging with API key redaction
|
||||
|
||||
This function sets up a custom access log formatter that automatically
|
||||
redacts API keys in HTTP access logs. It works by:
|
||||
|
||||
1. Intercepting uvicorn's access log messages
|
||||
2. Using regex patterns to find API keys in URLs
|
||||
3. Replacing them with redacted versions (first6...last6)
|
||||
|
||||
Supported API key formats:
|
||||
- Google/Gemini API keys: AIza[35 chars]
|
||||
- OpenAI API keys: sk-[48 chars]
|
||||
- General sk- prefixed keys: sk-[20+ chars]
|
||||
|
||||
Usage:
|
||||
- Automatically called in main.py when running with uvicorn
|
||||
- For production deployment with gunicorn, ensure this is called in startup
|
||||
"""
|
||||
# Get the uvicorn access logger
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
|
||||
# Remove existing handlers to avoid duplicate logs
|
||||
for handler in access_logger.handlers[:]:
|
||||
access_logger.removeHandler(handler)
|
||||
|
||||
# Create new handler with our custom formatter that includes timestamp and log level
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
access_formatter = AccessLogFormatter("%(asctime)s | %(levelname)-8s | %(message)s")
|
||||
handler.setFormatter(access_formatter)
|
||||
|
||||
# Add the handler to uvicorn access logger
|
||||
access_logger.addHandler(handler)
|
||||
access_logger.setLevel(logging.INFO)
|
||||
access_logger.propagate = False
|
||||
|
||||
return access_logger
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import Logger, get_config_routes_logger
|
||||
from app.service.config.config_service import ConfigService
|
||||
from app.service.proxy.proxy_check_service import get_proxy_check_service, ProxyCheckResult
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||
|
||||
@@ -63,10 +65,10 @@ class DeleteKeysRequest(BaseModel):
|
||||
async def delete_single_key(key_to_delete: str, request: Request):
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized attempt to delete key: {key_to_delete}")
|
||||
logger.warning(f"Unauthorized attempt to delete key: {redact_key_for_logging(key_to_delete)}")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
try:
|
||||
logger.info(f"Attempting to delete key: {key_to_delete}")
|
||||
logger.info(f"Attempting to delete key: {redact_key_for_logging(key_to_delete)}")
|
||||
result = await ConfigService.delete_key(key_to_delete)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
@@ -79,7 +81,7 @@ async def delete_single_key(key_to_delete: str, request: Request):
|
||||
except HTTPException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting key '{key_to_delete}': {e}", exc_info=True)
|
||||
logger.error(f"Error deleting key '{redact_key_for_logging(key_to_delete)}': {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Error deleting key: {str(e)}")
|
||||
|
||||
|
||||
@@ -131,3 +133,93 @@ async def get_ui_models(request: Request):
|
||||
status_code=500,
|
||||
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
class ProxyCheckRequest(BaseModel):
|
||||
"""Proxy check request"""
|
||||
proxy: str = Field(..., description="Proxy address to check")
|
||||
use_cache: bool = Field(True, description="Whether to use cached results")
|
||||
|
||||
|
||||
class ProxyBatchCheckRequest(BaseModel):
|
||||
"""Batch proxy check request"""
|
||||
proxies: List[str] = Field(..., description="List of proxy addresses to check")
|
||||
use_cache: bool = Field(True, description="Whether to use cached results")
|
||||
max_concurrent: int = Field(5, description="Maximum concurrent check count", ge=1, le=10)
|
||||
|
||||
|
||||
@router.post("/proxy/check", response_model=ProxyCheckResult)
|
||||
async def check_single_proxy(proxy_request: ProxyCheckRequest, request: Request):
|
||||
"""Check if a single proxy is available"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to proxy check")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
logger.info(f"Checking single proxy: {proxy_request.proxy}")
|
||||
proxy_service = get_proxy_check_service()
|
||||
result = await proxy_service.check_single_proxy(
|
||||
proxy_request.proxy,
|
||||
proxy_request.use_cache
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy check failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Proxy check failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/proxy/check-all", response_model=List[ProxyCheckResult])
|
||||
async def check_all_proxies(batch_request: ProxyBatchCheckRequest, request: Request):
|
||||
"""Check multiple proxies availability"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to batch proxy check")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
logger.info(f"Batch checking {len(batch_request.proxies)} proxies")
|
||||
proxy_service = get_proxy_check_service()
|
||||
results = await proxy_service.check_multiple_proxies(
|
||||
batch_request.proxies,
|
||||
batch_request.use_cache,
|
||||
batch_request.max_concurrent
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Batch proxy check failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Batch proxy check failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/proxy/cache-stats")
|
||||
async def get_proxy_cache_stats(request: Request):
|
||||
"""Get proxy check cache statistics"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to proxy cache stats")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
proxy_service = get_proxy_check_service()
|
||||
stats = proxy_service.get_cache_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
logger.error(f"Get proxy cache stats failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Get cache stats failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/proxy/clear-cache")
|
||||
async def clear_proxy_cache(request: Request):
|
||||
"""Clear proxy check cache"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to clear proxy cache")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
try:
|
||||
proxy_service = get_proxy_check_service()
|
||||
proxy_service.clear_cache()
|
||||
return {"success": True, "message": "Proxy check cache cleared"}
|
||||
except Exception as e:
|
||||
logger.error(f"Clear proxy cache failed: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Clear cache failed: {str(e)}")
|
||||
|
||||
@@ -192,10 +192,10 @@ async def delete_all_error_logs_api(request: Request):
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to delete all error logs")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
try:
|
||||
deleted_count = await error_log_service.process_delete_all_error_logs()
|
||||
logger.info(f"Successfully deleted all {deleted_count} error logs.")
|
||||
await error_log_service.process_delete_all_error_logs()
|
||||
logger.info("Successfully deleted all error logs.")
|
||||
# No body needed for 204 response
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
@@ -203,8 +203,8 @@ async def delete_all_error_logs_api(request: Request):
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Internal server error during deletion of all logs"
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.delete("/errors/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
"""
|
||||
@@ -214,7 +214,7 @@ async def delete_error_log_api(request: Request, log_id: int = Path(..., ge=1)):
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning(f"Unauthorized access attempt to delete error log ID: {log_id}")
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
|
||||
try:
|
||||
success = await error_log_service.process_delete_error_log_by_id(log_id)
|
||||
if not success:
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.log.logger import get_files_logger
|
||||
from app.core.security import SecurityService
|
||||
from app.service.files.files_service import get_files_service
|
||||
from app.service.files.file_upload_handler import get_upload_handler
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_files_logger()
|
||||
|
||||
@@ -202,7 +203,7 @@ async def handle_upload(
|
||||
):
|
||||
"""处理文件上传请求"""
|
||||
try:
|
||||
logger.info(f"Handling upload request: {request.method} {upload_path}, key={key}")
|
||||
logger.info(f"Handling upload request: {request.method} {upload_path}, key={redact_key_for_logging(key)}")
|
||||
|
||||
# 從查詢參數獲取 upload_id
|
||||
upload_id = request.query_params.get("upload_id")
|
||||
@@ -222,7 +223,7 @@ async def handle_upload(
|
||||
# 使用真實的 API key 構建完整的 Google 上傳 URL
|
||||
# 保留原始 URL 的所有參數,但使用真實的 API key
|
||||
upload_url = original_upload_url
|
||||
logger.info(f"Using real API key for upload: {real_api_key[:8]}...{real_api_key[-4:]}")
|
||||
logger.info(f"Using real API key for upload: {redact_key_for_logging(real_api_key)}")
|
||||
|
||||
# 代理上传请求
|
||||
upload_handler = get_upload_handler()
|
||||
|
||||
@@ -5,14 +5,16 @@ import asyncio
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_gemini_logger
|
||||
from app.core.security import SecurityService
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest
|
||||
from app.domain.gemini_models import GeminiContent, GeminiRequest, ResetSelectedKeysRequest, VerifySelectedKeysRequest, GeminiEmbedRequest, GeminiBatchEmbedRequest
|
||||
from app.service.chat.gemini_chat_service import GeminiChatService
|
||||
from app.service.embedding.gemini_embedding_service import GeminiEmbeddingService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.tts.native.tts_routes import get_tts_chat_service
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.core.constants import API_VERSION
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter(prefix=f"/gemini/{API_VERSION}")
|
||||
router_v1beta = APIRouter(prefix=f"/{API_VERSION}")
|
||||
@@ -37,6 +39,11 @@ async def get_chat_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
return GeminiChatService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
async def get_embedding_service(key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""获取Gemini嵌入服务实例"""
|
||||
return GeminiEmbeddingService(settings.BASE_URL, key_manager)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
@router_v1beta.get("/models")
|
||||
async def list_models(
|
||||
@@ -49,10 +56,10 @@ async def list_models(
|
||||
logger.info("Handling Gemini models list request")
|
||||
|
||||
try:
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
models_data = await model_service.get_gemini_models(api_key)
|
||||
if not models_data or "models" not in models_data:
|
||||
@@ -125,7 +132,7 @@ async def generate_content(
|
||||
logger.info(f"TTS responseModalities: {response_modalities}")
|
||||
logger.info(f"TTS speechConfig: {speech_config}")
|
||||
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
@@ -169,7 +176,7 @@ async def stream_generate_content(
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
@@ -198,7 +205,7 @@ async def 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}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
@@ -209,6 +216,63 @@ async def count_tokens(
|
||||
api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
@router.post("/models/{model_name}:embedContent")
|
||||
@router_v1beta.post("/models/{model_name}:embedContent")
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def embed_content(
|
||||
model_name: str,
|
||||
request: GeminiEmbedRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service)
|
||||
):
|
||||
"""处理 Gemini 单一嵌入请求"""
|
||||
operation_name = "gemini_embed_content"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Embedding content generation failed"):
|
||||
logger.info(f"Handling Gemini embedding request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
response = await embedding_service.embed_content(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/models/{model_name}:batchEmbedContents")
|
||||
@router_v1beta.post("/models/{model_name}:batchEmbedContents")
|
||||
@RetryHandler(key_arg="api_key")
|
||||
async def batch_embed_contents(
|
||||
model_name: str,
|
||||
request: GeminiBatchEmbedRequest,
|
||||
_=Depends(security_service.verify_key_or_goog_api_key),
|
||||
api_key: str = Depends(get_next_working_key),
|
||||
key_manager: KeyManager = Depends(get_key_manager),
|
||||
embedding_service: GeminiEmbeddingService = Depends(get_embedding_service)
|
||||
):
|
||||
"""处理 Gemini 批量嵌入请求"""
|
||||
operation_name = "gemini_batch_embed_contents"
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Batch embedding content generation failed"):
|
||||
logger.info(f"Handling Gemini batch embedding request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
response = await embedding_service.batch_embed_contents(
|
||||
model=model_name,
|
||||
request=request,
|
||||
api_key=api_key
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/reset-all-fail-counts")
|
||||
@@ -274,9 +338,9 @@ async def reset_selected_key_fail_counts(
|
||||
if result:
|
||||
reset_count += 1
|
||||
else:
|
||||
logger.warning(f"Key not found during selective reset: {key}")
|
||||
logger.warning(f"Key not found during selective reset: {redact_key_for_logging(key)}")
|
||||
except Exception as key_error:
|
||||
logger.error(f"Error resetting key {key}: {str(key_error)}")
|
||||
logger.error(f"Error resetting key {redact_key_for_logging(key)}: {str(key_error)}")
|
||||
errors.append(f"Key {key}: {str(key_error)}")
|
||||
|
||||
if errors:
|
||||
@@ -303,7 +367,7 @@ async def reset_selected_key_fail_counts(
|
||||
async def reset_key_fail_count(api_key: str, key_manager: KeyManager = Depends(get_key_manager)):
|
||||
"""重置指定Gemini API密钥的失败计数"""
|
||||
logger.info("-" * 50 + "reset_gemini_key_fail_count" + "-" * 50)
|
||||
logger.info(f"Resetting failure count for API key: {api_key}")
|
||||
logger.info(f"Resetting failure count for API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
try:
|
||||
result = await key_manager.reset_key_failure_count(api_key)
|
||||
@@ -348,7 +412,7 @@ async def verify_key(api_key: str, chat_service: GeminiChatService = Depends(get
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Verification exception for key: {api_key}, incrementing failure count")
|
||||
logger.warning(f"Verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count")
|
||||
|
||||
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||
|
||||
@@ -389,14 +453,14 @@ async def verify_selected_keys(
|
||||
return api_key, "valid", None
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.warning(f"Key verification failed for {api_key}: {error_message}")
|
||||
logger.warning(f"Key verification failed for {redact_key_for_logging(api_key)}: {error_message}")
|
||||
async with key_manager.failure_count_lock:
|
||||
if api_key in key_manager.key_failure_counts:
|
||||
key_manager.key_failure_counts[api_key] += 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, incrementing failure count")
|
||||
logger.warning(f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, incrementing failure count")
|
||||
else:
|
||||
key_manager.key_failure_counts[api_key] = 1
|
||||
logger.warning(f"Bulk verification exception for key: {api_key}, initializing failure count to 1")
|
||||
logger.warning(f"Bulk verification exception for key: {redact_key_for_logging(api_key)}, initializing failure count to 1")
|
||||
failed_keys[api_key] = error_message
|
||||
return api_key, "invalid", error_message
|
||||
|
||||
|
||||
83
app/router/key_routes.py
Normal file
83
app/router/key_routes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.core.security import verify_auth_token
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/keys")
|
||||
async def get_keys_paginated(
|
||||
request: Request,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
search: str = None,
|
||||
fail_count_threshold: int = None,
|
||||
status: str = "all", # 'valid', 'invalid', 'all'
|
||||
key_manager: KeyManager = Depends(get_key_manager_instance),
|
||||
):
|
||||
"""
|
||||
Get paginated, filtered, and searched keys.
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
|
||||
all_keys_with_status = await key_manager.get_all_keys_with_fail_count()
|
||||
|
||||
# Filter by status
|
||||
if status == "valid":
|
||||
keys_to_filter = all_keys_with_status["valid_keys"]
|
||||
elif status == "invalid":
|
||||
keys_to_filter = all_keys_with_status["invalid_keys"]
|
||||
else:
|
||||
# Combine both for 'all' status, which might be useful for a unified view if ever needed
|
||||
keys_to_filter = {**all_keys_with_status["valid_keys"], **all_keys_with_status["invalid_keys"]}
|
||||
|
||||
|
||||
# Further filtering (search and fail_count_threshold)
|
||||
filtered_keys = {}
|
||||
for key, fail_count in keys_to_filter.items():
|
||||
search_match = True
|
||||
if search:
|
||||
search_match = search.lower() in key.lower()
|
||||
|
||||
fail_count_match = True
|
||||
if fail_count_threshold is not None:
|
||||
fail_count_match = fail_count >= fail_count_threshold
|
||||
|
||||
if search_match and fail_count_match:
|
||||
filtered_keys[key] = fail_count
|
||||
|
||||
# Pagination
|
||||
keys_list = list(filtered_keys.items())
|
||||
total_items = len(keys_list)
|
||||
start_index = (page - 1) * limit
|
||||
end_index = start_index + limit
|
||||
paginated_keys = dict(keys_list[start_index:end_index])
|
||||
|
||||
return {
|
||||
"keys": paginated_keys,
|
||||
"total_items": total_items,
|
||||
"total_pages": (total_items + limit - 1) // limit,
|
||||
"current_page": page,
|
||||
}
|
||||
|
||||
@router.get("/api/keys/all")
|
||||
async def get_all_keys(
|
||||
request: Request,
|
||||
key_manager: KeyManager = Depends(get_key_manager_instance),
|
||||
):
|
||||
"""
|
||||
Get all keys (both valid and invalid) for bulk operations.
|
||||
"""
|
||||
auth_token = request.cookies.get("auth_token")
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
|
||||
|
||||
all_keys_with_status = await key_manager.get_all_keys_with_fail_count()
|
||||
|
||||
return {
|
||||
"valid_keys": list(all_keys_with_status["valid_keys"].keys()),
|
||||
"invalid_keys": list(all_keys_with_status["invalid_keys"].keys()),
|
||||
"total_count": len(all_keys_with_status["valid_keys"]) + len(all_keys_with_status["invalid_keys"])
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from app.handler.error_handler import handle_route_errors
|
||||
from app.log.logger import get_openai_compatible_logger
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.openai_compatiable.openai_compatiable_service import OpenAICompatiableService
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
@@ -45,8 +46,8 @@ async def list_models(
|
||||
operation_name = "list_models"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await openai_service.get_models(api_key)
|
||||
|
||||
|
||||
@@ -69,7 +70,7 @@ async def chat_completion(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {current_api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(current_api_key)}")
|
||||
|
||||
if is_image_chat:
|
||||
response = await openai_service.create_image_chat_completion(request, current_api_key)
|
||||
@@ -107,7 +108,7 @@ async def embedding(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await openai_service.create_embeddings(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.service.image.image_create_service import ImageCreateService
|
||||
from app.service.tts.tts_service import TTSService
|
||||
from app.service.key.key_manager import KeyManager, get_key_manager_instance
|
||||
from app.service.model.model_service import ModelService
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_openai_logger()
|
||||
@@ -59,8 +60,8 @@ async def list_models(
|
||||
operation_name = "list_models"
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info("Handling models list request")
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
return await model_service.get_gemini_openai_models(api_key)
|
||||
|
||||
|
||||
@@ -84,7 +85,7 @@ async def chat_completion(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling chat completion request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {current_api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(current_api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(request.model):
|
||||
raise HTTPException(
|
||||
@@ -129,7 +130,7 @@ async def embedding(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling embedding request for model: {request.model}")
|
||||
api_key = await key_manager.get_next_working_key()
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
response = await embedding_service.create_embedding(
|
||||
input_text=request.input, model=request.model, api_key=api_key
|
||||
)
|
||||
@@ -170,6 +171,6 @@ async def text_to_speech(
|
||||
async with handle_route_errors(logger, operation_name):
|
||||
logger.info(f"Handling TTS request for model: {request.model}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
audio_data = await tts_service.create_tts(request, api_key)
|
||||
return Response(content=audio_data, media_type="audio/wav")
|
||||
|
||||
@@ -6,9 +6,22 @@ from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.config.config import settings
|
||||
from app.core.security import verify_auth_token
|
||||
from app.log.logger import get_routes_logger
|
||||
from app.router import error_log_routes, gemini_routes, openai_routes, config_routes, scheduler_routes, stats_routes, version_routes, openai_compatiable_routes, vertex_express_routes, files_routes
|
||||
from app.router import (
|
||||
config_routes,
|
||||
error_log_routes,
|
||||
files_routes,
|
||||
gemini_routes,
|
||||
key_routes,
|
||||
openai_compatiable_routes,
|
||||
openai_routes,
|
||||
scheduler_routes,
|
||||
stats_routes,
|
||||
version_routes,
|
||||
vertex_express_routes,
|
||||
)
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.stats.stats_service import StatsService
|
||||
|
||||
@@ -35,6 +48,7 @@ def setup_routers(app: FastAPI) -> None:
|
||||
app.include_router(openai_compatiable_routes.router)
|
||||
app.include_router(vertex_express_routes.router)
|
||||
app.include_router(files_routes.router)
|
||||
app.include_router(key_routes.router)
|
||||
|
||||
setup_page_routes(app)
|
||||
|
||||
@@ -67,9 +81,12 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
if verify_auth_token(auth_token):
|
||||
logger.info("Successful authentication")
|
||||
response = RedirectResponse(url="/config", status_code=302)
|
||||
response = RedirectResponse(url="/keys", status_code=302)
|
||||
response.set_cookie(
|
||||
key="auth_token", value=auth_token, httponly=True, max_age=3600
|
||||
key="auth_token",
|
||||
value=auth_token,
|
||||
httponly=True,
|
||||
max_age=settings.ADMIN_SESSION_EXPIRE,
|
||||
)
|
||||
return response
|
||||
logger.warning("Failed authentication attempt with invalid token")
|
||||
@@ -89,7 +106,9 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
|
||||
key_manager = await get_key_manager_instance()
|
||||
keys_status = await key_manager.get_keys_by_status()
|
||||
total_keys = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
|
||||
total_keys = len(keys_status["valid_keys"]) + len(
|
||||
keys_status["invalid_keys"]
|
||||
)
|
||||
valid_key_count = len(keys_status["valid_keys"])
|
||||
invalid_key_count = len(keys_status["invalid_keys"])
|
||||
|
||||
@@ -102,8 +121,8 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
"keys_status.html",
|
||||
{
|
||||
"request": request,
|
||||
"valid_keys": keys_status["valid_keys"],
|
||||
"invalid_keys": keys_status["invalid_keys"],
|
||||
"valid_keys": {},
|
||||
"invalid_keys": {},
|
||||
"total_keys": total_keys,
|
||||
"valid_key_count": valid_key_count,
|
||||
"invalid_key_count": invalid_key_count,
|
||||
@@ -112,8 +131,26 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving keys status or API stats: {str(e)}")
|
||||
raise
|
||||
|
||||
# Even if there's an error, render the page with whatever data is available
|
||||
# or with empty/default values, so the frontend can still load.
|
||||
return templates.TemplateResponse(
|
||||
"keys_status.html",
|
||||
{
|
||||
"request": request,
|
||||
"valid_keys": {},
|
||||
"invalid_keys": {},
|
||||
"total_keys": 0,
|
||||
"valid_key_count": 0,
|
||||
"invalid_key_count": 0,
|
||||
"api_stats": { # Provide a default structure for api_stats
|
||||
"calls_1m": {"total": 0, "success": 0, "failure": 0},
|
||||
"calls_1h": {"total": 0, "success": 0, "failure": 0},
|
||||
"calls_24h": {"total": 0, "success": 0, "failure": 0},
|
||||
"calls_month": {"total": 0, "success": 0, "failure": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(request: Request):
|
||||
"""配置编辑页面"""
|
||||
@@ -122,13 +159,15 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to config page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
logger.info("Config page accessed successfully")
|
||||
return templates.TemplateResponse("config_editor.html", {"request": request})
|
||||
return templates.TemplateResponse(
|
||||
"config_editor.html", {"request": request}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error accessing config page: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
@app.get("/logs", response_class=HTMLResponse)
|
||||
async def logs_page(request: Request):
|
||||
"""错误日志页面"""
|
||||
@@ -137,7 +176,7 @@ def setup_page_routes(app: FastAPI) -> None:
|
||||
if not auth_token or not verify_auth_token(auth_token):
|
||||
logger.warning("Unauthorized access attempt to logs page")
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
logger.info("Logs page accessed successfully")
|
||||
return templates.TemplateResponse("error_logs.html", {"request": request})
|
||||
except Exception as e:
|
||||
@@ -167,6 +206,7 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
Args:
|
||||
app: FastAPI应用程序实例
|
||||
"""
|
||||
|
||||
@app.get("/api/stats/details")
|
||||
async def api_stats_details(request: Request, period: str):
|
||||
"""获取指定时间段内的 API 调用详情"""
|
||||
@@ -181,8 +221,12 @@ def setup_api_stats_routes(app: FastAPI) -> None:
|
||||
details = await stats_service.get_api_call_details(period)
|
||||
return details
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid period requested for API stats details: {period} - {str(e)}")
|
||||
logger.warning(
|
||||
f"Invalid period requested for API stats details: {period} - {str(e)}"
|
||||
)
|
||||
return {"error": str(e)}, 400
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching API stats details for period {period}: {str(e)}")
|
||||
logger.error(
|
||||
f"Error fetching API stats details for period {period}: {str(e)}"
|
||||
)
|
||||
return {"error": "Internal server error"}, 500
|
||||
|
||||
@@ -3,6 +3,7 @@ from starlette import status
|
||||
from app.core.security import verify_auth_token
|
||||
from app.service.stats.stats_service import StatsService
|
||||
from app.log.logger import get_stats_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_stats_logger()
|
||||
|
||||
@@ -48,7 +49,7 @@ async def get_key_usage_details(key: str):
|
||||
return {}
|
||||
return usage_details
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching key usage details for key {key[:4]}...: {e}")
|
||||
logger.error(f"Error fetching key usage details for key {redact_key_for_logging(key)}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"获取密钥使用详情时出错: {e}"
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.service.model.model_service import ModelService
|
||||
from app.handler.retry_handler import RetryHandler
|
||||
from app.handler.error_handler import handle_route_errors
|
||||
from app.core.constants import API_VERSION
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
router = APIRouter(prefix=f"/vertex-express/{API_VERSION}")
|
||||
logger = get_vertex_express_logger()
|
||||
@@ -45,10 +46,10 @@ async def list_models(
|
||||
logger.info("Handling Gemini models list request")
|
||||
|
||||
try:
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=503, detail="No valid API keys available to fetch models.")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
models_data = await model_service.get_gemini_models(api_key)
|
||||
if not models_data or "models" not in models_data:
|
||||
@@ -105,7 +106,7 @@ async def generate_content(
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Content generation failed"):
|
||||
logger.info(f"Handling Gemini content generation request for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
@@ -133,7 +134,7 @@ async def stream_generate_content(
|
||||
async with handle_route_errors(logger, operation_name, failure_message="Streaming request initiation failed"):
|
||||
logger.info(f"Handling Gemini streaming content generation for model: {model_name}")
|
||||
logger.debug(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||
logger.info(f"Using API key: {api_key}")
|
||||
logger.info(f"Using API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
if not await model_service.check_model_support(model_name):
|
||||
raise HTTPException(status_code=400, detail=f"Model {model_name} is not supported")
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.service.error_log.error_log_service import delete_old_error_logs
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
from app.service.request_log.request_log_service import delete_old_request_logs_task
|
||||
from app.service.files.files_service import get_files_service
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = Logger.setup_logger("scheduler")
|
||||
|
||||
@@ -51,7 +52,7 @@ async def check_failed_keys():
|
||||
|
||||
for key in keys_to_check:
|
||||
# 隐藏部分 key 用于日志记录
|
||||
log_key = f"{key[:4]}...{key[-4:]}" if len(key) > 8 else key
|
||||
log_key = redact_key_for_logging(key)
|
||||
logger.info(f"Verifying key: {log_key}...")
|
||||
try:
|
||||
# 构造测试请求
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.log.logger import get_gemini_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.database.services import add_error_log, add_request_log, get_file_api_key
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_gemini_logger()
|
||||
|
||||
@@ -80,6 +81,21 @@ def _clean_json_schema_properties(obj: Any) -> Any:
|
||||
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""构建工具"""
|
||||
|
||||
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
|
||||
"""检查内容中是否包含 functionCall"""
|
||||
if not contents or not isinstance(contents, list):
|
||||
return False
|
||||
for content in contents:
|
||||
if not content or not isinstance(content, dict) or "parts" not in content:
|
||||
continue
|
||||
parts = content.get("parts", [])
|
||||
if not parts or not isinstance(parts, list):
|
||||
continue
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and "functionCall" in part:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
record = dict()
|
||||
for item in tools:
|
||||
@@ -103,6 +119,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
record[k] = v
|
||||
return record
|
||||
|
||||
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
|
||||
"""检查请求是否要求结构化JSON输出"""
|
||||
try:
|
||||
generation_config = payload.get("generationConfig", {})
|
||||
return generation_config.get("responseMimeType") == "application/json"
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
tool = dict()
|
||||
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||
if payload.get("tools") and isinstance(payload.get("tools"), dict):
|
||||
@@ -111,21 +135,27 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if items and isinstance(items, list):
|
||||
tool.update(_merge_tools(items))
|
||||
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
and not _has_image_parts(payload.get("contents", []))
|
||||
):
|
||||
tool["codeExecution"] = {}
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
real_model = _get_real_model(model)
|
||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||
tool["urlContext"] = {}
|
||||
|
||||
# "Tool use with a response mime type: 'application/json' is unsupported"
|
||||
# Gemini API限制:不支持同时使用tools和结构化输出(response_mime_type='application/json')
|
||||
# 当请求指定了JSON响应格式时,跳过所有工具的添加以避免API错误
|
||||
has_structured_output = _is_structured_output_request(payload)
|
||||
if not has_structured_output:
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
and not _has_image_parts(payload.get("contents", []))
|
||||
):
|
||||
tool["codeExecution"] = {}
|
||||
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
real_model = _get_real_model(model)
|
||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||
tool["urlContext"] = {}
|
||||
|
||||
# 解决 "Tool use with function calling is unsupported" 问题
|
||||
if tool.get("functionDeclarations"):
|
||||
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
|
||||
tool.pop("googleSearch", None)
|
||||
tool.pop("codeExecution", None)
|
||||
tool.pop("urlContext", None)
|
||||
@@ -227,7 +257,7 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
elif model in settings.THINKING_BUDGET_MAP:
|
||||
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
|
||||
@@ -281,10 +311,10 @@ class GeminiChatService:
|
||||
logger.info(f"Request contains file references: {file_names}")
|
||||
file_api_key = await get_file_api_key(file_names[0])
|
||||
if file_api_key:
|
||||
logger.info(f"Found API key for file {file_names[0]}: {file_api_key[:8]}...{file_api_key[-4:]}")
|
||||
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
|
||||
api_key = file_api_key # 使用文件的 API key
|
||||
else:
|
||||
logger.warning(f"No API key found for file {file_names[0]}, using default key: {api_key[:8]}...{api_key[-4:]}")
|
||||
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
payload = _build_payload(model, request)
|
||||
start_time = time.perf_counter()
|
||||
@@ -387,10 +417,10 @@ class GeminiChatService:
|
||||
logger.info(f"Request contains file references: {file_names}")
|
||||
file_api_key = await get_file_api_key(file_names[0])
|
||||
if file_api_key:
|
||||
logger.info(f"Found API key for file {file_names[0]}: {file_api_key[:8]}...{file_api_key[-4:]}")
|
||||
logger.info(f"Found API key for file {file_names[0]}: {redact_key_for_logging(file_api_key)}")
|
||||
api_key = file_api_key # 使用文件的 API key
|
||||
else:
|
||||
logger.warning(f"No API key found for file {file_names[0]}, using default key: {api_key[:8]}...{api_key[-4:]}")
|
||||
logger.warning(f"No API key found for file {file_names[0]}, using default key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
retries = 0
|
||||
max_retries = settings.MAX_RETRIES
|
||||
@@ -457,7 +487,7 @@ class GeminiChatService:
|
||||
|
||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break
|
||||
|
||||
@@ -196,6 +196,10 @@ def _build_payload(
|
||||
|
||||
# 处理 max_tokens 参数
|
||||
_validate_and_set_max_tokens(payload, request.max_tokens, logger)
|
||||
|
||||
# 处理 n 参数
|
||||
if request.n is not None and request.n > 0:
|
||||
payload["generationConfig"]["candidateCount"] = request.n
|
||||
|
||||
if request.model.endswith("-image") or request.model.endswith("-image-generation"):
|
||||
payload["generationConfig"]["responseModalities"] = ["Text", "Image"]
|
||||
@@ -206,7 +210,7 @@ def _build_payload(
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
|
||||
if request.model in settings.THINKING_BUDGET_MAP:
|
||||
elif _get_real_model(request.model) in settings.THINKING_BUDGET_MAP:
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(request.model, 1000),
|
||||
@@ -358,39 +362,24 @@ class OpenAIChatService:
|
||||
logger.info(
|
||||
f"Fake streaming enabled for model: {model}. Calling non-streaming endpoint."
|
||||
)
|
||||
keep_sending_empty_data = True
|
||||
|
||||
async def send_empty_data_locally() -> AsyncGenerator[str, None]:
|
||||
"""定期发送空数据以保持连接"""
|
||||
while keep_sending_empty_data:
|
||||
await asyncio.sleep(settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS)
|
||||
if keep_sending_empty_data:
|
||||
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||
|
||||
empty_data_generator = send_empty_data_locally()
|
||||
|
||||
api_response_task = asyncio.create_task(
|
||||
self.api_client.generate_content(payload, model, api_key)
|
||||
)
|
||||
|
||||
i = 0
|
||||
try:
|
||||
while not api_response_task.done():
|
||||
try:
|
||||
next_empty_chunk = await asyncio.wait_for(
|
||||
empty_data_generator.__anext__(), timeout=0.1
|
||||
)
|
||||
yield next_empty_chunk
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except (
|
||||
StopAsyncIteration
|
||||
):
|
||||
break
|
||||
|
||||
response = await api_response_task
|
||||
i = i + 1
|
||||
"""定期发送空数据以保持连接"""
|
||||
if i >= settings.FAKE_STREAM_EMPTY_DATA_INTERVAL_SECONDS :
|
||||
i = 0
|
||||
empty_chunk = self.response_handler.handle_response({}, model, stream=True, finish_reason='stop', usage_metadata=None)
|
||||
yield f"data: {json.dumps(empty_chunk)}\n\n"
|
||||
logger.debug("Sent empty data chunk for fake stream heartbeat.")
|
||||
await asyncio.sleep(1)
|
||||
finally:
|
||||
keep_sending_empty_data = False
|
||||
response = await api_response_task
|
||||
|
||||
if response and response.get("candidates"):
|
||||
response = self.response_handler.handle_response(response, model, stream=True, finish_reason='stop', usage_metadata=response.get("usageMetadata", {}))
|
||||
@@ -511,7 +500,7 @@ class OpenAIChatService:
|
||||
f"Streaming API call failed with error: {error_log_msg}. Attempt {retries} of {max_retries} with key {current_attempt_key}"
|
||||
)
|
||||
|
||||
match = re.search(r"status code (\\d+)", error_log_msg)
|
||||
match = re.search(r"status code (\d+)", error_log_msg)
|
||||
if match:
|
||||
status_code = int(match.group(1))
|
||||
else:
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.log.logger import get_gemini_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.database.services import add_error_log, add_request_log
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_gemini_logger()
|
||||
|
||||
@@ -58,6 +59,21 @@ def _clean_json_schema_properties(obj: Any) -> Any:
|
||||
def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""构建工具"""
|
||||
|
||||
def _has_function_call(contents: List[Dict[str, Any]]) -> bool:
|
||||
"""检查内容中是否包含 functionCall"""
|
||||
if not contents or not isinstance(contents, list):
|
||||
return False
|
||||
for content in contents:
|
||||
if not content or not isinstance(content, dict) or "parts" not in content:
|
||||
continue
|
||||
parts = content.get("parts", [])
|
||||
if not parts or not isinstance(parts, list):
|
||||
continue
|
||||
for part in parts:
|
||||
if isinstance(part, dict) and "functionCall" in part:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _merge_tools(tools: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
record = dict()
|
||||
for item in tools:
|
||||
@@ -81,6 +97,14 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
record[k] = v
|
||||
return record
|
||||
|
||||
def _is_structured_output_request(payload: Dict[str, Any]) -> bool:
|
||||
"""检查请求是否要求结构化JSON输出"""
|
||||
try:
|
||||
generation_config = payload.get("generationConfig", {})
|
||||
return generation_config.get("responseMimeType") == "application/json"
|
||||
except (AttributeError, TypeError):
|
||||
return False
|
||||
|
||||
tool = dict()
|
||||
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||
if payload.get("tools") and isinstance(payload.get("tools"), dict):
|
||||
@@ -89,21 +113,27 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
if items and isinstance(items, list):
|
||||
tool.update(_merge_tools(items))
|
||||
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
and not _has_image_parts(payload.get("contents", []))
|
||||
):
|
||||
tool["codeExecution"] = {}
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
real_model = _get_real_model(model)
|
||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||
tool["urlContext"] = {}
|
||||
# "Tool use with a response mime type: 'application/json' is unsupported"
|
||||
# Gemini API限制:不支持同时使用tools和结构化输出(response_mime_type='application/json')
|
||||
# 当请求指定了JSON响应格式时,跳过所有工具的添加以避免API错误
|
||||
has_structured_output = _is_structured_output_request(payload)
|
||||
if not has_structured_output:
|
||||
if (
|
||||
settings.TOOLS_CODE_EXECUTION_ENABLED
|
||||
and not (model.endswith("-search") or "-thinking" in model)
|
||||
and not _has_image_parts(payload.get("contents", []))
|
||||
):
|
||||
tool["codeExecution"] = {}
|
||||
|
||||
if model.endswith("-search"):
|
||||
tool["googleSearch"] = {}
|
||||
|
||||
real_model = _get_real_model(model)
|
||||
if real_model in settings.URL_CONTEXT_MODELS and settings.URL_CONTEXT_ENABLED:
|
||||
tool["urlContext"] = {}
|
||||
|
||||
# 解决 "Tool use with function calling is unsupported" 问题
|
||||
if tool.get("functionDeclarations"):
|
||||
if tool.get("functionDeclarations") or _has_function_call(payload.get("contents", [])):
|
||||
tool.pop("googleSearch", None)
|
||||
tool.pop("codeExecution", None)
|
||||
tool.pop("urlContext", None)
|
||||
@@ -165,7 +195,7 @@ def _build_payload(model: str, request: GeminiRequest) -> Dict[str, Any]:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 128}
|
||||
else:
|
||||
payload["generationConfig"]["thinkingConfig"] = {"thinkingBudget": 0}
|
||||
elif model in settings.THINKING_BUDGET_MAP:
|
||||
elif _get_real_model(model) in settings.THINKING_BUDGET_MAP:
|
||||
if settings.SHOW_THINKING_PROCESS:
|
||||
payload["generationConfig"]["thinkingConfig"] = {
|
||||
"thinkingBudget": settings.THINKING_BUDGET_MAP.get(model,1000),
|
||||
@@ -325,7 +355,7 @@ class GeminiChatService:
|
||||
|
||||
api_key = await self.key_manager.handle_api_failure(current_attempt_key, retries)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
|
||||
else:
|
||||
logger.error(f"No valid API key available after {retries} retries.")
|
||||
break
|
||||
|
||||
@@ -161,6 +161,80 @@ class GeminiApiClient(ApiClient):
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
return response.json()
|
||||
|
||||
async def embed_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||
"""单一嵌入内容生成"""
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
|
||||
else:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for embedding: {proxy_to_use}")
|
||||
|
||||
headers = self._prepare_headers()
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:embedContent?key={api_key}"
|
||||
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
logger.error(f"Embedding API call failed - Status: {response.status_code}, Content: {error_content}")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Embedding request timeout: {e}")
|
||||
raise Exception(f"Request timeout: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Embedding request error: {e}")
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected embedding error: {e}")
|
||||
raise
|
||||
|
||||
async def batch_embed_contents(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||
"""批量嵌入内容生成"""
|
||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||
model = self._get_real_model(model)
|
||||
|
||||
proxy_to_use = None
|
||||
if settings.PROXIES:
|
||||
if settings.PROXIES_USE_CONSISTENCY_HASH_BY_API_KEY:
|
||||
proxy_to_use = settings.PROXIES[hash(api_key) % len(settings.PROXIES)]
|
||||
else:
|
||||
proxy_to_use = random.choice(settings.PROXIES)
|
||||
logger.info(f"Using proxy for batch embedding: {proxy_to_use}")
|
||||
|
||||
headers = self._prepare_headers()
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy_to_use) as client:
|
||||
url = f"{self.base_url}/models/{model}:batchEmbedContents?key={api_key}"
|
||||
|
||||
try:
|
||||
response = await client.post(url, json=payload, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_content = response.text
|
||||
logger.error(f"Batch embedding API call failed - Status: {response.status_code}, Content: {error_content}")
|
||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.TimeoutException as e:
|
||||
logger.error(f"Batch embedding request timeout: {e}")
|
||||
raise Exception(f"Request timeout: {e}")
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Batch embedding request error: {e}")
|
||||
raise Exception(f"Request error: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected batch embedding error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class OpenaiApiClient(ApiClient):
|
||||
"""OpenAI API客户端"""
|
||||
|
||||
@@ -230,7 +230,7 @@ class ConfigService:
|
||||
key_manager = await get_key_manager_instance()
|
||||
model_service = ModelService()
|
||||
|
||||
api_key = await key_manager.get_first_valid_key()
|
||||
api_key = await key_manager.get_random_valid_key()
|
||||
if not api_key:
|
||||
logger.error("No valid API keys available to fetch model list for UI.")
|
||||
raise HTTPException(
|
||||
|
||||
148
app/service/embedding/gemini_embedding_service.py
Normal file
148
app/service/embedding/gemini_embedding_service.py
Normal file
@@ -0,0 +1,148 @@
|
||||
# app/service/embedding/gemini_embedding_service.py
|
||||
|
||||
import datetime
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
from app.config.config import settings
|
||||
from app.database.services import add_error_log, add_request_log
|
||||
from app.domain.gemini_models import GeminiBatchEmbedRequest, GeminiEmbedRequest
|
||||
from app.log.logger import get_gemini_embedding_logger
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
|
||||
logger = get_gemini_embedding_logger()
|
||||
|
||||
|
||||
def _build_embed_payload(request: GeminiEmbedRequest) -> Dict[str, Any]:
|
||||
"""构建嵌入请求payload"""
|
||||
payload = {"content": request.content.model_dump()}
|
||||
|
||||
if request.taskType:
|
||||
payload["taskType"] = request.taskType
|
||||
if request.title:
|
||||
payload["title"] = request.title
|
||||
if request.outputDimensionality:
|
||||
payload["outputDimensionality"] = request.outputDimensionality
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _build_batch_embed_payload(
|
||||
request: GeminiBatchEmbedRequest, model: str
|
||||
) -> Dict[str, Any]:
|
||||
"""构建批量嵌入请求payload"""
|
||||
requests = []
|
||||
for embed_request in request.requests:
|
||||
embed_payload = _build_embed_payload(embed_request)
|
||||
embed_payload["model"] = (
|
||||
f"models/{model}" # Gemini API要求每个请求包含model字段
|
||||
)
|
||||
requests.append(embed_payload)
|
||||
|
||||
return {"requests": requests}
|
||||
|
||||
|
||||
class GeminiEmbeddingService:
|
||||
"""Gemini嵌入服务"""
|
||||
|
||||
def __init__(self, base_url: str, key_manager: KeyManager):
|
||||
self.api_client = GeminiApiClient(base_url, settings.TIME_OUT)
|
||||
self.key_manager = key_manager
|
||||
|
||||
async def embed_content(
|
||||
self, model: str, request: GeminiEmbedRequest, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""生成单一嵌入内容"""
|
||||
payload = _build_embed_payload(request)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.api_client.embed_content(payload, model, api_key)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"Single embedding API call failed: {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-embed-single",
|
||||
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 batch_embed_contents(
|
||||
self, model: str, request: GeminiBatchEmbedRequest, api_key: str
|
||||
) -> Dict[str, Any]:
|
||||
"""生成批量嵌入内容"""
|
||||
payload = _build_batch_embed_payload(request, model)
|
||||
start_time = time.perf_counter()
|
||||
request_datetime = datetime.datetime.now()
|
||||
is_success = False
|
||||
status_code = None
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = await self.api_client.batch_embed_contents(
|
||||
payload, model, api_key
|
||||
)
|
||||
is_success = True
|
||||
status_code = 200
|
||||
return response
|
||||
except Exception as e:
|
||||
is_success = False
|
||||
error_log_msg = str(e)
|
||||
logger.error(f"Batch embedding API call failed: {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-embed-batch",
|
||||
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,
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from app.config.config import settings
|
||||
from app.database import services as db_services
|
||||
from app.database.models import FileState
|
||||
from app.log.logger import get_files_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_files_logger()
|
||||
|
||||
@@ -144,7 +145,7 @@ class FileUploadHandler:
|
||||
sha256_hash=file_data.get("sha256Hash"),
|
||||
user_token=session_info["user_token"]
|
||||
)
|
||||
logger.info(f"Created file record: name={real_file_name}, api_key={session_info['api_key'][:8]}...{session_info['api_key'][-4:]}")
|
||||
logger.info(f"Created file record: name={real_file_name}, api_key={redact_key_for_logging(session_info['api_key'])}")
|
||||
else:
|
||||
logger.warning(f"No upload session found for URL: {upload_url}")
|
||||
else:
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.database.models import FileState
|
||||
from app.domain.file_models import FileMetadata, ListFilesResponse
|
||||
from fastapi import HTTPException
|
||||
from app.log.logger import get_files_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
from app.service.client.api_client import GeminiApiClient
|
||||
from app.service.key.key_manager import get_key_manager_instance
|
||||
|
||||
@@ -102,7 +103,7 @@ class FilesService:
|
||||
|
||||
# 儲存上傳資訊到 headers 中,供後續使用
|
||||
# 不在這裡創建數據庫記錄,等到上傳完成後再創建
|
||||
logger.info(f"Upload initialized with API key: {api_key[:8]}...{api_key[-4:]}")
|
||||
logger.info(f"Upload initialized with API key: {redact_key_for_logging(api_key)}")
|
||||
|
||||
# 解析响应 - 初始化响应可能是空的
|
||||
response_data = {}
|
||||
@@ -133,7 +134,7 @@ class FilesService:
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"upload_url": upload_url
|
||||
}
|
||||
logger.info(f"Stored upload session for upload_id={upload_id}: api_key={api_key[:8]}...{api_key[-4:]}")
|
||||
logger.info(f"Stored upload session for upload_id={upload_id}: api_key={redact_key_for_logging(api_key)}")
|
||||
logger.debug(f"Total active sessions: {len(_upload_sessions)}")
|
||||
else:
|
||||
logger.warning(f"No upload_id found in upload URL: {upload_url}")
|
||||
@@ -202,7 +203,7 @@ class FilesService:
|
||||
# 先嘗試直接查找
|
||||
session = _upload_sessions.get(key)
|
||||
if session:
|
||||
logger.debug(f"Found session by direct key {key}")
|
||||
logger.debug(f"Found session by direct key {redact_key_for_logging(key)}")
|
||||
return session
|
||||
|
||||
# 如果是 URL,嘗試提取 upload_id
|
||||
@@ -217,7 +218,7 @@ class FilesService:
|
||||
logger.debug(f"Found session by upload_id {upload_id} from URL")
|
||||
return session
|
||||
|
||||
logger.debug(f"No session found for key: {key}")
|
||||
logger.debug(f"No session found for key: {redact_key_for_logging(key)}")
|
||||
return None
|
||||
|
||||
async def get_file(self, file_name: str, user_token: str) -> FileMetadata:
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import asyncio
|
||||
import random
|
||||
from itertools import cycle
|
||||
from typing import Dict, Union
|
||||
|
||||
from app.config.config import settings
|
||||
from app.log.logger import get_key_manager_logger
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
|
||||
logger = get_key_manager_logger()
|
||||
|
||||
@@ -65,7 +67,7 @@ class KeyManager:
|
||||
async with self.failure_count_lock:
|
||||
if key in self.key_failure_counts:
|
||||
self.key_failure_counts[key] = 0
|
||||
logger.info(f"Reset failure count for key: {key}")
|
||||
logger.info(f"Reset failure count for key: {redact_key_for_logging(key)}")
|
||||
return True
|
||||
logger.warning(
|
||||
f"Attempt to reset failure count for non-existent key: {key}"
|
||||
@@ -77,7 +79,7 @@ class KeyManager:
|
||||
async with self.vertex_failure_count_lock:
|
||||
if key in self.vertex_key_failure_counts:
|
||||
self.vertex_key_failure_counts[key] = 0
|
||||
logger.info(f"Reset failure count for Vertex key: {key}")
|
||||
logger.info(f"Reset failure count for Vertex key: {redact_key_for_logging(key)}")
|
||||
return True
|
||||
logger.warning(
|
||||
f"Attempt to reset failure count for non-existent Vertex key: {key}"
|
||||
@@ -116,7 +118,7 @@ class KeyManager:
|
||||
self.key_failure_counts[api_key] += 1
|
||||
if self.key_failure_counts[api_key] >= self.MAX_FAILURES:
|
||||
logger.warning(
|
||||
f"API key {api_key} has failed {self.MAX_FAILURES} times"
|
||||
f"API key {redact_key_for_logging(api_key)} has failed {self.MAX_FAILURES} times"
|
||||
)
|
||||
if retries < settings.MAX_RETRIES:
|
||||
return await self.get_next_working_key()
|
||||
@@ -129,7 +131,7 @@ class KeyManager:
|
||||
self.vertex_key_failure_counts[api_key] += 1
|
||||
if self.vertex_key_failure_counts[api_key] >= self.MAX_FAILURES:
|
||||
logger.warning(
|
||||
f"Vertex Express API key {api_key} has failed {self.MAX_FAILURES} times"
|
||||
f"Vertex Express API key {redact_key_for_logging(api_key)} has failed {self.MAX_FAILURES} times"
|
||||
)
|
||||
|
||||
def get_fail_count(self, key: str) -> int:
|
||||
@@ -140,6 +142,18 @@ class KeyManager:
|
||||
"""获取指定 Vertex 密钥的失败次数"""
|
||||
return self.vertex_key_failure_counts.get(key, 0)
|
||||
|
||||
async def get_all_keys_with_fail_count(self) -> dict:
|
||||
"""获取所有API key及其失败次数"""
|
||||
all_keys = {}
|
||||
async with self.failure_count_lock:
|
||||
for key in self.api_keys:
|
||||
all_keys[key] = self.key_failure_counts.get(key, 0)
|
||||
|
||||
valid_keys = {k: v for k, v in all_keys.items() if v < self.MAX_FAILURES}
|
||||
invalid_keys = {k: v for k, v in all_keys.items() if v >= self.MAX_FAILURES}
|
||||
|
||||
return {"valid_keys": valid_keys, "invalid_keys": invalid_keys, "all_keys": all_keys}
|
||||
|
||||
async def get_keys_by_status(self) -> dict:
|
||||
"""获取分类后的API key列表,包括失败次数"""
|
||||
valid_keys = {}
|
||||
@@ -182,6 +196,25 @@ class KeyManager:
|
||||
return ""
|
||||
return self.api_keys[0]
|
||||
|
||||
async def get_random_valid_key(self) -> str:
|
||||
"""获取随机的有效API key"""
|
||||
valid_keys = []
|
||||
async with self.failure_count_lock:
|
||||
for key in self.key_failure_counts:
|
||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
||||
valid_keys.append(key)
|
||||
|
||||
if valid_keys:
|
||||
return random.choice(valid_keys)
|
||||
|
||||
# 如果没有有效的key,返回第一个key作为fallback
|
||||
if self.api_keys:
|
||||
logger.warning("No valid keys available, returning first key as fallback.")
|
||||
return self.api_keys[0]
|
||||
|
||||
logger.warning("API key list is empty, cannot get random valid key.")
|
||||
return ""
|
||||
|
||||
|
||||
_singleton_instance = None
|
||||
_singleton_lock = asyncio.Lock()
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.database.services import (
|
||||
from app.domain.openai_models import ChatRequest, ImageGenerationRequest
|
||||
from app.service.client.api_client import OpenaiApiClient
|
||||
from app.service.key.key_manager import KeyManager
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
from app.log.logger import get_openai_compatible_logger
|
||||
|
||||
logger = get_openai_compatible_logger()
|
||||
@@ -159,7 +160,7 @@ class OpenAICompatiableService:
|
||||
current_attempt_key, retries
|
||||
)
|
||||
if api_key:
|
||||
logger.info(f"Switched to new API key: {api_key}")
|
||||
logger.info(f"Switched to new API key: {redact_key_for_logging(api_key)}")
|
||||
else:
|
||||
logger.error(
|
||||
f"No valid API key available after {retries} retries."
|
||||
|
||||
7
app/service/proxy/__init__.py
Normal file
7
app/service/proxy/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Proxy service module
|
||||
"""
|
||||
|
||||
from .proxy_check_service import ProxyCheckService
|
||||
|
||||
__all__ = ["ProxyCheckService"]
|
||||
219
app/service/proxy/proxy_check_service.py
Normal file
219
app/service/proxy/proxy_check_service.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Proxy detection service module
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.log.logger import get_config_routes_logger
|
||||
|
||||
logger = get_config_routes_logger()
|
||||
|
||||
|
||||
class ProxyCheckResult(BaseModel):
|
||||
"""Proxy check result model"""
|
||||
proxy: str
|
||||
is_available: bool
|
||||
response_time: Optional[float] = None
|
||||
error_message: Optional[str] = None
|
||||
checked_at: float
|
||||
|
||||
|
||||
class ProxyCheckService:
|
||||
"""Proxy detection service class"""
|
||||
|
||||
# Target URL for checking
|
||||
CHECK_URL = "https://www.google.com"
|
||||
# Timeout in seconds
|
||||
TIMEOUT_SECONDS = 10
|
||||
# Cache duration in seconds
|
||||
CACHE_DURATION = 10 # 10s
|
||||
|
||||
def __init__(self):
|
||||
self._cache: Dict[str, ProxyCheckResult] = {}
|
||||
|
||||
def _is_valid_proxy_format(self, proxy: str) -> bool:
|
||||
"""Validate proxy format"""
|
||||
try:
|
||||
parsed = urlparse(proxy)
|
||||
return parsed.scheme in ['http', 'https', 'socks5'] and parsed.hostname
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _get_cached_result(self, proxy: str) -> Optional[ProxyCheckResult]:
|
||||
"""Get cached check result"""
|
||||
if proxy in self._cache:
|
||||
result = self._cache[proxy]
|
||||
# Check if cache is expired
|
||||
if time.time() - result.checked_at < self.CACHE_DURATION:
|
||||
logger.debug(f"Using cached proxy check result: {proxy}")
|
||||
return result
|
||||
else:
|
||||
# Remove expired cache
|
||||
del self._cache[proxy]
|
||||
return None
|
||||
|
||||
def _cache_result(self, result: ProxyCheckResult) -> None:
|
||||
"""Cache check result"""
|
||||
self._cache[result.proxy] = result
|
||||
|
||||
async def check_single_proxy(self, proxy: str, use_cache: bool = True) -> ProxyCheckResult:
|
||||
"""
|
||||
Check if a single proxy is available
|
||||
|
||||
Args:
|
||||
proxy: Proxy address in format like http://host:port or socks5://host:port
|
||||
use_cache: Whether to use cached results
|
||||
|
||||
Returns:
|
||||
ProxyCheckResult: Check result
|
||||
"""
|
||||
# Check cache first
|
||||
if use_cache:
|
||||
cached = self._get_cached_result(proxy)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Validate proxy format
|
||||
if not self._is_valid_proxy_format(proxy):
|
||||
result = ProxyCheckResult(
|
||||
proxy=proxy,
|
||||
is_available=False,
|
||||
error_message="Invalid proxy format",
|
||||
checked_at=time.time()
|
||||
)
|
||||
self._cache_result(result)
|
||||
return result
|
||||
|
||||
# Perform check
|
||||
start_time = time.time()
|
||||
try:
|
||||
logger.info(f"Starting proxy check: {proxy}")
|
||||
|
||||
timeout = httpx.Timeout(self.TIMEOUT_SECONDS, read=self.TIMEOUT_SECONDS)
|
||||
async with httpx.AsyncClient(timeout=timeout, proxy=proxy) as client:
|
||||
response = await client.head(self.CHECK_URL)
|
||||
|
||||
response_time = time.time() - start_time
|
||||
|
||||
# Check response status
|
||||
is_available = response.status_code in [200, 204, 301, 302, 307, 308]
|
||||
|
||||
result = ProxyCheckResult(
|
||||
proxy=proxy,
|
||||
is_available=is_available,
|
||||
response_time=round(response_time, 3),
|
||||
error_message=None if is_available else f"HTTP {response.status_code}",
|
||||
checked_at=time.time()
|
||||
)
|
||||
|
||||
logger.info(f"Proxy check completed: {proxy}, available: {is_available}, response_time: {response_time:.3f}s")
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
result = ProxyCheckResult(
|
||||
proxy=proxy,
|
||||
is_available=False,
|
||||
error_message="Connection timeout",
|
||||
checked_at=time.time()
|
||||
)
|
||||
logger.warning(f"Proxy check timeout: {proxy}")
|
||||
|
||||
except Exception as e:
|
||||
result = ProxyCheckResult(
|
||||
proxy=proxy,
|
||||
is_available=False,
|
||||
error_message=str(e),
|
||||
checked_at=time.time()
|
||||
)
|
||||
logger.error(f"Proxy check failed: {proxy}, error: {str(e)}")
|
||||
|
||||
# Cache result
|
||||
self._cache_result(result)
|
||||
return result
|
||||
|
||||
async def check_multiple_proxies(
|
||||
self,
|
||||
proxies: List[str],
|
||||
use_cache: bool = True,
|
||||
max_concurrent: int = 5
|
||||
) -> List[ProxyCheckResult]:
|
||||
"""
|
||||
Check multiple proxies concurrently
|
||||
|
||||
Args:
|
||||
proxies: List of proxy addresses
|
||||
use_cache: Whether to use cached results
|
||||
max_concurrent: Maximum concurrent check count
|
||||
|
||||
Returns:
|
||||
List[ProxyCheckResult]: List of check results
|
||||
"""
|
||||
if not proxies:
|
||||
return []
|
||||
|
||||
logger.info(f"Starting batch proxy check for {len(proxies)} proxies")
|
||||
|
||||
# Use semaphore to limit concurrency
|
||||
semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def check_with_semaphore(proxy: str) -> ProxyCheckResult:
|
||||
async with semaphore:
|
||||
return await self.check_single_proxy(proxy, use_cache)
|
||||
|
||||
# Execute checks concurrently
|
||||
tasks = [check_with_semaphore(proxy) for proxy in proxies]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Handle exception results
|
||||
final_results = []
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Proxy check task exception: {proxies[i]}, error: {str(result)}")
|
||||
final_results.append(ProxyCheckResult(
|
||||
proxy=proxies[i],
|
||||
is_available=False,
|
||||
error_message=f"Check task exception: {str(result)}",
|
||||
checked_at=time.time()
|
||||
))
|
||||
else:
|
||||
final_results.append(result)
|
||||
|
||||
available_count = sum(1 for r in final_results if r.is_available)
|
||||
logger.info(f"Batch proxy check completed: {available_count}/{len(proxies)} proxies available")
|
||||
|
||||
return final_results
|
||||
|
||||
def get_cache_stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics"""
|
||||
current_time = time.time()
|
||||
valid_cache_count = sum(
|
||||
1 for result in self._cache.values()
|
||||
if current_time - result.checked_at < self.CACHE_DURATION
|
||||
)
|
||||
|
||||
return {
|
||||
"total_cached": len(self._cache),
|
||||
"valid_cached": valid_cache_count,
|
||||
"expired_cached": len(self._cache) - valid_cache_count
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cache"""
|
||||
self._cache.clear()
|
||||
logger.info("Proxy check cache cleared")
|
||||
|
||||
|
||||
# Global instance
|
||||
_proxy_check_service: Optional[ProxyCheckService] = None
|
||||
|
||||
|
||||
def get_proxy_check_service() -> ProxyCheckService:
|
||||
"""Get proxy check service instance"""
|
||||
global _proxy_check_service
|
||||
if _proxy_check_service is None:
|
||||
_proxy_check_service = ProxyCheckService()
|
||||
return _proxy_check_service
|
||||
315
app/static/css/fonts.css
Normal file
315
app/static/css/fonts.css
Normal file
@@ -0,0 +1,315 @@
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa0ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2ZL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2pL7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa25L7SUc.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/inter/v19/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -16,6 +16,13 @@ const PROXY_REGEX =
|
||||
const VERTEX_API_KEY_REGEX = /AQ\.[a-zA-Z0-9_\-]{50}/g; // 新增 Vertex Express API Key 正则
|
||||
const MASKED_VALUE = "••••••••";
|
||||
|
||||
// API Keys Pagination Constants
|
||||
const API_KEYS_PER_PAGE = 20; // 每页显示的API密钥数量
|
||||
let currentApiKeyPage = 1;
|
||||
let totalApiKeyPages = 1;
|
||||
let allApiKeys = []; // 存储所有API密钥数据
|
||||
let filteredApiKeys = []; // 存储过滤后的API密钥数据
|
||||
|
||||
// DOM Elements - Global Scope for frequently accessed elements
|
||||
const safetySettingsContainer = document.getElementById(
|
||||
"SAFETY_SETTINGS_container"
|
||||
@@ -147,6 +154,17 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (apiKeySearchInput)
|
||||
apiKeySearchInput.addEventListener("input", handleApiKeySearch);
|
||||
|
||||
// API Key Pagination Event Listeners
|
||||
const apiKeyPrevBtn = document.getElementById("apiKeyPrevBtn");
|
||||
const apiKeyNextBtn = document.getElementById("apiKeyNextBtn");
|
||||
|
||||
if (apiKeyPrevBtn) {
|
||||
apiKeyPrevBtn.addEventListener("click", prevApiKeyPage);
|
||||
}
|
||||
if (apiKeyNextBtn) {
|
||||
apiKeyNextBtn.addEventListener("click", nextApiKeyPage);
|
||||
}
|
||||
|
||||
// Bulk Delete API Key Modal Elements and Events
|
||||
const bulkDeleteApiKeyBtn = document.getElementById("bulkDeleteApiKeyBtn");
|
||||
const closeBulkDeleteModalBtn = document.getElementById(
|
||||
@@ -184,6 +202,13 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
const closeProxyModalBtn = document.getElementById("closeProxyModalBtn");
|
||||
const cancelAddProxyBtn = document.getElementById("cancelAddProxyBtn");
|
||||
const confirmAddProxyBtn = document.getElementById("confirmAddProxyBtn");
|
||||
|
||||
// Proxy Check Elements and Events
|
||||
const checkAllProxiesBtn = document.getElementById("checkAllProxiesBtn");
|
||||
const proxyCheckModal = document.getElementById("proxyCheckModal");
|
||||
const closeProxyCheckModalBtn = document.getElementById("closeProxyCheckModalBtn");
|
||||
const closeProxyCheckBtn = document.getElementById("closeProxyCheckBtn");
|
||||
const retryFailedProxiesBtn = document.getElementById("retryFailedProxiesBtn");
|
||||
|
||||
if (addProxyBtn) {
|
||||
addProxyBtn.addEventListener("click", () => {
|
||||
@@ -191,6 +216,25 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (proxyBulkInput) proxyBulkInput.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
if (checkAllProxiesBtn) {
|
||||
checkAllProxiesBtn.addEventListener("click", checkAllProxies);
|
||||
}
|
||||
|
||||
if (closeProxyCheckModalBtn) {
|
||||
closeProxyCheckModalBtn.addEventListener("click", () => closeModal(proxyCheckModal));
|
||||
}
|
||||
|
||||
if (closeProxyCheckBtn) {
|
||||
closeProxyCheckBtn.addEventListener("click", () => closeModal(proxyCheckModal));
|
||||
}
|
||||
|
||||
if (retryFailedProxiesBtn) {
|
||||
retryFailedProxiesBtn.addEventListener("click", () => {
|
||||
// 重试失败的代理检测
|
||||
checkAllProxies();
|
||||
});
|
||||
}
|
||||
if (closeProxyModalBtn)
|
||||
closeProxyModalBtn.addEventListener("click", () => closeModal(proxyModal));
|
||||
if (cancelAddProxyBtn)
|
||||
@@ -898,9 +942,9 @@ function populateForm(config) {
|
||||
'<div class="text-gray-500 text-sm italic">添加自定义请求头,例如 X-Api-Key: your-key</div>';
|
||||
}
|
||||
|
||||
// 4. Populate other array fields (excluding THINKING_MODELS)
|
||||
// 4. Populate other array fields (excluding THINKING_MODELS and API_KEYS)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (Array.isArray(value) && key !== "THINKING_MODELS") {
|
||||
if (Array.isArray(value) && key !== "THINKING_MODELS" && key !== "API_KEYS") {
|
||||
const container = document.getElementById(`${key}_container`);
|
||||
if (container) {
|
||||
value.forEach((itemValue) => {
|
||||
@@ -914,6 +958,17 @@ function populateForm(config) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4.1. 特殊处理API_KEYS - 使用分页
|
||||
if (Array.isArray(config.API_KEYS)) {
|
||||
allApiKeys = config.API_KEYS.filter(key =>
|
||||
typeof key === "string" && key.trim() !== ""
|
||||
);
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
currentApiKeyPage = 1;
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
// 5. Populate non-array/non-budget fields
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (
|
||||
@@ -1036,44 +1091,31 @@ function populateForm(config) {
|
||||
* Handles the bulk addition of API keys from the modal input.
|
||||
*/
|
||||
function handleBulkAddApiKeys() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeyBulkInput || !apiKeyContainer || !apiKeyModal) return;
|
||||
if (!apiKeyBulkInput || !apiKeyModal) return;
|
||||
|
||||
const bulkText = apiKeyBulkInput.value;
|
||||
const extractedKeys = bulkText.match(API_KEY_REGEX) || [];
|
||||
|
||||
const currentKeyInputs = apiKeyContainer.querySelectorAll(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
let currentKeys = Array.from(currentKeyInputs)
|
||||
.map((input) => {
|
||||
return input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value")
|
||||
: input.value;
|
||||
})
|
||||
.filter((key) => key && key.trim() !== "" && key !== MASKED_VALUE);
|
||||
|
||||
const combinedKeys = new Set([...currentKeys, ...extractedKeys]);
|
||||
// 合并现有密钥和新密钥,去重
|
||||
const combinedKeys = new Set([...allApiKeys, ...extractedKeys]);
|
||||
const uniqueKeys = Array.from(combinedKeys);
|
||||
|
||||
apiKeyContainer.innerHTML = ""; // Clear existing items more directly
|
||||
// 更新全局密钥数组
|
||||
allApiKeys = uniqueKeys;
|
||||
|
||||
// 更新过滤后的数组
|
||||
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
uniqueKeys.forEach((key) => {
|
||||
addArrayItemWithValue("API_KEYS", key);
|
||||
});
|
||||
|
||||
const newKeyInputs = apiKeyContainer.querySelectorAll(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
newKeyInputs.forEach((input) => {
|
||||
if (configForm && typeof initializeSensitiveFields === "function") {
|
||||
const focusoutEvent = new Event("focusout", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(focusoutEvent);
|
||||
}
|
||||
});
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
|
||||
closeModal(apiKeyModal);
|
||||
showNotification(`添加/更新了 ${uniqueKeys.length} 个唯一密钥`, "success");
|
||||
@@ -1083,32 +1125,139 @@ function handleBulkAddApiKeys() {
|
||||
* Handles searching/filtering of API keys in the list.
|
||||
*/
|
||||
function handleApiKeySearch() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeySearchInput || !apiKeyContainer) return;
|
||||
if (!apiKeySearchInput) return;
|
||||
|
||||
const searchTerm = apiKeySearchInput.value.toLowerCase();
|
||||
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
|
||||
|
||||
keyItems.forEach((item) => {
|
||||
const input = item.querySelector(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
|
||||
// 过滤API密钥
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
if (input) {
|
||||
const realValue = input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value").toLowerCase()
|
||||
: input.value.toLowerCase();
|
||||
item.style.display = realValue.includes(searchTerm) ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// 重置到第一页
|
||||
currentApiKeyPage = 1;
|
||||
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染当前页的API密钥
|
||||
*/
|
||||
function renderApiKeyPage() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!apiKeyContainer) return;
|
||||
|
||||
// 清空容器
|
||||
apiKeyContainer.innerHTML = "";
|
||||
|
||||
// 计算当前页的数据范围
|
||||
const startIndex = (currentApiKeyPage - 1) * API_KEYS_PER_PAGE;
|
||||
const endIndex = Math.min(startIndex + API_KEYS_PER_PAGE, filteredApiKeys.length);
|
||||
const pageKeys = filteredApiKeys.slice(startIndex, endIndex);
|
||||
|
||||
// 渲染当前页的密钥
|
||||
pageKeys.forEach((key) => {
|
||||
addArrayItemWithValue("API_KEYS", key);
|
||||
});
|
||||
|
||||
// 如果没有密钥,显示提示信息
|
||||
if (pageKeys.length === 0) {
|
||||
const emptyMessage = document.createElement("div");
|
||||
emptyMessage.className = "text-gray-500 text-sm italic text-center py-4";
|
||||
emptyMessage.textContent = filteredApiKeys.length === 0 ?
|
||||
(allApiKeys.length === 0 ? "暂无API密钥" : "未找到匹配的密钥") :
|
||||
"当前页无数据";
|
||||
apiKeyContainer.appendChild(emptyMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新分页控件
|
||||
*/
|
||||
function updateApiKeyPagination() {
|
||||
totalApiKeyPages = Math.max(1, Math.ceil(filteredApiKeys.length / API_KEYS_PER_PAGE));
|
||||
|
||||
// 确保当前页在有效范围内
|
||||
if (currentApiKeyPage > totalApiKeyPages) {
|
||||
currentApiKeyPage = totalApiKeyPages;
|
||||
}
|
||||
|
||||
const paginationContainer = document.getElementById("apiKeyPagination");
|
||||
if (!paginationContainer) return;
|
||||
|
||||
// 如果只有一页或没有数据,隐藏分页控件
|
||||
if (totalApiKeyPages <= 1) {
|
||||
paginationContainer.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
paginationContainer.style.display = "flex";
|
||||
|
||||
// 更新页码信息
|
||||
const pageInfo = document.getElementById("apiKeyPageInfo");
|
||||
if (pageInfo) {
|
||||
pageInfo.textContent = `第 ${currentApiKeyPage} 页,共 ${totalApiKeyPages} 页 (${filteredApiKeys.length} 个密钥)`;
|
||||
}
|
||||
|
||||
// 更新按钮状态
|
||||
const prevBtn = document.getElementById("apiKeyPrevBtn");
|
||||
const nextBtn = document.getElementById("apiKeyNextBtn");
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.disabled = currentApiKeyPage <= 1;
|
||||
prevBtn.className = currentApiKeyPage <= 1 ?
|
||||
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
|
||||
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.disabled = currentApiKeyPage >= totalApiKeyPages;
|
||||
nextBtn.className = currentApiKeyPage >= totalApiKeyPages ?
|
||||
"px-3 py-1 rounded bg-gray-300 text-gray-500 cursor-not-allowed" :
|
||||
"px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到指定页
|
||||
*/
|
||||
function goToApiKeyPage(page) {
|
||||
if (page < 1 || page > totalApiKeyPages) return;
|
||||
|
||||
currentApiKeyPage = page;
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
}
|
||||
|
||||
/**
|
||||
* 上一页
|
||||
*/
|
||||
function prevApiKeyPage() {
|
||||
if (currentApiKeyPage > 1) {
|
||||
goToApiKeyPage(currentApiKeyPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一页
|
||||
*/
|
||||
function nextApiKeyPage() {
|
||||
if (currentApiKeyPage < totalApiKeyPages) {
|
||||
goToApiKeyPage(currentApiKeyPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the bulk deletion of API keys based on input from the modal.
|
||||
*/
|
||||
function handleBulkDeleteApiKeys() {
|
||||
const apiKeyContainer = document.getElementById("API_KEYS_container");
|
||||
if (!bulkDeleteApiKeyInput || !apiKeyContainer || !bulkDeleteApiKeyModal)
|
||||
return;
|
||||
if (!bulkDeleteApiKeyInput || !bulkDeleteApiKeyModal) return;
|
||||
|
||||
const bulkText = bulkDeleteApiKeyInput.value;
|
||||
if (!bulkText.trim()) {
|
||||
@@ -1123,24 +1272,30 @@ function handleBulkDeleteApiKeys() {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyItems = apiKeyContainer.querySelectorAll(`.${ARRAY_ITEM_CLASS}`);
|
||||
// 从allApiKeys数组中删除匹配的密钥
|
||||
let deleteCount = 0;
|
||||
|
||||
keyItems.forEach((item) => {
|
||||
const input = item.querySelector(
|
||||
`.${ARRAY_INPUT_CLASS}.${SENSITIVE_INPUT_CLASS}`
|
||||
);
|
||||
const realValue =
|
||||
input &&
|
||||
(input.hasAttribute("data-real-value")
|
||||
? input.getAttribute("data-real-value")
|
||||
: input.value);
|
||||
if (realValue && keysToDelete.has(realValue)) {
|
||||
item.remove();
|
||||
allApiKeys = allApiKeys.filter(key => {
|
||||
if (keysToDelete.has(key)) {
|
||||
deleteCount++;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// 更新过滤后的数组
|
||||
const searchTerm = apiKeySearchInput ? apiKeySearchInput.value.toLowerCase() : "";
|
||||
if (!searchTerm) {
|
||||
filteredApiKeys = [...allApiKeys];
|
||||
} else {
|
||||
filteredApiKeys = allApiKeys.filter(key =>
|
||||
key.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
}
|
||||
|
||||
// 重新渲染当前页
|
||||
renderApiKeyPage();
|
||||
updateApiKeyPagination();
|
||||
|
||||
closeModal(bulkDeleteApiKeyModal);
|
||||
|
||||
if (deleteCount > 0) {
|
||||
@@ -1455,6 +1610,45 @@ function createRemoveButton() {
|
||||
return removeBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a proxy status icon for displaying proxy check status.
|
||||
* @returns {HTMLSpanElement} The status icon element.
|
||||
*/
|
||||
function createProxyStatusIcon() {
|
||||
const statusIcon = document.createElement("span");
|
||||
statusIcon.className = "proxy-status-icon px-2 py-2 text-gray-400";
|
||||
statusIcon.innerHTML = '<i class="fas fa-question-circle" title="未检测"></i>';
|
||||
statusIcon.setAttribute("data-status", "unknown");
|
||||
return statusIcon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a proxy check button for individual proxy checking.
|
||||
* @returns {HTMLButtonElement} The check button element.
|
||||
*/
|
||||
function createProxyCheckButton() {
|
||||
const checkBtn = document.createElement("button");
|
||||
checkBtn.type = "button";
|
||||
checkBtn.className =
|
||||
"proxy-check-btn px-2 py-2 text-blue-500 hover:text-blue-700 focus:outline-none transition-colors duration-150 rounded-r-md";
|
||||
checkBtn.innerHTML = '<i class="fas fa-globe"></i>';
|
||||
checkBtn.title = "检测此代理";
|
||||
|
||||
// 添加点击事件监听器
|
||||
checkBtn.addEventListener("click", function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const inputElement = this.closest('.flex').querySelector('.array-input');
|
||||
if (inputElement && inputElement.value.trim()) {
|
||||
checkSingleProxy(inputElement.value.trim(), this);
|
||||
} else {
|
||||
showNotification("请先输入代理地址", "warning");
|
||||
}
|
||||
});
|
||||
|
||||
return checkBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new item to an array configuration section (e.g., API_KEYS, ALLOWED_TOKENS).
|
||||
* This function is typically called by a "+" button.
|
||||
@@ -1486,6 +1680,7 @@ function addArrayItemWithValue(key, value) {
|
||||
const isThinkingModel = key === "THINKING_MODELS";
|
||||
const isAllowedToken = key === "ALLOWED_TOKENS";
|
||||
const isVertexApiKey = key === "VERTEX_API_KEYS"; // 新增判断
|
||||
const isProxy = key === "PROXIES"; // 新增代理判断
|
||||
const isSensitive = key === "API_KEYS" || isAllowedToken || isVertexApiKey; // 更新敏感判断
|
||||
const modelId = isThinkingModel ? generateUUID() : null;
|
||||
|
||||
@@ -1513,6 +1708,13 @@ function addArrayItemWithValue(key, value) {
|
||||
if (isAllowedToken) {
|
||||
const generateBtn = createGenerateTokenButton();
|
||||
inputWrapper.appendChild(generateBtn);
|
||||
} else if (isProxy) {
|
||||
// 为代理添加状态显示和检测按钮
|
||||
const proxyStatusIcon = createProxyStatusIcon();
|
||||
inputWrapper.appendChild(proxyStatusIcon);
|
||||
|
||||
const proxyCheckBtn = createProxyCheckButton();
|
||||
inputWrapper.appendChild(proxyCheckBtn);
|
||||
} else {
|
||||
// Ensure right-side rounding if no button is present
|
||||
input.classList.add("rounded-r-md");
|
||||
@@ -1709,6 +1911,15 @@ function collectFormData() {
|
||||
const arrayContainers = document.querySelectorAll(".array-container");
|
||||
arrayContainers.forEach((container) => {
|
||||
const key = container.id.replace("_container", "");
|
||||
|
||||
// 特殊处理API_KEYS - 使用全局数组而不是DOM元素
|
||||
if (key === "API_KEYS") {
|
||||
formData[key] = allApiKeys.filter(
|
||||
(value) => value && value.trim() !== "" && value !== MASKED_VALUE
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayInputs = container.querySelectorAll(`.${ARRAY_INPUT_CLASS}`);
|
||||
formData[key] = Array.from(arrayInputs)
|
||||
.map((input) => {
|
||||
@@ -2299,3 +2510,241 @@ function handleModelSelection(selectedModelId) {
|
||||
}
|
||||
|
||||
// -- End Model Helper Functions --
|
||||
|
||||
// -- Proxy Check Functions --
|
||||
|
||||
/**
|
||||
* 检测单个代理是否可用
|
||||
* @param {string} proxy - 代理地址
|
||||
* @param {HTMLElement} buttonElement - 触发检测的按钮元素
|
||||
*/
|
||||
async function checkSingleProxy(proxy, buttonElement) {
|
||||
const statusIcon = buttonElement.parentElement.querySelector('.proxy-status-icon');
|
||||
const originalButtonContent = buttonElement.innerHTML;
|
||||
|
||||
try {
|
||||
// 更新UI状态为检测中
|
||||
buttonElement.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
buttonElement.disabled = true;
|
||||
if (statusIcon) {
|
||||
statusIcon.className = "proxy-status-icon px-2 py-2 text-blue-500";
|
||||
statusIcon.innerHTML = '<i class="fas fa-spinner fa-spin" title="检测中..."></i>';
|
||||
statusIcon.setAttribute("data-status", "checking");
|
||||
}
|
||||
|
||||
const response = await fetch('/api/config/proxy/check', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
proxy: proxy,
|
||||
use_cache: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`检测请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
updateProxyStatus(statusIcon, result);
|
||||
|
||||
// 显示检测结果通知
|
||||
if (result.is_available) {
|
||||
showNotification(`代理可用 (${result.response_time}s)`, "success");
|
||||
} else {
|
||||
showNotification(`代理不可用: ${result.error_message}`, "error");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('代理检测失败:', error);
|
||||
if (statusIcon) {
|
||||
statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500";
|
||||
statusIcon.innerHTML = '<i class="fas fa-times-circle" title="检测失败"></i>';
|
||||
statusIcon.setAttribute("data-status", "error");
|
||||
}
|
||||
showNotification(`检测失败: ${error.message}`, "error");
|
||||
} finally {
|
||||
// 恢复按钮状态
|
||||
buttonElement.innerHTML = originalButtonContent;
|
||||
buttonElement.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新代理状态图标
|
||||
* @param {HTMLElement} statusIcon - 状态图标元素
|
||||
* @param {Object} result - 检测结果
|
||||
*/
|
||||
function updateProxyStatus(statusIcon, result) {
|
||||
if (!statusIcon) return;
|
||||
|
||||
if (result.is_available) {
|
||||
statusIcon.className = "proxy-status-icon px-2 py-2 text-green-500";
|
||||
statusIcon.innerHTML = `<i class="fas fa-check-circle" title="可用 (${result.response_time}s)"></i>`;
|
||||
statusIcon.setAttribute("data-status", "available");
|
||||
} else {
|
||||
statusIcon.className = "proxy-status-icon px-2 py-2 text-red-500";
|
||||
statusIcon.innerHTML = `<i class="fas fa-times-circle" title="不可用: ${result.error_message}"></i>`;
|
||||
statusIcon.setAttribute("data-status", "unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测所有代理
|
||||
*/
|
||||
async function checkAllProxies() {
|
||||
const proxyContainer = document.getElementById("PROXIES_container");
|
||||
if (!proxyContainer) return;
|
||||
|
||||
const proxyInputs = proxyContainer.querySelectorAll('.array-input');
|
||||
const proxies = Array.from(proxyInputs)
|
||||
.map(input => input.value.trim())
|
||||
.filter(proxy => proxy.length > 0);
|
||||
|
||||
if (proxies.length === 0) {
|
||||
showNotification("没有代理需要检测", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开检测结果模态框
|
||||
const proxyCheckModal = document.getElementById("proxyCheckModal");
|
||||
if (proxyCheckModal) {
|
||||
openModal(proxyCheckModal);
|
||||
|
||||
// 显示进度
|
||||
const progressContainer = document.getElementById("proxyCheckProgress");
|
||||
const summaryContainer = document.getElementById("proxyCheckSummary");
|
||||
const resultsContainer = document.getElementById("proxyCheckResults");
|
||||
|
||||
if (progressContainer) progressContainer.classList.remove("hidden");
|
||||
if (summaryContainer) summaryContainer.classList.add("hidden");
|
||||
if (resultsContainer) resultsContainer.innerHTML = "";
|
||||
|
||||
// 更新总数
|
||||
const totalCountElement = document.getElementById("totalCount");
|
||||
if (totalCountElement) totalCountElement.textContent = proxies.length;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/proxy/check-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
proxies: proxies,
|
||||
use_cache: true,
|
||||
max_concurrent: 5
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`批量检测请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const results = await response.json();
|
||||
displayProxyCheckResults(results);
|
||||
updateProxyStatusInList(results);
|
||||
|
||||
} catch (error) {
|
||||
console.error('批量代理检测失败:', error);
|
||||
showNotification(`批量检测失败: ${error.message}`, "error");
|
||||
if (resultsContainer) {
|
||||
resultsContainer.innerHTML = `<div class="text-red-500 text-center py-4">检测失败: ${error.message}</div>`;
|
||||
}
|
||||
} finally {
|
||||
// 隐藏进度
|
||||
if (progressContainer) progressContainer.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示代理检测结果
|
||||
* @param {Array} results - 检测结果数组
|
||||
*/
|
||||
function displayProxyCheckResults(results) {
|
||||
const summaryContainer = document.getElementById("proxyCheckSummary");
|
||||
const resultsContainer = document.getElementById("proxyCheckResults");
|
||||
const availableCountElement = document.getElementById("availableCount");
|
||||
const unavailableCountElement = document.getElementById("unavailableCount");
|
||||
const retryButton = document.getElementById("retryFailedProxiesBtn");
|
||||
|
||||
if (!resultsContainer) return;
|
||||
|
||||
// 统计结果
|
||||
const availableCount = results.filter(r => r.is_available).length;
|
||||
const unavailableCount = results.length - availableCount;
|
||||
|
||||
// 更新概览
|
||||
if (availableCountElement) availableCountElement.textContent = availableCount;
|
||||
if (unavailableCountElement) unavailableCountElement.textContent = unavailableCount;
|
||||
if (summaryContainer) summaryContainer.classList.remove("hidden");
|
||||
|
||||
// 显示重试按钮(如果有失败的代理)
|
||||
if (retryButton) {
|
||||
if (unavailableCount > 0) {
|
||||
retryButton.classList.remove("hidden");
|
||||
} else {
|
||||
retryButton.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// 清空并填充结果
|
||||
resultsContainer.innerHTML = "";
|
||||
|
||||
results.forEach(result => {
|
||||
const resultItem = document.createElement("div");
|
||||
resultItem.className = `flex items-center justify-between p-3 border rounded-lg ${
|
||||
result.is_available ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'
|
||||
}`;
|
||||
|
||||
const statusIcon = result.is_available ?
|
||||
'<i class="fas fa-check-circle text-green-500"></i>' :
|
||||
'<i class="fas fa-times-circle text-red-500"></i>';
|
||||
|
||||
const responseTimeText = result.response_time ?
|
||||
` (${result.response_time}s)` : '';
|
||||
|
||||
const errorText = result.error_message ?
|
||||
`<span class="text-red-600 text-sm ml-2">${result.error_message}</span>` : '';
|
||||
|
||||
resultItem.innerHTML = `
|
||||
<div class="flex items-center gap-3">
|
||||
${statusIcon}
|
||||
<span class="font-mono text-sm">${result.proxy}</span>
|
||||
${responseTimeText}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm ${result.is_available ? 'text-green-700' : 'text-red-700'}">
|
||||
${result.is_available ? '可用' : '不可用'}
|
||||
</span>
|
||||
${errorText}
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultsContainer.appendChild(resultItem);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据检测结果更新代理列表中的状态图标
|
||||
* @param {Array} results - 检测结果数组
|
||||
*/
|
||||
function updateProxyStatusInList(results) {
|
||||
const proxyContainer = document.getElementById("PROXIES_container");
|
||||
if (!proxyContainer) return;
|
||||
|
||||
results.forEach(result => {
|
||||
const proxyInputs = proxyContainer.querySelectorAll('.array-input');
|
||||
proxyInputs.forEach(input => {
|
||||
if (input.value.trim() === result.proxy) {
|
||||
const statusIcon = input.parentElement.querySelector('.proxy-status-icon');
|
||||
updateProxyStatus(statusIcon, result);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -- End Proxy Check Functions --
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
83
app/static/js/tailwindcss.js
Normal file
83
app/static/js/tailwindcss.js
Normal file
File diff suppressed because one or more lines are too long
@@ -11,14 +11,14 @@
|
||||
<meta name="apple-mobile-web-app-title" content="GBalance" />
|
||||
<link rel="icon" href="/static/icons/icon-192x192.png" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
href="/static/css/fonts.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
|
||||
/>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="/static/js/tailwindcss.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
|
||||
@@ -961,6 +961,31 @@ endblock %} {% block head_extra_styles %}
|
||||
<div class="array-container" id="API_KEYS_container">
|
||||
<!-- 数组项将在这里动态添加 -->
|
||||
</div>
|
||||
<!-- API密钥分页控件 -->
|
||||
<div id="apiKeyPagination" class="flex items-center justify-between mt-2 mb-2" style="display: none;">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
id="apiKeyPrevBtn"
|
||||
onclick="prevApiKeyPage()"
|
||||
class="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
|
||||
>
|
||||
<i class="fas fa-chevron-left"></i> 上一页
|
||||
</button>
|
||||
<span id="apiKeyPageInfo" class="text-sm text-gray-600">第 1 页,共 1 页</span>
|
||||
<button
|
||||
type="button"
|
||||
id="apiKeyNextBtn"
|
||||
onclick="nextApiKeyPage()"
|
||||
class="px-3 py-1 rounded bg-blue-500 text-white hover:bg-blue-600 cursor-pointer"
|
||||
>
|
||||
下一页 <i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
每页显示 20 个密钥
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -1232,6 +1257,13 @@ endblock %} {% block head_extra_styles %}
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i> 删除代理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
|
||||
id="checkAllProxiesBtn"
|
||||
>
|
||||
<i class="fas fa-globe"></i> 检测所有代理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
|
||||
@@ -2614,6 +2646,84 @@ endblock %} {% block head_extra_styles %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxy Check Results Modal -->
|
||||
<div id="proxyCheckModal" class="modal">
|
||||
<div
|
||||
class="w-full max-w-4xl mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
|
||||
style="
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
color: #374151;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-800">代理检测结果</h2>
|
||||
<button
|
||||
id="closeProxyCheckModalBtn"
|
||||
class="text-gray-300 hover:text-gray-800 text-xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 检测状态和进度 -->
|
||||
<div id="proxyCheckProgress" class="mb-4 hidden">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div class="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span class="text-sm text-gray-600">检测中...</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<span id="progressText">准备开始检测...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检测结果概览 -->
|
||||
<div id="proxyCheckSummary" class="mb-4 hidden">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<div class="text-2xl font-bold text-green-600" id="availableCount">0</div>
|
||||
<div class="text-sm text-green-700">可用</div>
|
||||
</div>
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div class="text-2xl font-bold text-red-600" id="unavailableCount">0</div>
|
||||
<div class="text-sm text-red-700">不可用</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div class="text-2xl font-bold text-blue-600" id="totalCount">0</div>
|
||||
<div class="text-sm text-blue-700">总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 检测结果列表 -->
|
||||
<div id="proxyCheckResults" class="space-y-2" style="max-height: 400px; overflow-y: auto;">
|
||||
<!-- 检测结果将在这里动态添加 -->
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
id="retryFailedProxiesBtn"
|
||||
class="bg-orange-600 hover:bg-orange-700 text-white px-6 py-2 rounded-lg font-medium transition hidden"
|
||||
>
|
||||
重试失败的代理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="closeProxyCheckBtn"
|
||||
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Helper Modal -->
|
||||
<div id="modelHelperModal" class="modal">
|
||||
<div
|
||||
|
||||
@@ -875,7 +875,8 @@ endblock %} {% block head_extra_styles %}
|
||||
}
|
||||
|
||||
/* Fix specific pagination elements by ID and class */
|
||||
#validKeysPageSize, #invalidKeysPageSize {
|
||||
#validKeysPageSize, #invalidKeysPageSize,
|
||||
#itemsPerPageSelect, #invalidItemsPerPageSelect {
|
||||
background-color: rgba(255, 255, 255, 0.95) !important;
|
||||
color: #374151 !important; /* gray-700 */
|
||||
border: 1px solid rgba(0, 0, 0, 0.12) !important;
|
||||
@@ -884,7 +885,8 @@ endblock %} {% block head_extra_styles %}
|
||||
font-size: 0.875rem !important; /* text-sm */
|
||||
}
|
||||
|
||||
#validKeysPageSize:focus, #invalidKeysPageSize:focus {
|
||||
#validKeysPageSize:focus, #invalidKeysPageSize:focus,
|
||||
#itemsPerPageSelect:focus, #invalidItemsPerPageSelect:focus {
|
||||
border-color: #3b82f6 !important; /* blue-500 */
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||
outline: none !important;
|
||||
@@ -947,7 +949,10 @@ endblock %} {% block head_extra_styles %}
|
||||
label[for="selectAllInvalid"],
|
||||
label[for="failCountThreshold"],
|
||||
label[for="keySearchInput"],
|
||||
label[for="itemsPerPageSelect"] {
|
||||
label[for="itemsPerPageSelect"],
|
||||
label[for="invalidFailCountThreshold"],
|
||||
label[for="invalidKeySearchInput"],
|
||||
label[for="invalidItemsPerPageSelect"] {
|
||||
color: #1f2937 !important; /* gray-800 for maximum contrast */
|
||||
font-weight: 600 !important; /* font-semibold for better visibility */
|
||||
text-shadow: none !important;
|
||||
@@ -1026,6 +1031,69 @@ endblock %} {% block head_extra_styles %}
|
||||
color: #fca5b3 !important;
|
||||
}
|
||||
/* End of API Call Details Modal Specific Styling Adjustments */
|
||||
|
||||
/* 下拉菜单样式 */
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-10px);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #374151;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dropdown-item:first-child {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item:last-child {
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item i {
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-toggle {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block head_extra_scripts %}
|
||||
<!-- keys_status.js needs to be loaded in head because it might be used by inline scripts -->
|
||||
@@ -1061,6 +1129,28 @@ endblock %} {% block head_extra_styles %}
|
||||
>
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
<!-- 下拉菜单按钮 -->
|
||||
<div class="dropdown-toggle relative">
|
||||
<button
|
||||
id="dropdownMenuButton"
|
||||
class="bg-white bg-opacity-20 hover:bg-opacity-30 rounded-full w-8 h-8 flex items-center justify-center text-primary-600 transition-all duration-300"
|
||||
onclick="toggleDropdownMenu()"
|
||||
title="更多操作"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<!-- 下拉菜单 -->
|
||||
<div id="dropdownMenu" class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick="copyAllKeys()">
|
||||
<i class="fas fa-copy"></i>
|
||||
<span>复制全部密钥</span>
|
||||
</button>
|
||||
<button class="dropdown-item" onclick="verifyAllKeys()">
|
||||
<i class="fas fa-check-double"></i>
|
||||
<span>验证所有密钥</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
@@ -1319,95 +1409,10 @@ endblock %} {% block head_extra_styles %}
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="validKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side for non-JS users or initial
|
||||
load #} {# JS will replace this content with paginated/filtered
|
||||
results #} {% if valid_keys %} {% for key, fail_count in
|
||||
valid_keys.items() %}
|
||||
<li
|
||||
class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-success-300 transform hover:-translate-y-1"
|
||||
data-fail-count="{{ fail_count }}"
|
||||
data-key="{{ key }}"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox"
|
||||
data-key-type="valid"
|
||||
value="{{ key }}"
|
||||
/>
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-50 text-success-600"
|
||||
>
|
||||
<i class="fas fa-check mr-1"></i> 有效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono" data-full-key="{{ key }}"
|
||||
>{{ key[:4] + '...' + key[-4:] }}</span
|
||||
>
|
||||
<button
|
||||
class="text-gray-500 hover:text-primary-600 transition-colors"
|
||||
onclick="toggleKeyVisibility(this)"
|
||||
title="显示/隐藏密钥"
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1 bg-success-600 hover:bg-success-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="verifyKey('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="resetKeyFailCount('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="copyKey('{{ key }}')"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="showKeyUsageDetails('{{ key }}')"
|
||||
>
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="showSingleKeyDeleteConfirmModal('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %} {% else %}
|
||||
{# This content is now loaded via JavaScript #}
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">
|
||||
暂无有效密钥
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading keys...
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 有效密钥分页控件容器 -->
|
||||
<div
|
||||
@@ -1434,12 +1439,72 @@ endblock %} {% block head_extra_styles %}
|
||||
无效密钥列表 ({{ invalid_key_count }})
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Middle: Filters and Search (Allow wrapping) -->
|
||||
<div
|
||||
class="flex items-center gap-x-4 gap-y-2 flex-grow flex-wrap justify-start md:justify-center"
|
||||
>
|
||||
<!-- Allow wrapping, center on medium+ -->
|
||||
<!-- 失败次数筛选 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label
|
||||
for="invalidFailCountThreshold"
|
||||
class="text-sm select-none whitespace-nowrap font-semibold"
|
||||
style="color: #1f2937 !important;"
|
||||
>失败次数≥</label
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
id="invalidFailCountThreshold"
|
||||
value="0"
|
||||
min="0"
|
||||
class="form-input h-7 w-16 px-2 py-1 text-sm border rounded focus:ring-primary-500 focus:border-primary-500"
|
||||
onclick="event.stopPropagation();"
|
||||
/>
|
||||
</div>
|
||||
<!-- 密钥搜索 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label
|
||||
for="invalidKeySearchInput"
|
||||
class="text-sm select-none whitespace-nowrap font-semibold"
|
||||
style="color: #1f2937 !important;"
|
||||
><i class="fas fa-search mr-1"></i>搜索</label
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
id="invalidKeySearchInput"
|
||||
placeholder="输入密钥..."
|
||||
class="form-input h-7 w-32 px-2 py-1 text-sm border rounded focus:ring-primary-500 focus:border-primary-500"
|
||||
onclick="event.stopPropagation();"
|
||||
/>
|
||||
</div>
|
||||
<!-- 每页显示数量 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<label
|
||||
for="invalidItemsPerPageSelect"
|
||||
class="text-sm select-none whitespace-nowrap font-semibold"
|
||||
style="color: #1f2937 !important;"
|
||||
>每页</label
|
||||
>
|
||||
<select
|
||||
id="invalidItemsPerPageSelect"
|
||||
class="form-select h-7 px-2 py-1 text-sm border rounded focus:ring-primary-500 focus:border-primary-500"
|
||||
onclick="event.stopPropagation();"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
<span class="text-sm select-none font-semibold" style="color: #1f2937 !important;">项</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right side: Select All -->
|
||||
<div
|
||||
class="flex items-center gap-1 ml-auto flex-shrink-0"
|
||||
class="flex items-center gap-1 flex-shrink-0"
|
||||
onclick="event.stopPropagation();"
|
||||
>
|
||||
<!-- Use ml-auto, Prevent shrinking -->
|
||||
<!-- Prevent shrinking -->
|
||||
<input
|
||||
type="checkbox"
|
||||
id="selectAllInvalid"
|
||||
@@ -1503,93 +1568,10 @@ endblock %} {% block head_extra_styles %}
|
||||
<div class="key-content p-4 bg-white bg-opacity-40">
|
||||
<!-- Key list will be populated by JS -->
|
||||
<ul id="invalidKeys" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{# Initial keys rendered by server-side #} {# JS will replace this
|
||||
content with paginated results #} {% if invalid_keys %} {% for key,
|
||||
fail_count in invalid_keys.items() %}
|
||||
<li
|
||||
class="bg-white rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 hover:border-danger-300 transform hover:-translate-y-1"
|
||||
data-key="{{ key }}"
|
||||
>
|
||||
<!-- Checkbox -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="form-checkbox h-5 w-5 text-primary-600 border-gray-300 rounded focus:ring-primary-500 mt-1 key-checkbox"
|
||||
data-key-type="invalid"
|
||||
value="{{ key }}"
|
||||
/>
|
||||
<!-- Key Info -->
|
||||
<div class="flex-grow">
|
||||
<div class="flex flex-col justify-between h-full gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-50 text-danger-600"
|
||||
>
|
||||
<i class="fas fa-times mr-1"></i> 无效
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="key-text font-mono" data-full-key="{{ key }}"
|
||||
>{{ key[:4] + '...' + key[-4:] }}</span
|
||||
>
|
||||
<button
|
||||
class="text-gray-500 hover:text-primary-600 transition-colors"
|
||||
onclick="toggleKeyVisibility(this)"
|
||||
title="显示/隐藏密钥"
|
||||
>
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-50 text-amber-600"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
失败: {{ fail_count }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1 bg-success-600 hover:bg-success-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="verifyKey('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
验证
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-gray-500 hover:bg-gray-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="resetKeyFailCount('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-redo-alt"></i>
|
||||
重置
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-blue-500 hover:bg-blue-600 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="copyKey('{{ key }}')"
|
||||
>
|
||||
<i class="fas fa-copy"></i>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-blue-600 hover:bg-blue-700 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="showKeyUsageDetails('{{ key }}')"
|
||||
>
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-red-800 hover:bg-red-900 text-white px-2.5 py-1 rounded-lg text-xs font-medium transition-all duration-200"
|
||||
onclick="showSingleKeyDeleteConfirmModal('{{ key }}', this)"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %} {% else %}
|
||||
{# This content is now loaded via JavaScript #}
|
||||
<li class="text-center text-gray-500 py-4 col-span-full">
|
||||
暂无无效密钥
|
||||
<i class="fas fa-spinner fa-spin"></i> Loading keys...
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<!-- 无效密钥分页控件容器 -->
|
||||
<div
|
||||
@@ -1688,7 +1670,11 @@ endblock %} {% block head_extra_styles %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<p style="color: #374151" id="verifyModalMessage"></p>
|
||||
<p style="color: #374151" id="verifyModalMessage" class="mb-4"></p>
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="batchSize" class="text-sm font-medium" style="color: #374151;">每批次验证数量:</label>
|
||||
<input type="number" id="batchSize" value="10" min="1" class="form-input h-8 w-20 px-2 py-1 text-sm border rounded focus:ring-primary-500 focus:border-primary-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@@ -1812,6 +1798,78 @@ endblock %} {% block head_extra_styles %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作进度模态框 -->
|
||||
<div
|
||||
id="progressModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg p-6 shadow-xl max-w-2xl w-full animate-fade-in"
|
||||
style="
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
color: #374151;
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3
|
||||
class="text-lg font-semibold text-gray-800"
|
||||
id="progressModalTitle"
|
||||
style="color: #1f2937; font-weight: 600"
|
||||
>
|
||||
批量操作进度
|
||||
</h3>
|
||||
<button
|
||||
onclick="closeProgressModal()"
|
||||
id="closeProgressModalBtn"
|
||||
class="text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
disabled
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p id="progressStatusText" class="text-sm text-gray-600 mb-2">
|
||||
准备开始...
|
||||
</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-4 dark:bg-gray-700">
|
||||
<div
|
||||
id="progressBar"
|
||||
class="bg-primary-600 h-4 rounded-full transition-all duration-300"
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
<p
|
||||
id="progressPercentage"
|
||||
class="text-center text-sm font-semibold mt-1"
|
||||
style="color: #1f2937"
|
||||
>
|
||||
0%
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="progressLog"
|
||||
class="text-xs max-h-60 overflow-y-auto bg-gray-50 p-3 rounded border border-gray-200 space-y-1 font-mono"
|
||||
style="
|
||||
background-color: rgba(249, 250, 251, 0.95);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
"
|
||||
>
|
||||
<!-- Log entries will be added here -->
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
id="progressModalCloseBtn"
|
||||
onclick="closeProgressModal(true)"
|
||||
class="px-4 py-1.5 text-sm font-medium bg-primary-700 hover:bg-primary-800 text-white rounded-lg transition-colors"
|
||||
disabled
|
||||
>
|
||||
完成并刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作结果模态框 -->
|
||||
<div
|
||||
id="resultModal"
|
||||
|
||||
@@ -154,6 +154,25 @@ def is_valid_api_key(key: str) -> bool:
|
||||
|
||||
|
||||
|
||||
def redact_key_for_logging(key: str) -> str:
|
||||
"""
|
||||
Redacts API key for secure logging by showing only first and last 6 characters.
|
||||
|
||||
Args:
|
||||
key: API key to redact
|
||||
|
||||
Returns:
|
||||
str: Redacted key in format "first6...last6" or descriptive placeholder for edge cases
|
||||
"""
|
||||
if not key:
|
||||
return key
|
||||
|
||||
if len(key) <= 12:
|
||||
return f"{key[:3]}...{key[-3:]}"
|
||||
else:
|
||||
return f"{key[:6]}...{key[-6:]}"
|
||||
|
||||
|
||||
def get_current_version(default_version: str = "0.0.0") -> str:
|
||||
"""Reads the current version from the VERSION file."""
|
||||
version_file = VERSION_FILE_PATH
|
||||
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
187
tests/test_key_redaction.py
Normal file
187
tests/test_key_redaction.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Unit tests for API key redaction functionality
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import logging
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.utils.helpers import redact_key_for_logging
|
||||
from app.log.logger import AccessLogFormatter
|
||||
|
||||
|
||||
class TestKeyRedaction(unittest.TestCase):
|
||||
"""Test cases for the redact_key_for_logging function"""
|
||||
|
||||
def test_valid_long_key_redaction(self):
|
||||
"""Test redaction of valid long API keys"""
|
||||
# Test Google/Gemini API key
|
||||
# This value is a random generated string for testing
|
||||
gemini_key = "AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI"
|
||||
result = redact_key_for_logging(gemini_key)
|
||||
expected = "AIzaSy...xDfGhI"
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test OpenAI API key
|
||||
# This value is a random generated string for testing
|
||||
openai_key = "sk-1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
result = redact_key_for_logging(openai_key)
|
||||
expected = "sk-123...abcdef"
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
def test_short_key_handling(self):
|
||||
"""Test handling of short keys"""
|
||||
short_key = "short"
|
||||
result = redact_key_for_logging(short_key)
|
||||
self.assertEqual(result, "[SHORT_KEY]")
|
||||
|
||||
# Test exactly 12 characters (boundary case)
|
||||
boundary_key = "123456789012"
|
||||
result = redact_key_for_logging(boundary_key)
|
||||
self.assertEqual(result, "[SHORT_KEY]")
|
||||
|
||||
def test_empty_and_none_keys(self):
|
||||
"""Test handling of empty and None keys"""
|
||||
# Test empty string
|
||||
result = redact_key_for_logging("")
|
||||
self.assertEqual(result, "[INVALID_KEY]")
|
||||
|
||||
# Test None
|
||||
result = redact_key_for_logging(None)
|
||||
self.assertEqual(result, "[INVALID_KEY]")
|
||||
|
||||
def test_invalid_input_types(self):
|
||||
"""Test handling of invalid input types"""
|
||||
# Test integer
|
||||
result = redact_key_for_logging(123)
|
||||
self.assertEqual(result, "[INVALID_KEY]")
|
||||
|
||||
# Test list
|
||||
result = redact_key_for_logging(["key"])
|
||||
self.assertEqual(result, "[INVALID_KEY]")
|
||||
|
||||
# Test dict
|
||||
result = redact_key_for_logging({"key": "value"})
|
||||
self.assertEqual(result, "[INVALID_KEY]")
|
||||
|
||||
def test_boundary_cases(self):
|
||||
"""Test boundary cases for key length"""
|
||||
# Test 13 characters (just above the threshold)
|
||||
key_13 = "1234567890123"
|
||||
result = redact_key_for_logging(key_13)
|
||||
expected = "123456...890123"
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
# Test very long key
|
||||
long_key = "a" * 100
|
||||
result = redact_key_for_logging(long_key)
|
||||
expected = "aaaaaa...aaaaaa"
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
|
||||
class TestAccessLogFormatter(unittest.TestCase):
|
||||
"""Test cases for the AccessLogFormatter class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.formatter = AccessLogFormatter()
|
||||
|
||||
def test_gemini_key_redaction_in_url(self):
|
||||
"""Test redaction of Gemini API keys in URLs"""
|
||||
log_message = (
|
||||
'POST /verify-key/AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI HTTP/1.1" 200'
|
||||
)
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertIn("AIzaSy...xDfGhI", result)
|
||||
self.assertNotIn("AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI", result)
|
||||
|
||||
def test_openai_key_redaction_in_url(self):
|
||||
"""Test redaction of OpenAI API keys in URLs"""
|
||||
log_message = 'GET /api/models?key=sk-1234567890abcdef1234567890abcdef1234567890abcdef HTTP/1.1" 200'
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertIn("sk-123...abcdef", result)
|
||||
self.assertNotIn("sk-1234567890abcdef1234567890abcdef1234567890abcdef", result)
|
||||
|
||||
def test_multiple_keys_in_message(self):
|
||||
"""Test redaction of multiple API keys in a single message"""
|
||||
log_message = "Request with keys: AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI and sk-1234567890abcdef1234567890abcdef1234567890abcdef"
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertIn("AIzaSy...xDfGhI", result)
|
||||
self.assertIn("sk-123...abcdef", result)
|
||||
self.assertNotIn("AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI", result)
|
||||
self.assertNotIn("sk-1234567890abcdef1234567890abcdef1234567890abcdef", result)
|
||||
|
||||
def test_no_keys_in_message(self):
|
||||
"""Test that messages without API keys are unchanged"""
|
||||
log_message = 'GET /api/health HTTP/1.1" 200'
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertEqual(result, log_message)
|
||||
|
||||
def test_partial_key_patterns_not_redacted(self):
|
||||
"""Test that partial key patterns are not redacted"""
|
||||
log_message = "Message with partial patterns: AIza sk- incomplete"
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertEqual(result, log_message)
|
||||
|
||||
def test_error_handling_in_redaction(self):
|
||||
"""Test error handling in the redaction process"""
|
||||
# Test by directly calling _redact_api_keys_in_message with a broken pattern
|
||||
original_patterns = self.formatter.compiled_patterns
|
||||
# Create a mock pattern that will raise an exception
|
||||
mock_pattern = MagicMock()
|
||||
mock_pattern.sub.side_effect = Exception("Regex error")
|
||||
self.formatter.compiled_patterns = [mock_pattern]
|
||||
|
||||
try:
|
||||
log_message = (
|
||||
'POST /verify-key/AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI HTTP/1.1" 200'
|
||||
)
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertEqual(result, "[LOG_REDACTION_ERROR]")
|
||||
finally:
|
||||
# Restore original patterns
|
||||
self.formatter.compiled_patterns = original_patterns
|
||||
|
||||
def test_format_method(self):
|
||||
"""Test the format method of AccessLogFormatter"""
|
||||
# Create a mock log record
|
||||
record = MagicMock()
|
||||
record.getMessage.return_value = (
|
||||
'POST /verify-key/AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI HTTP/1.1" 200'
|
||||
)
|
||||
|
||||
# Mock the parent format method
|
||||
with patch(
|
||||
"logging.Formatter.format",
|
||||
return_value='2025-01-01 12:00:00 | INFO | POST /verify-key/AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI HTTP/1.1" 200',
|
||||
):
|
||||
result = self.formatter.format(record)
|
||||
self.assertIn("AIzaSy...xDfGhI", result)
|
||||
self.assertNotIn("AIzaSyDhKGfJ8xYzQwErTyUiOpLkMnBvCxDfGhI", result)
|
||||
|
||||
def test_regex_patterns_compilation(self):
|
||||
"""Test that regex patterns are properly compiled"""
|
||||
formatter = AccessLogFormatter()
|
||||
self.assertEqual(len(formatter.compiled_patterns), 2)
|
||||
self.assertTrue(
|
||||
all(hasattr(pattern, "sub") for pattern in formatter.compiled_patterns)
|
||||
)
|
||||
|
||||
def test_flexible_openai_pattern(self):
|
||||
"""Test the flexible OpenAI pattern matches various formats"""
|
||||
test_cases = [
|
||||
"sk-1234567890abcdef1234567890abcdef1234567890abcdef", # Standard 48 chars
|
||||
"sk-proj-1234567890abcdef1234567890abcdef1234567890abcdef", # Project key
|
||||
"sk-1234567890abcdef_1234567890abcdef-1234567890abcdef", # With underscores/hyphens
|
||||
"sk-12345678901234567890", # Shorter key (20 chars)
|
||||
]
|
||||
|
||||
for test_key in test_cases:
|
||||
log_message = f"Request with key: {test_key}"
|
||||
result = self.formatter._redact_api_keys_in_message(log_message)
|
||||
self.assertNotIn(test_key, result)
|
||||
self.assertIn("sk-", result) # Should still contain the prefix
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user