mirror of
https://github.com/snailyp/gemini-balance.git
synced 2026-07-04 22:31:31 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a44a76c48 | ||
|
|
7b5b6c7d4c | ||
|
|
68ed4da789 | ||
|
|
cdbca7ec62 | ||
|
|
48d58ef2e8 | ||
|
|
88d483c1ef | ||
|
|
8d48db026c | ||
|
|
a592269198 | ||
|
|
18a5fe6109 | ||
|
|
348cbbdf2a | ||
|
|
64235143dd | ||
|
|
d566c28fa2 | ||
|
|
c1893d918e | ||
|
|
4a02475cc1 | ||
|
|
6e55a0985c | ||
|
|
7b433aab91 | ||
|
|
fc7280bb18 | ||
|
|
8d9c99bda2 | ||
|
|
ab701f9415 | ||
|
|
c3e0d4b64f | ||
|
|
5b7f4de63c | ||
|
|
ede27a5d70 | ||
|
|
5a4619444b | ||
|
|
b3851441f1 | ||
|
|
44f956e4e4 | ||
|
|
3aa4384b9d | ||
|
|
6db4b56186 | ||
|
|
8e77773d5a | ||
|
|
343f40476a | ||
|
|
e024d55006 | ||
|
|
17f1355099 | ||
|
|
f994c5d66d | ||
|
|
e6bf45d778 | ||
|
|
8c9b802016 | ||
|
|
d1f8a98ad0 | ||
|
|
30858937b5 | ||
|
|
cb4d26778e | ||
|
|
0aefd4c03a | ||
|
|
97b9b99235 | ||
|
|
34a98389f5 |
2
.github/workflows/docker-publish.yml
vendored
2
.github/workflows/docker-publish.yml
vendored
@@ -2,7 +2,7 @@ name: Docker Image CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ "main" ]
|
# branches: [ "main" ]
|
||||||
tags: [ 'v*.*.*' ]
|
tags: [ 'v*.*.*' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|||||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
@@ -6,9 +6,10 @@ on:
|
|||||||
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0)
|
- 'v*' # 当推送以 "v" 开头的标签时触发(如 v1.0.0, v2.1.0)
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
update-release-draft:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # 添加写入权限
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
# Step 1: 检出代码库
|
# Step 1: 检出代码库
|
||||||
@@ -24,10 +25,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
release_name: ${{ github.ref_name }}
|
release_name: ${{ github.ref_name }}
|
||||||
body: |
|
|
||||||
## Release Notes
|
|
||||||
- 自动发布版本。
|
|
||||||
- 请根据需求更新对应内容。
|
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|
||||||
@@ -45,4 +42,4 @@ jobs:
|
|||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
asset_path: ./gemini-balance.zip # 替换为你的构建文件路径
|
asset_path: ./gemini-balance.zip # 替换为你的构建文件路径
|
||||||
asset_name: gemini-balance.zip # 替换为你的文件名
|
asset_name: gemini-balance.zip # 替换为你的文件名
|
||||||
asset_content_type: application/zip
|
asset_content_type: application/zip
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ FROM python:3.10-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 复制所需文件到容器中
|
# 复制所需文件到容器中
|
||||||
COPY ./app /app/app
|
|
||||||
COPY ./requirements.txt /app
|
COPY ./requirements.txt /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY ./app /app/app
|
||||||
ENV API_KEYS='["your_api_key_1"]'
|
ENV API_KEYS='["your_api_key_1"]'
|
||||||
ENV ALLOWED_TOKENS='["your_token_1"]'
|
ENV ALLOWED_TOKENS='["your_token_1"]'
|
||||||
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
ENV BASE_URL=https://generativelanguage.googleapis.com/v1beta
|
||||||
|
|||||||
221
README.md
221
README.md
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
## 📝 项目简介
|
## 📝 项目简介
|
||||||
|
|
||||||
本项目是一个基于 FastAPI 框架开发的高性能、易于部署的 OpenAI 和 Gemini API 代理服务。它不仅兼容 OpenAI 的 API 接口,还支持 Google 的 Gemini 模型,为用户提供灵活的模型选择。该代理服务内置了多 API Key 轮询、负载均衡、自动重试、访问控制(Bearer Token 认证)、流式响应等功能,旨在简化 AI 应用的开发和部署流程。
|
本项目是一个基于 FastAPI 框架开发的高性能、易于部署的Gemini OpenAI兼容 和 Gemini API 代理服务。它不仅兼容 OpenAI 的 API 接口,还支持 Google 的 Gemini 原生接口。该代理服务内置了多 API Key 轮询、负载均衡、自动重试、访问控制(Bearer Token 认证)、流式响应等功能,旨在简化 AI 应用的开发和部署流程。
|
||||||
|
|
||||||
**核心功能与优势:**
|
**核心功能与优势:**
|
||||||
|
|
||||||
- **多模型支持**: 无缝切换 OpenAI 和 Gemini 模型。
|
- **多协议支持**: 无缝切换 OpenAI兼容 和 Gemini 协议。
|
||||||
- **智能 API Key 管理**: 自动轮询多个 API Key,实现负载均衡和故障转移。
|
- **智能 API Key 管理**: 自动轮询多个 API Key,实现负载均衡和故障转移。
|
||||||
- **安全访问控制**: 使用 Bearer Token 进行身份验证,保护 API 访问。
|
- **安全访问控制**: 使用 Bearer Token 进行身份验证,保护 API 访问。
|
||||||
- **流式响应支持**: 提供实时的流式数据传输,提升用户体验。
|
- **流式响应支持**: 提供实时的流式数据传输,提升用户体验。
|
||||||
@@ -51,29 +51,90 @@
|
|||||||
|
|
||||||
3. **配置**:
|
3. **配置**:
|
||||||
|
|
||||||
创建 `.env` 文件,并配置以下环境变量:
|
创建 `.env` 文件,并按以下分类配置环境变量:
|
||||||
|
|
||||||
```env
|
```env
|
||||||
API_KEYS=["your-gemini-api-key-1", "your-gemini-api-key-2"] # 你的 Gemini API 密钥列表
|
# 基础配置
|
||||||
ALLOWED_TOKENS=["your-access-token-1", "your-access-token-2"] # 允许访问的 Token 列表
|
BASE_URL="https://generativelanguage.googleapis.com/v1beta" # Gemini API 基础 URL,默认无需修改
|
||||||
BASE_URL="https://generativelanguage.googleapis.com/v1beta" # Gemini API 基础 URL, 保持默认即可
|
MAX_FAILURES=3 # 允许单个key失败的次数,默认3次
|
||||||
MODEL_SEARCH=["gemini-2.0-flash-exp"] # 启用搜索功能的模型列表
|
|
||||||
TOOLS_CODE_EXECUTION_ENABLED=false # 是否启用代码执行工具, 默认为 false
|
# 认证与安全配置
|
||||||
SHOW_SEARCH_LINK=true # 是否显示搜索链接
|
API_KEYS=["your-gemini-api-key-1", "your-gemini-api-key-2"] # Gemini API 密钥列表,用于负载均衡
|
||||||
SHOW_THINKING_PROCESS=true # 是否显示思考过程
|
ALLOWED_TOKENS=["your-access-token-1", "your-access-token-2"] # 允许访问的 Token 列表
|
||||||
AUTH_TOKEN="" # 备用token, 如果不设置, 默认为 ALLOWED_TOKENS 的第一个
|
AUTH_TOKEN="" # 超级管理员token,具有所有权限,默认使用 ALLOWED_TOKENS 的第一个
|
||||||
MAX_FAILURES=3 # 允许单个key失败的次数
|
|
||||||
|
# 模型功能配置
|
||||||
|
MODEL_SEARCH=["gemini-2.0-flash-exp"] # 支持搜索功能的模型列表
|
||||||
|
TOOLS_CODE_EXECUTION_ENABLED=false # 是否启用代码执行工具,默认false
|
||||||
|
SHOW_SEARCH_LINK=true # 是否在响应中显示搜索结果链接,默认true
|
||||||
|
SHOW_THINKING_PROCESS=true # 是否显示模型思考过程,默认true
|
||||||
|
|
||||||
|
# 图片生成配置
|
||||||
|
PAID_KEY="your-paid-api-key" # 付费版API Key,用于图片生成等高级功能
|
||||||
|
CREATE_IMAGE_MODEL="imagen-3.0-generate-002" # 图片生成模型,默认使用imagen-3.0
|
||||||
|
|
||||||
|
# 图片上传配置
|
||||||
|
UPLOAD_PROVIDER="smms" # 图片上传提供商,目前支持smms
|
||||||
|
SMMS_SECRET_TOKEN="your-smms-token" # SM.MS图床的API Token
|
||||||
```
|
```
|
||||||
|
|
||||||
- `API_KEYS`: 你的 Gemini API 密钥列表,支持多个 Key 轮询。
|
### 配置说明
|
||||||
- `ALLOWED_TOKENS`: 允许访问的 Token 列表,用于 API 认证。
|
|
||||||
- `BASE_URL`: Gemini API 的基础 URL,通常不需要修改。
|
#### 基础配置
|
||||||
- `MODEL_SEARCH`: 启用搜索功能的模型列表。
|
|
||||||
- `TOOLS_CODE_EXECUTION_ENABLED`: 是否启用代码执行工具, 默认为 `false`。
|
- `BASE_URL`: Gemini API 的基础 URL
|
||||||
- `SHOW_SEARCH_LINK`: 是否显示搜索结果链接(当使用搜索模型时)。
|
- 默认值: `https://generativelanguage.googleapis.com/v1beta`
|
||||||
- `SHOW_THINKING_PROCESS`: 是否显示模型的"思考"过程(对于某些模型)。
|
- 说明: 通常无需修改,除非 API 地址发生变化
|
||||||
- `AUTH_TOKEN`: 主鉴权token(权限较大,注意保管), 如果不设置, 默认为 `ALLOWED_TOKENS` 的第一个。
|
- `MAX_FAILURES`: API Key 允许的最大失败次数
|
||||||
- `MAX_FAILURES`: 允许单个 API Key 失败的次数,超过此次数后该 Key 将被标记为无效。
|
- 默认值: `3`
|
||||||
|
- 说明: 超过此次数后,Key 将被暂时标记为无效
|
||||||
|
|
||||||
|
#### 认证与安全配置
|
||||||
|
|
||||||
|
- `API_KEYS`: Gemini API 密钥列表
|
||||||
|
- 格式: JSON 数组字符串
|
||||||
|
- 用途: 支持多个 Key 轮询,实现负载均衡
|
||||||
|
- 建议: 至少配置 2 个 Key 以保证服务可用性
|
||||||
|
- `ALLOWED_TOKENS`: 访问令牌列表
|
||||||
|
- 格式: JSON 数组字符串
|
||||||
|
- 用途: 用于客户端认证
|
||||||
|
- 安全提示: 请使用足够复杂的令牌
|
||||||
|
- `AUTH_TOKEN`: 超级管理员令牌
|
||||||
|
- 可选配置,留空则使用 ALLOWED_TOKENS 的第一个
|
||||||
|
- 具有查看 API Key 状态等特权操作权限
|
||||||
|
|
||||||
|
#### 模型功能配置
|
||||||
|
|
||||||
|
- `MODEL_SEARCH`: 搜索功能支持的模型
|
||||||
|
- 默认值: `["gemini-2.0-flash-exp"]`
|
||||||
|
- 说明: 仅列表中的模型可使用搜索功能
|
||||||
|
- `TOOLS_CODE_EXECUTION_ENABLED`: 代码执行功能
|
||||||
|
- 默认值: `false`
|
||||||
|
- 安全提示: 生产环境建议禁用
|
||||||
|
- `SHOW_SEARCH_LINK`: 搜索结果链接显示
|
||||||
|
- 默认值: `true`
|
||||||
|
- 用途: 控制搜索结果中是否包含原始链接
|
||||||
|
- `SHOW_THINKING_PROCESS`: 思考过程显示
|
||||||
|
- 默认值: `true`
|
||||||
|
- 用途: 显示模型的推理过程,便于调试
|
||||||
|
|
||||||
|
#### 图片生成配置
|
||||||
|
|
||||||
|
- `PAID_KEY`: 付费版 API Key
|
||||||
|
- 用途: 用于图片生成等高级功能
|
||||||
|
- 说明: 需要单独申请的付费版 Key
|
||||||
|
- `CREATE_IMAGE_MODEL`: 图片生成模型
|
||||||
|
- 默认值: `imagen-3.0-generate-002`
|
||||||
|
- 说明: 当前支持的最新图片生成模型
|
||||||
|
|
||||||
|
#### 图片上传配置
|
||||||
|
|
||||||
|
- `UPLOAD_PROVIDER`: 图片上传服务提供商
|
||||||
|
- 默认值: `smms`
|
||||||
|
- 说明: 目前支持 SM.MS 图床
|
||||||
|
- `SMMS_SECRET_TOKEN`: SM.MS API Token
|
||||||
|
- 用途: 用于图片上传到 SM.MS 图床
|
||||||
|
- 获取方式: 需要在 SM.MS 官网注册并获取
|
||||||
|
|
||||||
### ▶️ 运行
|
### ▶️ 运行
|
||||||
|
|
||||||
@@ -109,13 +170,30 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
|
|
||||||
所有 API 请求都需要在 Header 中添加 `Authorization` 字段,值为 `Bearer <your-token>`,其中 `<your-token>` 需要替换为你在 `.env` 文件中配置的 `ALLOWED_TOKENS` 中的一个或者 `AUTH_TOKEN`。
|
所有 API 请求都需要在 Header 中添加 `Authorization` 字段,值为 `Bearer <your-token>`,其中 `<your-token>` 需要替换为你在 `.env` 文件中配置的 `ALLOWED_TOKENS` 中的一个或者 `AUTH_TOKEN`。
|
||||||
|
|
||||||
### 获取模型列表
|
### API 路由
|
||||||
|
|
||||||
|
本服务提供两种API路由:
|
||||||
|
|
||||||
|
1. **OpenAI 兼容路由** (推荐)
|
||||||
|
- 基础路径: `/v1`
|
||||||
|
- 完全兼容OpenAI API格式
|
||||||
|
- 支持所有Gemini模型
|
||||||
|
|
||||||
|
2. **Gemini 原生路由**
|
||||||
|
- 基础路径: `/gemini/v1beta` 或 `/v1beta`
|
||||||
|
- 遵循Google原生API格式
|
||||||
|
- 适用于需要直接使用Gemini API的场景
|
||||||
|
|
||||||
|
### OpenAI兼容路由
|
||||||
|
|
||||||
|
#### 获取模型列表
|
||||||
|
|
||||||
- **URL**: `/v1/models`
|
- **URL**: `/v1/models`
|
||||||
- **Method**: `GET`
|
- **Method**: `GET`
|
||||||
- **Header**: `Authorization: Bearer <your-token>`
|
- **Header**: `Authorization: Bearer <your-token>`
|
||||||
|
- **Response**: 返回支持的所有模型列表,包括最新的`gemini-2.0-flash-exp-search`等模型
|
||||||
|
|
||||||
### 聊天补全 (Chat Completions)
|
#### 聊天补全 (Chat Completions)
|
||||||
|
|
||||||
- **URL**: `/v1/chat/completions`
|
- **URL**: `/v1/chat/completions`
|
||||||
- **Method**: `POST`
|
- **Method**: `POST`
|
||||||
@@ -141,11 +219,34 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `messages`: 消息列表,格式与 OpenAI API 相同。
|
- `messages`: 消息列表,格式与 OpenAI API 相同
|
||||||
- `model`: 模型名称,例如 `gemini-1.5-flash-002`。
|
- `model`: 模型名称,支持所有Gemini模型,包括:
|
||||||
- `stream`: 是否开启流式响应,`true` 或 `false`。
|
- `gemini-1.5-flash-002`: 快速响应模型
|
||||||
- `tools`: 使用的工具列表。
|
- `gemini-2.0-flash-exp`: 实验性快速响应模型
|
||||||
- 其他参数:与 OpenAI API 兼容的参数,如 `temperature`, `max_tokens` 等。
|
- `gemini-2.0-flash-exp-search`: 支持搜索功能的实验性模型
|
||||||
|
- `stream`: 是否开启流式响应,`true` 或 `false`
|
||||||
|
- `tools`: 使用的工具列表
|
||||||
|
- 其他参数:与 OpenAI API 兼容的参数,如 `temperature`, `max_tokens` 等
|
||||||
|
|
||||||
|
### Gemini原生路由
|
||||||
|
|
||||||
|
#### 获取模型列表
|
||||||
|
|
||||||
|
- **URL**: `/gemini/v1beta/models` 或 `/v1beta/models`
|
||||||
|
- **Method**: `GET`
|
||||||
|
- **Header**: `Authorization: Bearer <your-token>`
|
||||||
|
|
||||||
|
#### 生成内容
|
||||||
|
|
||||||
|
- **URL**: `/gemini/v1beta/models/{model_name}:generateContent`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Header**: `Authorization: Bearer <your-token>`
|
||||||
|
|
||||||
|
#### 流式生成内容
|
||||||
|
|
||||||
|
- **URL**: `/gemini/v1beta/models/{model_name}:streamGenerateContent`
|
||||||
|
- **Method**: `POST`
|
||||||
|
- **Header**: `Authorization: Bearer <your-token>`
|
||||||
|
|
||||||
### 获取词向量 (Embeddings)
|
### 获取词向量 (Embeddings)
|
||||||
|
|
||||||
@@ -169,12 +270,47 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
- **URL**: `/health`
|
- **URL**: `/health`
|
||||||
- **Method**: `GET`
|
- **Method**: `GET`
|
||||||
|
|
||||||
### 获取 API Key 列表
|
### Web界面功能
|
||||||
|
|
||||||
|
#### 验证页面 (auth.html)
|
||||||
|
|
||||||
|
- **URL**: `/auth`
|
||||||
|
- **说明**: 提供了一个简洁的Web界面用于验证访问令牌
|
||||||
|
- **功能特点**:
|
||||||
|
- 现代化的渐变背景设计
|
||||||
|
- 响应式布局,完美支持移动端
|
||||||
|
- 毛玻璃效果的卡片设计
|
||||||
|
- 优雅的动画效果(淡入、滑动、悬浮)
|
||||||
|
- 安全的令牌验证机制
|
||||||
|
- 清晰的错误提示功能
|
||||||
|
- PWA支持,可安装为本地应用
|
||||||
|
- 底部版权信息和GitHub链接
|
||||||
|
- 支持暗色主题适配
|
||||||
|
|
||||||
|
#### API密钥状态管理 (keys_status.html)
|
||||||
|
|
||||||
- **URL**: `/v1/keys/list`
|
- **URL**: `/v1/keys/list`
|
||||||
- **Method**: `GET`
|
- **Method**: `GET`
|
||||||
- **Header**: `Authorization: Bearer <your-auth-token>`
|
- **Header**: `Authorization: Bearer <your-auth-token>`
|
||||||
- **说明**: 只有使用 `AUTH_TOKEN` 才能访问此接口, 用于获取有效和无效的 API Key 列表。
|
- **功能特点**:
|
||||||
|
- 只有使用 `AUTH_TOKEN` 才能访问此接口
|
||||||
|
- 分类展示API密钥状态(有效/无效)
|
||||||
|
- 可折叠的密钥列表分组
|
||||||
|
- 每个密钥显示:
|
||||||
|
- 状态标识(有效/无效)
|
||||||
|
- 密钥内容
|
||||||
|
- 失败次数统计
|
||||||
|
- 高级功能:
|
||||||
|
- 一键复制单个密钥
|
||||||
|
- 批量复制分组密钥(JSON格式)
|
||||||
|
- 实时刷新功能
|
||||||
|
- 回到顶部/底部快捷按钮
|
||||||
|
- 界面特性:
|
||||||
|
- 响应式设计,适配各种屏幕
|
||||||
|
- 优雅的动画效果
|
||||||
|
- 操作反馈(复制成功提示)
|
||||||
|
- PWA支持
|
||||||
|
- 暗色主题适配
|
||||||
|
|
||||||
### 图片生成 (Image Generation)
|
### 图片生成 (Image Generation)
|
||||||
|
|
||||||
@@ -186,12 +322,34 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "dall-e-3",
|
"model": "dall-e-3",
|
||||||
"prompt": "汉服美女",
|
"prompt": "{n:2} {ratio:16:9} 汉服美女",
|
||||||
"n": 1,
|
"n": 1,
|
||||||
"size": "1024x1024"
|
"size": "1024x1024"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Prompt参数说明:**
|
||||||
|
|
||||||
|
prompt支持通过特殊标记来控制生成参数:
|
||||||
|
|
||||||
|
1. 图片数量控制:
|
||||||
|
- 格式: `{n:数量}`
|
||||||
|
- 示例: `{n:2} 一只可爱的猫` - 生成2张图片
|
||||||
|
- 取值范围: 1-4
|
||||||
|
- 说明: 如果在prompt中指定了n,将覆盖请求body中的n参数
|
||||||
|
|
||||||
|
2. 图片比例控制:
|
||||||
|
- 格式: `{ratio:宽:高}`
|
||||||
|
- 示例: `{ratio:16:9} 一片森林` - 生成16:9比例的图片
|
||||||
|
- 支持的比例: "1:1"、"3:4"、"4:3"、"9:16"、"16:9"
|
||||||
|
- 说明: 如果指定了size参数,将优先使用size对应的比例
|
||||||
|
|
||||||
|
3. 参数组合:
|
||||||
|
- 示例: `{n:2} {ratio:16:9} 一片美丽的森林` - 生成2张16:9比例的图片
|
||||||
|
- 说明: 这些参数标记会自动从prompt中移除,不会影响实际的图片生成提示词
|
||||||
|
|
||||||
|
> 注意:n的取值范围[1,4], ratio取值范围"1:1"、"3:4"、"4:3"、"9:16" 和 "16:9"
|
||||||
|
|
||||||
## 📚 代码结构
|
## 📚 代码结构
|
||||||
|
|
||||||
```plaintext
|
```plaintext
|
||||||
@@ -267,6 +425,7 @@ A: 请检查以下几点:
|
|||||||
A: 在请求的 Body 中,将 `stream` 参数设置为 `true` 即可。
|
A: 在请求的 Body 中,将 `stream` 参数设置为 `true` 即可。
|
||||||
|
|
||||||
**Q: 如何启用代码执行工具?**
|
**Q: 如何启用代码执行工具?**
|
||||||
|
|
||||||
A: 在 `.env` 文件的 `TOOLS_CODE_EXECUTION_ENABLED` 变量中, 设置为 `true` 即可。
|
A: 在 `.env` 文件的 `TOOLS_CODE_EXECUTION_ENABLED` 变量中, 设置为 `true` 即可。
|
||||||
|
|
||||||
## 📄 许可证
|
## 📄 许可证
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.logger import get_gemini_logger
|
from app.core.logger import get_gemini_logger
|
||||||
from app.core.security import SecurityService
|
from app.core.security import SecurityService
|
||||||
from app.schemas.gemini_models import GeminiRequest
|
from app.schemas.gemini_models import GeminiContent, GeminiRequest
|
||||||
from app.services.gemini_chat_service import GeminiChatService
|
from app.services.gemini_chat_service import GeminiChatService
|
||||||
from app.services.key_manager import KeyManager
|
from app.services.key_manager import KeyManager, get_key_manager_instance
|
||||||
from app.services.model_service import ModelService
|
from app.services.model_service import ModelService
|
||||||
from app.services.chat.retry_handler import RetryHandler
|
from app.services.chat.retry_handler import RetryHandler
|
||||||
|
|
||||||
@@ -16,13 +16,20 @@ logger = get_gemini_logger()
|
|||||||
|
|
||||||
# 初始化服务
|
# 初始化服务
|
||||||
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
||||||
key_manager = KeyManager(settings.API_KEYS)
|
|
||||||
|
async def get_key_manager():
|
||||||
|
return await get_key_manager_instance()
|
||||||
|
|
||||||
|
async def get_next_working_key_wrapper(key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
|
return await key_manager.get_next_working_key()
|
||||||
|
|
||||||
model_service = ModelService(settings.MODEL_SEARCH)
|
model_service = ModelService(settings.MODEL_SEARCH)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
@router_v1beta.get("/models")
|
@router_v1beta.get("/models")
|
||||||
async def list_models(_=Depends(security_service.verify_key)):
|
async def list_models(_=Depends(security_service.verify_key),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
"""获取可用的Gemini模型列表"""
|
"""获取可用的Gemini模型列表"""
|
||||||
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
|
logger.info("-" * 50 + "list_gemini_models" + "-" * 50)
|
||||||
logger.info("Handling Gemini models list request")
|
logger.info("Handling Gemini models list request")
|
||||||
@@ -40,12 +47,13 @@ async def list_models(_=Depends(security_service.verify_key)):
|
|||||||
|
|
||||||
@router.post("/models/{model_name}:generateContent")
|
@router.post("/models/{model_name}:generateContent")
|
||||||
@router_v1beta.post("/models/{model_name}:generateContent")
|
@router_v1beta.post("/models/{model_name}:generateContent")
|
||||||
@RetryHandler(max_retries=3, key_manager=key_manager, key_arg="api_key")
|
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||||
async def generate_content(
|
async def generate_content(
|
||||||
model_name: str,
|
model_name: str,
|
||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
_=Depends(security_service.verify_goog_api_key),
|
_=Depends(security_service.verify_goog_api_key),
|
||||||
api_key: str = Depends(key_manager.get_next_working_key),
|
api_key: str = Depends(get_next_working_key_wrapper),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||||
"""非流式生成内容"""
|
"""非流式生成内容"""
|
||||||
@@ -55,7 +63,7 @@ async def generate_content(
|
|||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = chat_service.generate_content(
|
response = await chat_service.generate_content(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
request=request,
|
request=request,
|
||||||
api_key=api_key
|
api_key=api_key
|
||||||
@@ -69,12 +77,13 @@ async def generate_content(
|
|||||||
|
|
||||||
@router.post("/models/{model_name}:streamGenerateContent")
|
@router.post("/models/{model_name}:streamGenerateContent")
|
||||||
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
|
@router_v1beta.post("/models/{model_name}:streamGenerateContent")
|
||||||
@RetryHandler(max_retries=3, key_manager=key_manager, key_arg="api_key")
|
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||||
async def stream_generate_content(
|
async def stream_generate_content(
|
||||||
model_name: str,
|
model_name: str,
|
||||||
request: GeminiRequest,
|
request: GeminiRequest,
|
||||||
_=Depends(security_service.verify_goog_api_key),
|
_=Depends(security_service.verify_goog_api_key),
|
||||||
api_key: str = Depends(key_manager.get_next_working_key),
|
api_key: str = Depends(get_next_working_key_wrapper),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||||
"""流式生成内容"""
|
"""流式生成内容"""
|
||||||
@@ -93,3 +102,30 @@ async def stream_generate_content(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Streaming request failed: {str(e)}")
|
logger.error(f"Streaming request failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-key/{api_key}")
|
||||||
|
async def verify_key(api_key: str):
|
||||||
|
key_manager = await get_key_manager()
|
||||||
|
chat_service = GeminiChatService(settings.BASE_URL, key_manager)
|
||||||
|
"""验证Gemini API密钥的有效性"""
|
||||||
|
logger.info("-" * 50 + "verify_gemini_key" + "-" * 50)
|
||||||
|
logger.info("Verifying API key validity")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 使用generate_content接口测试key的有效性
|
||||||
|
gemini_requset = GeminiRequest(
|
||||||
|
contents=[
|
||||||
|
GeminiContent(
|
||||||
|
role="user",
|
||||||
|
parts=[{"text": "hi"}]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
response = await chat_service.generate_content(settings.TEST_MODEL,gemini_requset, api_key)
|
||||||
|
if response:
|
||||||
|
return JSONResponse({"status": "valid"})
|
||||||
|
return JSONResponse({"status": "invalid"})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Key verification failed: {str(e)}")
|
||||||
|
return JSONResponse({"status": "invalid", "error": str(e)})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.schemas.openai_models import ChatRequest, EmbeddingRequest, ImageGenera
|
|||||||
from app.services.chat.retry_handler import RetryHandler
|
from app.services.chat.retry_handler import RetryHandler
|
||||||
from app.services.embedding_service import EmbeddingService
|
from app.services.embedding_service import EmbeddingService
|
||||||
from app.services.image_create_service import ImageCreateService
|
from app.services.image_create_service import ImageCreateService
|
||||||
from app.services.key_manager import KeyManager
|
from app.services.key_manager import KeyManager, get_key_manager_instance
|
||||||
from app.services.model_service import ModelService
|
from app.services.model_service import ModelService
|
||||||
from app.services.openai_chat_service import OpenAIChatService
|
from app.services.openai_chat_service import OpenAIChatService
|
||||||
|
|
||||||
@@ -17,15 +17,22 @@ logger = get_openai_logger()
|
|||||||
|
|
||||||
# 初始化服务
|
# 初始化服务
|
||||||
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
security_service = SecurityService(settings.ALLOWED_TOKENS, settings.AUTH_TOKEN)
|
||||||
key_manager = KeyManager(settings.API_KEYS)
|
|
||||||
model_service = ModelService(settings.MODEL_SEARCH)
|
model_service = ModelService(settings.MODEL_SEARCH)
|
||||||
embedding_service = EmbeddingService(settings.BASE_URL)
|
embedding_service = EmbeddingService(settings.BASE_URL)
|
||||||
image_create_service = ImageCreateService()
|
image_create_service = ImageCreateService()
|
||||||
|
|
||||||
|
async def get_key_manager():
|
||||||
|
return await get_key_manager_instance()
|
||||||
|
|
||||||
|
async def get_next_working_key_wrapper(key_manager: KeyManager = Depends(get_key_manager)):
|
||||||
|
return await key_manager.get_next_working_key()
|
||||||
|
|
||||||
@router.get("/v1/models")
|
@router.get("/v1/models")
|
||||||
@router.get("/hf/v1/models")
|
@router.get("/hf/v1/models")
|
||||||
async def list_models(_=Depends(security_service.verify_authorization)):
|
async def list_models(
|
||||||
|
_=Depends(security_service.verify_authorization),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
|
):
|
||||||
logger.info("-" * 50 + "list_models" + "-" * 50)
|
logger.info("-" * 50 + "list_models" + "-" * 50)
|
||||||
logger.info("Handling models list request")
|
logger.info("Handling models list request")
|
||||||
api_key = await key_manager.get_next_working_key()
|
api_key = await key_manager.get_next_working_key()
|
||||||
@@ -39,11 +46,12 @@ async def list_models(_=Depends(security_service.verify_authorization)):
|
|||||||
|
|
||||||
@router.post("/v1/chat/completions")
|
@router.post("/v1/chat/completions")
|
||||||
@router.post("/hf/v1/chat/completions")
|
@router.post("/hf/v1/chat/completions")
|
||||||
@RetryHandler(max_retries=3, key_manager=key_manager, key_arg="api_key")
|
@RetryHandler(max_retries=3, key_arg="api_key")
|
||||||
async def chat_completion(
|
async def chat_completion(
|
||||||
request: ChatRequest,
|
request: ChatRequest,
|
||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
api_key: str = Depends(key_manager.get_next_working_key),
|
api_key: str = Depends(get_next_working_key_wrapper),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
# 如果model是imagen3,使用paid_key
|
# 如果model是imagen3,使用paid_key
|
||||||
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||||
@@ -54,23 +62,25 @@ async def chat_completion(
|
|||||||
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
logger.info(f"Request: \n{request.model_dump_json(indent=2)}")
|
||||||
logger.info(f"Using API key: {api_key}")
|
logger.info(f"Using API key: {api_key}")
|
||||||
try:
|
try:
|
||||||
response = await chat_service.create_image_chat_completion(request=request)
|
# 如果model是imagen3,使用paid_key
|
||||||
|
if request.model == f"{settings.CREATE_IMAGE_MODEL}-chat":
|
||||||
|
response = await chat_service.create_image_chat_completion(request=request)
|
||||||
|
else:
|
||||||
|
response = await chat_service.create_chat_completion(request, api_key)
|
||||||
# 处理流式响应
|
# 处理流式响应
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return StreamingResponse(response, media_type="text/event-stream")
|
return StreamingResponse(response, media_type="text/event-stream")
|
||||||
logger.info("Chat completion request successful")
|
logger.info("Chat completion request successful")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Chat completion failed after retries: {str(e)}")
|
logger.error(f"Chat completion failed after retries: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Chat completion failed") from e
|
raise HTTPException(status_code=500, detail="Chat completion failed") from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/images/generations")
|
@router.post("/v1/images/generations")
|
||||||
@router.post("/hf/v1/images/generations")
|
@router.post("/hf/v1/images/generations")
|
||||||
async def generate_image(
|
async def generate_image(
|
||||||
request: ImageGenerationRequest,
|
request: ImageGenerationRequest,
|
||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
):
|
):
|
||||||
logger.info("-" * 50 + "generate_image" + "-" * 50)
|
logger.info("-" * 50 + "generate_image" + "-" * 50)
|
||||||
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
logger.info(f"Handling image generation request for prompt: {request.prompt}")
|
||||||
@@ -79,17 +89,16 @@ async def generate_image(
|
|||||||
response = image_create_service.generate_images(request)
|
response = image_create_service.generate_images(request)
|
||||||
logger.info("Image generation request successful")
|
logger.info("Image generation request successful")
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Image generation request failed: {str(e)}")
|
logger.error(f"Image generation request failed: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Image generation request failed") from e
|
raise HTTPException(status_code=500, detail="Image generation request failed") from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/v1/embeddings")
|
@router.post("/v1/embeddings")
|
||||||
@router.post("/hf/v1/embeddings")
|
@router.post("/hf/v1/embeddings")
|
||||||
async def embedding(
|
async def embedding(
|
||||||
request: EmbeddingRequest,
|
request: EmbeddingRequest,
|
||||||
_=Depends(security_service.verify_authorization),
|
_=Depends(security_service.verify_authorization),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
logger.info("-" * 50 + "embedding" + "-" * 50)
|
logger.info("-" * 50 + "embedding" + "-" * 50)
|
||||||
logger.info(f"Handling embedding request for model: {request.model}")
|
logger.info(f"Handling embedding request for model: {request.model}")
|
||||||
@@ -105,11 +114,11 @@ async def embedding(
|
|||||||
logger.error(f"Embedding request failed: {str(e)}")
|
logger.error(f"Embedding request failed: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Embedding request failed") from e
|
raise HTTPException(status_code=500, detail="Embedding request failed") from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/v1/keys/list")
|
@router.get("/v1/keys/list")
|
||||||
@router.get("/hf/v1/keys/list")
|
@router.get("/hf/v1/keys/list")
|
||||||
async def get_keys_list(
|
async def get_keys_list(
|
||||||
_=Depends(security_service.verify_auth_token),
|
_=Depends(security_service.verify_auth_token),
|
||||||
|
key_manager: KeyManager = Depends(get_key_manager)
|
||||||
):
|
):
|
||||||
"""获取有效和无效的API key列表"""
|
"""获取有效和无效的API key列表"""
|
||||||
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
|
logger.info("-" * 50 + "get_keys_list" + "-" * 50)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
|||||||
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
|
CREATE_IMAGE_MODEL: str = "imagen-3.0-generate-002"
|
||||||
UPLOAD_PROVIDER: str = "smms"
|
UPLOAD_PROVIDER: str = "smms"
|
||||||
SMMS_SECRET_TOKEN: str = ""
|
SMMS_SECRET_TOKEN: str = ""
|
||||||
|
TEST_MODEL: str = "gemini-1.5-flash"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
from fastapi import HTTPException, Header
|
from fastapi import HTTPException, Header
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.core.logger import get_security_logger
|
from app.core.logger import get_security_logger
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = get_security_logger()
|
logger = get_security_logger()
|
||||||
|
|
||||||
|
def verify_auth_token(token: str) -> bool:
|
||||||
|
return token == settings.AUTH_TOKEN
|
||||||
|
|
||||||
class SecurityService:
|
class SecurityService:
|
||||||
def __init__(self, allowed_tokens: list, auth_token: str):
|
def __init__(self, allowed_tokens: list, auth_token: str):
|
||||||
|
|||||||
104
app/main.py
104
app/main.py
@@ -1,17 +1,61 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
from app.core.logger import get_main_logger
|
from app.core.logger import get_main_logger
|
||||||
|
from app.core.security import verify_auth_token
|
||||||
|
from app.services.key_manager import get_key_manager_instance
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
from app.api import gemini_routes, openai_routes
|
from app.api import gemini_routes, openai_routes
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from app.middleware.request_logging_middleware import RequestLoggingMiddleware
|
|
||||||
|
|
||||||
# 配置日志
|
# 配置日志
|
||||||
logger = get_main_logger()
|
logger = get_main_logger()
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# 配置Jinja2模板
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
# 配置静态文件
|
||||||
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
# 创建 KeyManager 实例
|
||||||
|
key_manager = None
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
global key_manager
|
||||||
|
logger.info("Application starting up...")
|
||||||
|
try:
|
||||||
|
key_manager = await get_key_manager_instance(settings.API_KEYS)
|
||||||
|
logger.info("KeyManager initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize KeyManager: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 添加中间件来处理未经身份验证的请求
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
# 允许 gemini_routes 和 openai_routes 中的端点绕过身份验证
|
||||||
|
if (request.url.path not in ["/", "/auth"] and
|
||||||
|
not request.url.path.startswith("/static") and
|
||||||
|
not request.url.path.startswith("/gemini") and
|
||||||
|
not request.url.path.startswith("/v1") and
|
||||||
|
not request.url.path.startswith("/v1beta") and
|
||||||
|
not request.url.path.startswith("/health") and
|
||||||
|
not request.url.path.startswith("/hf")):
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning(f"Unauthorized access attempt to {request.url.path}")
|
||||||
|
return RedirectResponse(url="/")
|
||||||
|
logger.debug("Request authenticated successfully")
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
# 添加请求日志中间件
|
# 添加请求日志中间件
|
||||||
# app.add_middleware(RequestLoggingMiddleware)
|
# app.add_middleware(RequestLoggingMiddleware)
|
||||||
|
|
||||||
@@ -32,13 +76,59 @@ app.include_router(gemini_routes.router)
|
|||||||
app.include_router(gemini_routes.router_v1beta)
|
app.include_router(gemini_routes.router_v1beta)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def auth_page(request: Request):
|
||||||
|
return templates.TemplateResponse("auth.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/auth")
|
||||||
|
async def authenticate(request: Request):
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
auth_token = form.get("auth_token")
|
||||||
|
if not auth_token:
|
||||||
|
logger.warning("Authentication attempt with empty token")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
if verify_auth_token(auth_token):
|
||||||
|
logger.info("Successful authentication")
|
||||||
|
response = RedirectResponse(url="/keys", status_code=302)
|
||||||
|
response.set_cookie(key="auth_token", value=auth_token, httponly=True, max_age=3600)
|
||||||
|
return response
|
||||||
|
logger.warning("Failed authentication attempt with invalid token")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Authentication error: {str(e)}")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
@app.get("/keys", response_class=HTMLResponse)
|
||||||
|
async def keys_page(request: Request):
|
||||||
|
try:
|
||||||
|
auth_token = request.cookies.get("auth_token")
|
||||||
|
if not auth_token or not verify_auth_token(auth_token):
|
||||||
|
logger.warning("Unauthorized access attempt to keys page")
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
keys_status = await key_manager.get_keys_by_status()
|
||||||
|
total = len(keys_status["valid_keys"]) + len(keys_status["invalid_keys"])
|
||||||
|
logger.info(f"Keys status retrieved successfully. Total keys: {total}")
|
||||||
|
return templates.TemplateResponse("keys_status.html", {
|
||||||
|
"request": request,
|
||||||
|
"valid_keys": keys_status["valid_keys"],
|
||||||
|
"invalid_keys": keys_status["invalid_keys"],
|
||||||
|
"total": total
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving keys status: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@app.get("/")
|
async def health_check(request: Request):
|
||||||
async def health_check():
|
|
||||||
logger.info("Health check endpoint called")
|
logger.info("Health check endpoint called")
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logger.info("Starting application server...")
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ class ImageGenerationRequest(BaseModel):
|
|||||||
size: Optional[str] = "1024x1024"
|
size: Optional[str] = "1024x1024"
|
||||||
quality: Optional[str] = ""
|
quality: Optional[str] = ""
|
||||||
style: Optional[str] = ""
|
style: Optional[str] = ""
|
||||||
response_format: Optional[str] = "b64_json"
|
response_format: Optional[str] = "url"
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ class GeminiApiClient(ApiClient):
|
|||||||
self.base_url = base_url
|
self.base_url = base_url
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
async def generate_content(self, payload: Dict[str, Any], model: str, api_key: str) -> Dict[str, Any]:
|
||||||
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
timeout = httpx.Timeout(self.timeout, read=self.timeout)
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
model = model[:-7]
|
model = model[:-7]
|
||||||
with httpx.Client(timeout=timeout) as client:
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
url = f"{self.base_url}/models/{model}:generateContent?key={api_key}"
|
||||||
response = client.post(url, json=payload)
|
response = await client.post(url, json=payload)
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
error_content = response.text
|
error_content = response.text
|
||||||
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
raise Exception(f"API call failed with status code {response.status_code}, {error_content}")
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
# app/services/chat/message_converter.py
|
# app/services/chat/message_converter.py
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
SUPPORTED_ROLES = ["user", "model", "system"]
|
||||||
|
|
||||||
|
|
||||||
class MessageConverter(ABC):
|
class MessageConverter(ABC):
|
||||||
"""消息转换器基类"""
|
"""消息转换器基类"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -30,24 +32,40 @@ def _convert_image(image_url: str) -> Dict[str, Any]:
|
|||||||
class OpenAIMessageConverter(MessageConverter):
|
class OpenAIMessageConverter(MessageConverter):
|
||||||
"""OpenAI消息格式转换器"""
|
"""OpenAI消息格式转换器"""
|
||||||
|
|
||||||
def convert(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def convert(self, messages: List[Dict[str, Any]]) -> tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]:
|
||||||
converted_messages = []
|
converted_messages = []
|
||||||
for msg in messages:
|
system_instruction = None
|
||||||
role = "user" if msg["role"] == "user" else "model"
|
|
||||||
parts = []
|
|
||||||
|
|
||||||
if isinstance(msg["content"], str):
|
for idx, msg in enumerate(messages):
|
||||||
|
role = msg.get("role", "")
|
||||||
|
if role not in SUPPORTED_ROLES:
|
||||||
|
if role == "tool":
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
# 如果是最后一条消息,则认为是用户消息
|
||||||
|
if idx == len(messages) - 1:
|
||||||
|
role = "user"
|
||||||
|
else:
|
||||||
|
role = "model"
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if isinstance(msg["content"], str) and msg["content"]:
|
||||||
|
# 请求 gemini 接口时如果包含 content 字段但内容为空时会返回 400 错误,所以需要判断是否为空并移除
|
||||||
parts.append({"text": msg["content"]})
|
parts.append({"text": msg["content"]})
|
||||||
elif isinstance(msg["content"], list):
|
elif isinstance(msg["content"], list):
|
||||||
for content in msg["content"]:
|
for content in msg["content"]:
|
||||||
if isinstance(content, str):
|
if isinstance(content, str) and content:
|
||||||
parts.append({"text": content})
|
parts.append({"text": content})
|
||||||
elif isinstance(content, dict):
|
elif isinstance(content, dict):
|
||||||
if content["type"] == "text":
|
if content["type"] == "text" and content["text"]:
|
||||||
parts.append({"text": content["text"]})
|
parts.append({"text": content["text"]})
|
||||||
elif content["type"] == "image_url":
|
elif content["type"] == "image_url":
|
||||||
parts.append(_convert_image(content["image_url"]["url"]))
|
parts.append(_convert_image(content["image_url"]["url"]))
|
||||||
|
|
||||||
converted_messages.append({"role": role, "parts": parts})
|
if parts:
|
||||||
|
if role == "system":
|
||||||
|
system_instruction = {"role": "system", "parts": parts}
|
||||||
|
else:
|
||||||
|
converted_messages.append({"role": role, "parts": parts})
|
||||||
|
|
||||||
return converted_messages
|
return converted_messages, system_instruction
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# app/services/chat/response_handler.py
|
# app/services/chat/response_handler.py
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -29,40 +32,38 @@ class GeminiResponseHandler(ResponseHandler):
|
|||||||
|
|
||||||
|
|
||||||
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
def _handle_openai_stream_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=True)
|
text, tool_calls = _extract_result(response, model, stream=True, gemini_format=False)
|
||||||
|
if not text and not tool_calls:
|
||||||
|
delta = {}
|
||||||
|
else:
|
||||||
|
delta = {"content": text, "role": "assistant"}
|
||||||
|
if tool_calls:
|
||||||
|
delta["tool_calls"] = tool_calls
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion.chunk",
|
"object": "chat.completion.chunk",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
||||||
"index": 0,
|
|
||||||
"delta": {"content": text} if text else {},
|
|
||||||
"finish_reason": finish_reason
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
def _handle_openai_normal_response(response: Dict[str, Any], model: str, finish_reason: str) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=False)
|
text, tool_calls = _extract_result(response, model, stream=False, gemini_format=False)
|
||||||
return {
|
return {
|
||||||
"id": f"chatcmpl-{uuid.uuid4()}",
|
"id": f"chatcmpl-{uuid.uuid4()}",
|
||||||
"object": "chat.completion",
|
"object": "chat.completion",
|
||||||
"created": int(time.time()),
|
"created": int(time.time()),
|
||||||
"model": model,
|
"model": model,
|
||||||
"choices": [{
|
"choices": [
|
||||||
"index": 0,
|
{
|
||||||
"message": {
|
"index": 0,
|
||||||
"role": "assistant",
|
"message": {"role": "assistant", "content": text, "tool_calls": tool_calls},
|
||||||
"content": text
|
"finish_reason": finish_reason,
|
||||||
},
|
}
|
||||||
"finish_reason": finish_reason
|
],
|
||||||
}],
|
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 0,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"total_tokens": 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,8 +128,8 @@ def _handle_openai_normal_image_response(image_str: str,model: str,finish_reason
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) -> str:
|
def _extract_result(response: Dict[str, Any], model: str, stream: bool = False, gemini_format: bool = False) -> tuple[str, List[Dict[str, Any]]]:
|
||||||
text = ""
|
text, tool_calls = "", []
|
||||||
if stream:
|
if stream:
|
||||||
if response.get("candidates"):
|
if response.get("candidates"):
|
||||||
candidate = response["candidates"][0]
|
candidate = response["candidates"][0]
|
||||||
@@ -212,6 +213,7 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
|
|||||||
else:
|
else:
|
||||||
text = ""
|
text = ""
|
||||||
text = _add_search_link_text(model, candidate, text)
|
text = _add_search_link_text(model, candidate, text)
|
||||||
|
tool_calls = _extract_tool_calls(parts, gemini_format)
|
||||||
else:
|
else:
|
||||||
if response.get("candidates"):
|
if response.get("candidates"):
|
||||||
candidate = response["candidates"][0]
|
candidate = response["candidates"][0]
|
||||||
@@ -232,23 +234,67 @@ def _extract_text(response: Dict[str, Any], model: str, stream: bool = False) ->
|
|||||||
else:
|
else:
|
||||||
text = candidate["content"]["parts"][0]["text"]
|
text = candidate["content"]["parts"][0]["text"]
|
||||||
else:
|
else:
|
||||||
text = candidate["content"]["parts"][0]["text"]
|
text = ""
|
||||||
|
for part in candidate["content"]["parts"]:
|
||||||
|
text += part.get("text", "")
|
||||||
text = _add_search_link_text(model, candidate, text)
|
text = _add_search_link_text(model, candidate, text)
|
||||||
|
tool_calls = _extract_tool_calls(candidate["content"]["parts"], gemini_format)
|
||||||
else:
|
else:
|
||||||
text = "暂无返回"
|
text = "暂无返回"
|
||||||
return text
|
return text, tool_calls
|
||||||
|
|
||||||
|
def _extract_tool_calls(parts: List[Dict[str, Any]], gemini_format: bool) -> List[Dict[str, Any]]:
|
||||||
|
"""提取工具调用信息"""
|
||||||
|
if not parts or not isinstance(parts, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
letters = string.ascii_lowercase + string.digits
|
||||||
|
|
||||||
|
tool_calls = list()
|
||||||
|
for i in range(len(parts)):
|
||||||
|
part = parts[i]
|
||||||
|
if not part or not isinstance(part, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
item = part.get("functionCall", {})
|
||||||
|
if not item or not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if gemini_format:
|
||||||
|
tool_calls.append(part)
|
||||||
|
else:
|
||||||
|
id = f"call_{''.join(random.sample(letters, 32))}"
|
||||||
|
name = item.get("name", "")
|
||||||
|
arguments = json.dumps(item.get("args", None) or {})
|
||||||
|
|
||||||
|
tool_calls.append(
|
||||||
|
{
|
||||||
|
"index": i,
|
||||||
|
"id": id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": name, "arguments": arguments},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return tool_calls
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_stream_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=stream)
|
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||||
content = {"parts": [{"text": text}], "role": "model"}
|
if tool_calls:
|
||||||
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
|
else:
|
||||||
|
content = {"parts": [{"text": text}], "role": "model"}
|
||||||
response["candidates"][0]["content"] = content
|
response["candidates"][0]["content"] = content
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
def _handle_gemini_normal_response(response: Dict[str, Any], model: str, stream: bool) -> Dict[str, Any]:
|
||||||
text = _extract_text(response, model, stream=stream)
|
text, tool_calls = _extract_result(response, model, stream=stream, gemini_format=True)
|
||||||
content = {"parts": [{"text": text}], "role": "model"}
|
if tool_calls:
|
||||||
|
content = {"parts": tool_calls, "role": "model"}
|
||||||
|
else:
|
||||||
|
content = {"parts": [{"text": text}], "role": "model"}
|
||||||
response["candidates"][0]["content"] = content
|
response["candidates"][0]["content"] = content
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from typing import TypeVar, Callable
|
from typing import TypeVar, Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from app.core.logger import get_retry_logger
|
from app.core.logger import get_retry_logger
|
||||||
from app.services.key_manager import KeyManager
|
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
logger = get_retry_logger()
|
logger = get_retry_logger()
|
||||||
@@ -12,9 +11,8 @@ logger = get_retry_logger()
|
|||||||
class RetryHandler:
|
class RetryHandler:
|
||||||
"""重试处理装饰器"""
|
"""重试处理装饰器"""
|
||||||
|
|
||||||
def __init__(self, max_retries: int = 3, key_manager: KeyManager = None, key_arg: str = "api_key"):
|
def __init__(self, max_retries: int = 3, key_arg: str = "api_key"):
|
||||||
self.max_retries = max_retries
|
self.max_retries = max_retries
|
||||||
self.key_manager = key_manager
|
|
||||||
self.key_arg = key_arg
|
self.key_arg = key_arg
|
||||||
|
|
||||||
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
|
def __call__(self, func: Callable[..., T]) -> Callable[..., T]:
|
||||||
@@ -29,9 +27,11 @@ class RetryHandler:
|
|||||||
last_exception = e
|
last_exception = e
|
||||||
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}")
|
logger.warning(f"API call failed with error: {str(e)}. Attempt {attempt + 1} of {self.max_retries}")
|
||||||
|
|
||||||
if self.key_manager:
|
# 从函数参数中获取 key_manager
|
||||||
|
key_manager = kwargs.get('key_manager')
|
||||||
|
if key_manager:
|
||||||
old_key = kwargs.get(self.key_arg)
|
old_key = kwargs.get(self.key_arg)
|
||||||
new_key = await self.key_manager.handle_api_failure(old_key)
|
new_key = await key_manager.handle_api_failure(old_key)
|
||||||
kwargs[self.key_arg] = 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: {new_key}")
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ def _build_tools(model: str, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|||||||
tools.append({"code_execution": {}})
|
tools.append({"code_execution": {}})
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
tools.append({"googleSearch": {}})
|
tools.append({"googleSearch": {}})
|
||||||
|
|
||||||
|
if payload and isinstance(payload, dict) and "tools" in payload:
|
||||||
|
items = payload.get("tools", [])
|
||||||
|
if items and isinstance(items, list):
|
||||||
|
tools.extend(items)
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
@@ -73,10 +79,10 @@ class GeminiChatService:
|
|||||||
self.key_manager = key_manager
|
self.key_manager = key_manager
|
||||||
self.response_handler = GeminiResponseHandler()
|
self.response_handler = GeminiResponseHandler()
|
||||||
|
|
||||||
def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
|
async def generate_content(self, model: str, request: GeminiRequest, api_key: str) -> Dict[str, Any]:
|
||||||
"""生成内容"""
|
"""生成内容"""
|
||||||
payload = _build_payload(model, request)
|
payload = _build_payload(model, request)
|
||||||
response = self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
return self.response_handler.handle_response(response, model, stream=False)
|
return self.response_handler.handle_response(response, model, stream=False)
|
||||||
|
|
||||||
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:
|
async def stream_generate_content(self, model: str, request: GeminiRequest, api_key: str) -> AsyncGenerator[str, None]:
|
||||||
|
|||||||
@@ -19,8 +19,42 @@ class ImageCreateService:
|
|||||||
self.paid_key = settings.PAID_KEY
|
self.paid_key = settings.PAID_KEY
|
||||||
self.aspect_ratio = aspect_ratio
|
self.aspect_ratio = aspect_ratio
|
||||||
|
|
||||||
|
def parse_prompt_parameters(self, prompt: str) -> tuple:
|
||||||
|
"""从prompt中解析参数
|
||||||
|
支持的格式:
|
||||||
|
- {n:数量} 例如: {n:2} 生成2张图片
|
||||||
|
- {ratio:比例} 例如: {ratio:16:9} 使用16:9比例
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 默认值
|
||||||
|
n = 1
|
||||||
|
aspect_ratio = self.aspect_ratio
|
||||||
|
|
||||||
|
# 解析n参数
|
||||||
|
n_match = re.search(r'{n:(\d+)}', prompt)
|
||||||
|
if n_match:
|
||||||
|
n = int(n_match.group(1))
|
||||||
|
if n < 1 or n > 4:
|
||||||
|
raise ValueError(f"Invalid n value: {n}. Must be between 1 and 4.")
|
||||||
|
prompt = prompt.replace(n_match.group(0), '').strip()
|
||||||
|
|
||||||
|
# 解析ratio参数
|
||||||
|
ratio_match = re.search(r'{ratio:(\d+:\d+)}', prompt)
|
||||||
|
if ratio_match:
|
||||||
|
aspect_ratio = ratio_match.group(1)
|
||||||
|
valid_ratios = ["1:1", "3:4", "4:3", "9:16", "16:9"]
|
||||||
|
if aspect_ratio not in valid_ratios:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid ratio: {aspect_ratio}. Must be one of: {', '.join(valid_ratios)}"
|
||||||
|
)
|
||||||
|
prompt = prompt.replace(ratio_match.group(0), '').strip()
|
||||||
|
|
||||||
|
return prompt, n, aspect_ratio
|
||||||
|
|
||||||
def generate_images(self, request: ImageGenerationRequest):
|
def generate_images(self, request: ImageGenerationRequest):
|
||||||
client = genai.Client(api_key=self.paid_key)
|
client = genai.Client(api_key=self.paid_key)
|
||||||
|
|
||||||
if request.size == "1024x1024":
|
if request.size == "1024x1024":
|
||||||
self.aspect_ratio = "1:1"
|
self.aspect_ratio = "1:1"
|
||||||
elif request.size == "1792x1024":
|
elif request.size == "1792x1024":
|
||||||
@@ -32,6 +66,18 @@ class ImageCreateService:
|
|||||||
f"Invalid size: {request.size}. Supported sizes are 1024x1024, 1792x1024, and 1024x1792."
|
f"Invalid size: {request.size}. Supported sizes are 1024x1024, 1792x1024, and 1024x1792."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 解析prompt中的参数
|
||||||
|
cleaned_prompt, prompt_n, prompt_ratio = self.parse_prompt_parameters(request.prompt)
|
||||||
|
request.prompt = cleaned_prompt
|
||||||
|
|
||||||
|
# 如果prompt中指定了n,则覆盖请求中的n
|
||||||
|
if prompt_n > 1:
|
||||||
|
request.n = prompt_n
|
||||||
|
|
||||||
|
# 如果prompt中指定了ratio,则覆盖默认的aspect_ratio
|
||||||
|
if prompt_ratio != self.aspect_ratio:
|
||||||
|
self.aspect_ratio = prompt_ratio
|
||||||
|
|
||||||
response = client.models.generate_images(
|
response = client.models.generate_images(
|
||||||
model=self.image_model,
|
model=self.image_model,
|
||||||
prompt=request.prompt,
|
prompt=request.prompt,
|
||||||
@@ -56,11 +102,17 @@ class ImageCreateService:
|
|||||||
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
filename = f"{current_date}/{uuid.uuid4().hex[:8]}.png"
|
||||||
upload_response = image_uploader.upload(image_data,filename)
|
upload_response = image_uploader.upload(image_data,filename)
|
||||||
|
|
||||||
# base64_image = base64.b64encode(image_data).decode('utf-8')
|
if request.response_format == "b64_json":
|
||||||
images_data.append({
|
base64_image = base64.b64encode(image_data).decode('utf-8')
|
||||||
"url": f"{upload_response.data.url}",
|
images_data.append({
|
||||||
"revised_prompt": request.prompt
|
"b64_json": base64_image,
|
||||||
})
|
"revised_prompt": request.prompt
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
images_data.append({
|
||||||
|
"url": f"{upload_response.data.url}",
|
||||||
|
"revised_prompt": request.prompt
|
||||||
|
})
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"created": int(time.time()), # Current timestamp
|
"created": int(time.time()), # Current timestamp
|
||||||
@@ -76,6 +128,9 @@ class ImageCreateService:
|
|||||||
if image_datas:
|
if image_datas:
|
||||||
markdown_images = []
|
markdown_images = []
|
||||||
for index, image_data in enumerate(image_datas):
|
for index, image_data in enumerate(image_datas):
|
||||||
markdown_images.append(f"")
|
if 'url' in image_data:
|
||||||
|
markdown_images.append(f"")
|
||||||
|
else:
|
||||||
|
# 如果是base64格式,创建data URL
|
||||||
|
markdown_images.append(f"")
|
||||||
return "\n".join(markdown_images)
|
return "\n".join(markdown_images)
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ from typing import Dict
|
|||||||
from app.core.logger import get_key_manager_logger
|
from app.core.logger import get_key_manager_logger
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
logger = get_key_manager_logger()
|
logger = get_key_manager_logger()
|
||||||
|
|
||||||
|
|
||||||
@@ -61,20 +62,44 @@ class KeyManager:
|
|||||||
|
|
||||||
return await self.get_next_working_key()
|
return await self.get_next_working_key()
|
||||||
|
|
||||||
|
def get_fail_count(self, key: str) -> int:
|
||||||
|
"""获取指定密钥的失败次数"""
|
||||||
|
return self.key_failure_counts.get(key, 0)
|
||||||
|
|
||||||
async def get_keys_by_status(self) -> dict:
|
async def get_keys_by_status(self) -> dict:
|
||||||
"""获取分类后的API key列表"""
|
"""获取分类后的API key列表,包括失败次数"""
|
||||||
valid_keys = []
|
valid_keys = {}
|
||||||
invalid_keys = []
|
invalid_keys = {}
|
||||||
|
|
||||||
async with self.failure_count_lock:
|
async with self.failure_count_lock:
|
||||||
for key in self.api_keys:
|
for key in self.api_keys:
|
||||||
masked_key = f"{key}"
|
fail_count = self.key_failure_counts[key]
|
||||||
if self.key_failure_counts[key] < self.MAX_FAILURES:
|
if fail_count < self.MAX_FAILURES:
|
||||||
valid_keys.append(masked_key)
|
valid_keys[key] = fail_count
|
||||||
else:
|
else:
|
||||||
invalid_keys.append(masked_key)
|
invalid_keys[key] = fail_count
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid_keys": valid_keys,
|
"valid_keys": valid_keys,
|
||||||
"invalid_keys": invalid_keys
|
"invalid_keys": invalid_keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_singleton_instance = None
|
||||||
|
_singleton_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def get_key_manager_instance(api_keys: list = None) -> KeyManager:
|
||||||
|
"""
|
||||||
|
获取 KeyManager 单例实例。
|
||||||
|
|
||||||
|
如果尚未创建实例,将使用提供的 api_keys 初始化 KeyManager。
|
||||||
|
如果已创建实例,则忽略 api_keys 参数,返回现有单例。
|
||||||
|
"""
|
||||||
|
global _singleton_instance
|
||||||
|
|
||||||
|
async with _singleton_lock:
|
||||||
|
if _singleton_instance is None:
|
||||||
|
if api_keys is None:
|
||||||
|
raise ValueError("API keys are required to initialize the KeyManager")
|
||||||
|
_singleton_instance = KeyManager(api_keys)
|
||||||
|
return _singleton_instance
|
||||||
|
|||||||
@@ -52,15 +52,14 @@ class ModelService:
|
|||||||
"parent": None,
|
"parent": None,
|
||||||
}
|
}
|
||||||
openai_format["data"].append(openai_model)
|
openai_format["data"].append(openai_model)
|
||||||
|
|
||||||
if settings.CREATE_IMAGE_MODEL:
|
|
||||||
image_model = openai_model.copy()
|
|
||||||
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
|
|
||||||
openai_format["data"].append(image_model)
|
|
||||||
|
|
||||||
if model_id in self.model_search:
|
if model_id in self.model_search:
|
||||||
search_model = openai_model.copy()
|
search_model = openai_model.copy()
|
||||||
search_model["id"] = f"{model_id}-search"
|
search_model["id"] = f"{model_id}-search"
|
||||||
openai_format["data"].append(search_model)
|
openai_format["data"].append(search_model)
|
||||||
|
|
||||||
|
if settings.CREATE_IMAGE_MODEL:
|
||||||
|
image_model = openai_model.copy()
|
||||||
|
image_model["id"] = f"{settings.CREATE_IMAGE_MODEL}-chat"
|
||||||
|
openai_format["data"].append(image_model)
|
||||||
return openai_format
|
return openai_format
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
# app/services/chat_service.py
|
# app/services/chat_service.py
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
import json
|
import json
|
||||||
from typing import Dict, Any, AsyncGenerator, List, Union
|
from typing import Dict, Any, AsyncGenerator, List, Optional, Union
|
||||||
from app.core.logger import get_openai_logger
|
from app.core.logger import get_openai_logger
|
||||||
|
from app.services.chat.message_converter import OpenAIMessageConverter
|
||||||
from app.services.chat.response_handler import OpenAIResponseHandler
|
from app.services.chat.response_handler import OpenAIResponseHandler
|
||||||
from app.services.chat.api_client import GeminiApiClient
|
from app.services.chat.api_client import GeminiApiClient
|
||||||
from app.schemas.openai_models import ChatRequest, ImageGenerationRequest
|
from app.schemas.openai_models import ChatRequest, ImageGenerationRequest
|
||||||
@@ -38,6 +40,32 @@ def _build_tools(
|
|||||||
tools.append({"code_execution": {}})
|
tools.append({"code_execution": {}})
|
||||||
if model.endswith("-search"):
|
if model.endswith("-search"):
|
||||||
tools.append({"googleSearch": {}})
|
tools.append({"googleSearch": {}})
|
||||||
|
|
||||||
|
# 将 request 中的 tools 合并到 tools 中
|
||||||
|
if request.tools:
|
||||||
|
function_declarations = []
|
||||||
|
for tool in request.tools:
|
||||||
|
if not tool or not isinstance(tool, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tool.get("type", "") == "function" and tool.get("function"):
|
||||||
|
function = deepcopy(tool.get("function"))
|
||||||
|
parameters = function.get("parameters", {})
|
||||||
|
if parameters.get("type") == "object" and not parameters.get("properties", {}):
|
||||||
|
function.pop("parameters", None)
|
||||||
|
|
||||||
|
function_declarations.append(function)
|
||||||
|
|
||||||
|
if function_declarations:
|
||||||
|
# 按照 function 的 name 去重
|
||||||
|
names, functions = set(), []
|
||||||
|
for item in function_declarations:
|
||||||
|
if item.get("name") not in names:
|
||||||
|
names.add(item.get("name"))
|
||||||
|
functions.append(item)
|
||||||
|
|
||||||
|
tools.append({"functionDeclarations": functions})
|
||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
|
|
||||||
@@ -66,10 +94,10 @@ def _get_safety_settings(model: str) -> List[Dict[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_payload(
|
def _build_payload(
|
||||||
request: ChatRequest, messages: List[Dict[str, Any]]
|
request: ChatRequest, messages: List[Dict[str, Any]], instruction: Optional[Dict[str, Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""构建请求payload"""
|
"""构建请求payload"""
|
||||||
return {
|
payload = {
|
||||||
"contents": messages,
|
"contents": messages,
|
||||||
"generationConfig": {
|
"generationConfig": {
|
||||||
"temperature": request.temperature,
|
"temperature": request.temperature,
|
||||||
@@ -82,12 +110,21 @@ def _build_payload(
|
|||||||
"safetySettings": _get_safety_settings(request.model),
|
"safetySettings": _get_safety_settings(request.model),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
instruction
|
||||||
|
and isinstance(instruction, dict)
|
||||||
|
and instruction.get("role") == "system"
|
||||||
|
and instruction.get("parts")
|
||||||
|
):
|
||||||
|
payload["systemInstruction"] = instruction
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
class OpenAIChatService:
|
class OpenAIChatService:
|
||||||
"""聊天服务"""
|
"""聊天服务"""
|
||||||
|
|
||||||
def __init__(self, base_url: str, key_manager: KeyManager = None):
|
def __init__(self, base_url: str, key_manager: KeyManager = None):
|
||||||
|
self.message_converter = OpenAIMessageConverter()
|
||||||
self.response_handler = OpenAIResponseHandler(config=None)
|
self.response_handler = OpenAIResponseHandler(config=None)
|
||||||
self.api_client = GeminiApiClient(base_url)
|
self.api_client = GeminiApiClient(base_url)
|
||||||
self.key_manager = key_manager
|
self.key_manager = key_manager
|
||||||
@@ -100,20 +137,20 @@ class OpenAIChatService:
|
|||||||
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
) -> Union[Dict[str, Any], AsyncGenerator[str, None]]:
|
||||||
"""创建聊天完成"""
|
"""创建聊天完成"""
|
||||||
# 转换消息格式
|
# 转换消息格式
|
||||||
messages = self.message_converter.convert(request.messages)
|
messages, instruction = self.message_converter.convert(request.messages)
|
||||||
|
|
||||||
# 构建请求payload
|
# 构建请求payload
|
||||||
payload = _build_payload(request, messages)
|
payload = _build_payload(request, messages, instruction)
|
||||||
|
|
||||||
if request.stream:
|
if request.stream:
|
||||||
return self._handle_stream_completion(request.model, payload, api_key)
|
return self._handle_stream_completion(request.model, payload, api_key)
|
||||||
return self._handle_normal_completion(request.model, payload, api_key)
|
return await self._handle_normal_completion(request.model, payload, api_key)
|
||||||
|
|
||||||
def _handle_normal_completion(
|
async def _handle_normal_completion(
|
||||||
self, model: str, payload: Dict[str, Any], api_key: str
|
self, model: str, payload: Dict[str, Any], api_key: str
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""处理普通聊天完成"""
|
"""处理普通聊天完成"""
|
||||||
response = self.api_client.generate_content(payload, model, api_key)
|
response = await self.api_client.generate_content(payload, model, api_key)
|
||||||
return self.response_handler.handle_response(
|
return self.response_handler.handle_response(
|
||||||
response, model, stream=False, finish_reason="stop"
|
response, model, stream=False, finish_reason="stop"
|
||||||
)
|
)
|
||||||
|
|||||||
249
app/static/css/auth.css
Normal file
249
app/static/css/auth.css
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
animation: fadeIn 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo i {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #764ba2;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 24px;
|
||||||
|
animation: slideDown 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
position: relative;
|
||||||
|
animation: slideUp 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group i {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #764ba2;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 12px 12px 40px;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: #764ba2;
|
||||||
|
box-shadow: 0 0 10px rgba(118, 75, 162, 0.2);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
button::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: width 0.6s, height 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active::after {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #e74c3c;
|
||||||
|
margin-top: 15px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
animation: shake 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2c3e50;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a {
|
||||||
|
color: #764ba2;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a:hover {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { transform: translateY(-20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
25% { transform: translateX(-5px); }
|
||||||
|
75% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
width: 85%;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
.logo i {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 10px 10px 10px 35px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.input-group i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
width: 90%;
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
.logo i {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
padding: 10px 10px 10px 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.input-group i {
|
||||||
|
font-size: 15px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
461
app/static/css/keys_status.css
Normal file
461
app/static/css/keys_status.css
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
width: 95%;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 40px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
position: relative;
|
||||||
|
margin: 20px auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 32px;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 100px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
background: rgba(248, 249, 250, 0.9);
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
animation: fadeIn 0.5s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list h2 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 1.5em;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid rgba(0,0,0,0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list h2 .toggle-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list h2 .toggle-icon.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list .key-content {
|
||||||
|
transition: all 0.3s ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
height: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list .key-content.collapsed {
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(0,0,0,0.1);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-text {
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-count {
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
color: #e74c3c;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fail-count i {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn, .copy-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn {
|
||||||
|
background: linear-gradient(135deg, #2ecc71, #27ae60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(118, 75, 162, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-top: 30px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyStatus {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
padding: 15px 30px;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 200px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyStatus.success {
|
||||||
|
background: rgba(39, 174, 96, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#copyStatus.error {
|
||||||
|
background: rgba(231, 76, 60, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-valid {
|
||||||
|
background: rgba(39, 174, 96, 0.1);
|
||||||
|
color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-invalid {
|
||||||
|
background: rgba(231, 76, 60, 0.1);
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-buttons {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-btn:hover {
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 8px 20px rgba(118, 75, 162, 0.3);
|
||||||
|
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn i {
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-btn.loading i {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
padding: 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2c3e50;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
border-top: 1px solid rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a {
|
||||||
|
color: #764ba2;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright a:hover {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright img {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 10px auto;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.key-list h2 {
|
||||||
|
font-size: 1.2em;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.key-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.key-actions {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn, .copy-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.key-text {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.scroll-buttons {
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
}
|
||||||
|
.scroll-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.refresh-btn {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.key-list {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.status-badge {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.fail-count {
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
.total {
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
app/static/icons/icon-192x192.png
Normal file
BIN
app/static/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
18
app/static/js/auth.js
Normal file
18
app/static/js/auth.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/static/service-worker.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('ServiceWorker注册成功:', registration.scope);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log('ServiceWorker注册失败:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const copyrightYear = document.querySelector('.copyright script');
|
||||||
|
if (copyrightYear) {
|
||||||
|
copyrightYear.textContent = new Date().getFullYear();
|
||||||
|
}
|
||||||
|
});
|
||||||
175
app/static/js/keys_status.js
Normal file
175
app/static/js/keys_status.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
function copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
} else {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
if (successful) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error('复制失败'));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKeys(type) {
|
||||||
|
const keys = Array.from(document.querySelectorAll(`#${type}Keys .key-text`)).map(span => span.textContent.trim());
|
||||||
|
const jsonKeys = JSON.stringify(keys);
|
||||||
|
|
||||||
|
copyToClipboard(jsonKeys)
|
||||||
|
.then(() => {
|
||||||
|
showCopyStatus(`已成功复制${type === 'valid' ? '有效' : '无效'}密钥到剪贴板`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('无法复制文本: ', err);
|
||||||
|
showCopyStatus('复制失败,请重试');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyKey(key) {
|
||||||
|
copyToClipboard(key)
|
||||||
|
.then(() => {
|
||||||
|
showCopyStatus(`已成功复制密钥到剪贴板`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('无法复制文本: ', err);
|
||||||
|
showCopyStatus('复制失败,请重试');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopyStatus(message, type = 'success') {
|
||||||
|
const statusElement = document.getElementById('copyStatus');
|
||||||
|
statusElement.textContent = message;
|
||||||
|
statusElement.className = type; // 设置样式类
|
||||||
|
statusElement.style.opacity = 1;
|
||||||
|
setTimeout(() => {
|
||||||
|
statusElement.style.opacity = 0;
|
||||||
|
setTimeout(() => {
|
||||||
|
statusElement.className = ''; // 清除样式类
|
||||||
|
}, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyKey(key, button) {
|
||||||
|
try {
|
||||||
|
// 禁用按钮并显示加载状态
|
||||||
|
button.disabled = true;
|
||||||
|
const originalHtml = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 验证中';
|
||||||
|
|
||||||
|
const response = await fetch(`/gemini/v1beta/verify-key/${key}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 根据验证结果更新UI
|
||||||
|
if (data.status === 'valid') {
|
||||||
|
showCopyStatus('密钥验证成功', 'success');
|
||||||
|
button.style.backgroundColor = '#27ae60';
|
||||||
|
} else {
|
||||||
|
showCopyStatus('密钥验证失败', 'error');
|
||||||
|
button.style.backgroundColor = '#e74c3c';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3秒后恢复按钮原始状态
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
button.disabled = false;
|
||||||
|
button.style.backgroundColor = '';
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证失败:', error);
|
||||||
|
showCopyStatus('验证请求失败', 'error');
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '<i class="fas fa-check-circle"></i> 验证';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
container.scrollTo({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
container.scrollTo({
|
||||||
|
top: container.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScrollButtons() {
|
||||||
|
const container = document.querySelector('.container');
|
||||||
|
const scrollButtons = document.querySelector('.scroll-buttons');
|
||||||
|
if (container.scrollHeight > container.clientHeight) {
|
||||||
|
scrollButtons.style.display = 'flex';
|
||||||
|
} else {
|
||||||
|
scrollButtons.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPage(button) {
|
||||||
|
button.classList.add('loading');
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSection(header, sectionId) {
|
||||||
|
const toggleIcon = header.querySelector('.toggle-icon');
|
||||||
|
const content = header.nextElementSibling;
|
||||||
|
|
||||||
|
toggleIcon.classList.toggle('collapsed');
|
||||||
|
content.classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// 检查滚动按钮
|
||||||
|
updateScrollButtons();
|
||||||
|
|
||||||
|
// 监听展开/折叠事件
|
||||||
|
document.querySelectorAll('.key-list h2').forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
setTimeout(updateScrollButtons, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新版权年份
|
||||||
|
const copyrightYear = document.querySelector('.copyright script');
|
||||||
|
if (copyrightYear) {
|
||||||
|
copyrightYear.textContent = new Date().getFullYear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service Worker registration
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/static/service-worker.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('ServiceWorker注册成功:', registration.scope);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.log('ServiceWorker注册失败:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
17
app/static/manifest.json
Normal file
17
app/static/manifest.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Gemini Balance",
|
||||||
|
"short_name": "GBalance",
|
||||||
|
"description": "Gemini API密钥管理工具",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#667eea",
|
||||||
|
"theme_color": "#764ba2",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
app/static/service-worker.js
Normal file
43
app/static/service-worker.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const CACHE_NAME = 'gbalance-cache-v1';
|
||||||
|
const urlsToCache = [
|
||||||
|
'/',
|
||||||
|
'/static/manifest.json',
|
||||||
|
'/static/icons/icon-192x192.png'
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
console.log('Opened cache');
|
||||||
|
return cache.addAll(urlsToCache);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request)
|
||||||
|
.then(response => {
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return fetch(event.request);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
const cacheWhitelist = [CACHE_NAME];
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(cacheNames => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map(cacheName => {
|
||||||
|
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
42
app/templates/auth.html
Normal file
42
app/templates/auth.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>验证页面</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#764ba2">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GBalance">
|
||||||
|
<link rel="icon" href="/static/icons/icon-192x192.png">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/auth.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">
|
||||||
|
<i class="fas fa-shield-alt"></i>
|
||||||
|
</div>
|
||||||
|
<h2>安全验证</h2>
|
||||||
|
<form id="auth-form" action="/auth" method="post">
|
||||||
|
<div class="input-group">
|
||||||
|
<i class="fas fa-key"></i>
|
||||||
|
<input type="password" id="auth-token" name="auth_token" required placeholder="请输入验证令牌">
|
||||||
|
</div>
|
||||||
|
<button type="submit">
|
||||||
|
验证访问
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if error %}
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="copyright">
|
||||||
|
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
|
||||||
|
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
|
||||||
|
</div>
|
||||||
|
<script src="/static/js/auth.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
128
app/templates/keys_status.html
Normal file
128
app/templates/keys_status.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API密钥状态</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#764ba2">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="GBalance">
|
||||||
|
<link rel="icon" href="/static/icons/icon-192x192.png">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;700&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/keys_status.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<button class="refresh-btn" onclick="refreshPage(this)">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
<h1>API密钥状态</h1>
|
||||||
|
<div class="key-list">
|
||||||
|
<h2 onclick="toggleSection(this, 'validKeys')">
|
||||||
|
<span>
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
<i class="fas fa-check-circle" style="color: #27ae60;"></i>
|
||||||
|
有效密钥
|
||||||
|
</span>
|
||||||
|
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('valid')">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
批量复制
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div class="key-content">
|
||||||
|
<ul id="validKeys">
|
||||||
|
{% for key, fail_count in valid_keys.items() %}
|
||||||
|
<li>
|
||||||
|
<div class="key-info">
|
||||||
|
<span class="status-badge status-valid">
|
||||||
|
<i class="fas fa-check"></i> 有效
|
||||||
|
</span>
|
||||||
|
<span class="key-text">{{ key }}</span>
|
||||||
|
<span class="fail-count">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
失败: {{ fail_count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
验证
|
||||||
|
</button>
|
||||||
|
<button class="copy-btn" onclick="copyKey('{{ key }}')">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="key-list">
|
||||||
|
<h2 onclick="toggleSection(this, 'invalidKeys')">
|
||||||
|
<span>
|
||||||
|
<i class="fas fa-chevron-down toggle-icon"></i>
|
||||||
|
<i class="fas fa-times-circle" style="color: #e74c3c;"></i>
|
||||||
|
无效密钥
|
||||||
|
</span>
|
||||||
|
<button class="copy-btn" onclick="event.stopPropagation(); copyKeys('invalid')">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
批量复制
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div class="key-content">
|
||||||
|
<ul id="invalidKeys">
|
||||||
|
{% for key, fail_count in invalid_keys.items() %}
|
||||||
|
<li>
|
||||||
|
<div class="key-info">
|
||||||
|
<span class="status-badge status-invalid">
|
||||||
|
<i class="fas fa-times"></i> 无效
|
||||||
|
</span>
|
||||||
|
<span class="key-text">{{ key }}</span>
|
||||||
|
<span class="fail-count">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
失败: {{ fail_count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-actions">
|
||||||
|
<button class="verify-btn" onclick="verifyKey('{{ key }}', this)">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
验证
|
||||||
|
</button>
|
||||||
|
<button class="copy-btn" onclick="copyKey('{{ key }}')">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
复制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="total">
|
||||||
|
<i class="fas fa-key"></i> 总密钥数:{{ total }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll-buttons">
|
||||||
|
<button class="scroll-btn" onclick="scrollToTop()" title="回到顶部">
|
||||||
|
<i class="fas fa-chevron-up"></i>
|
||||||
|
</button>
|
||||||
|
<button class="scroll-btn" onclick="scrollToBottom()" title="滚动到底部">
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="copyStatus"></div>
|
||||||
|
|
||||||
|
<div class="copyright">
|
||||||
|
© <script>document.write(new Date().getFullYear())</script> by <a href="https://linux.do/u/snaily" target="_blank"><img src="https://linux.do/user_avatar/linux.do/snaily/288/306510_2.gif" alt="snaily">snaily</a> |
|
||||||
|
<a href="https://github.com/snailyp/gemini-balance" target="_blank"><i class="fab fa-github"></i> GitHub</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/keys_status.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
services:
|
||||||
|
gemini-balance:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
@@ -6,4 +6,6 @@ pydantic_settings
|
|||||||
requests
|
requests
|
||||||
starlette
|
starlette
|
||||||
uvicorn
|
uvicorn
|
||||||
google-genai
|
google-genai
|
||||||
|
jinja2
|
||||||
|
python-multipart
|
||||||
|
|||||||
Reference in New Issue
Block a user