Spaces:
Sleeping
Sleeping
Commit ·
dc03b36
0
Parent(s):
clean deploy
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +14 -0
- .env.example +48 -0
- .github/workflows/docker-publish.yml +61 -0
- .gitignore +30 -0
- .python-version +1 -0
- CHANGELOG.md +20 -0
- Dockerfile +56 -0
- LICENSE +21 -0
- README.md +336 -0
- VERSION +1 -0
- api/__init__.py +2 -0
- api/accounts.py +488 -0
- api/ai.py +141 -0
- api/app.py +63 -0
- api/errors.py +46 -0
- api/image_inputs.py +294 -0
- api/image_tasks.py +96 -0
- api/register.py +68 -0
- api/support.py +132 -0
- api/system.py +334 -0
- docker-compose.local.yml +34 -0
- docker-compose.yml +29 -0
- docs/feature-status.en.md +37 -0
- docs/review.md +9 -0
- docs/upstream-sse-conversation.md +271 -0
- main.py +9 -0
- pyproject.toml +27 -0
- scripts/migrate_storage.py +163 -0
- scripts/test_storage.py +129 -0
- scripts/verify_oauth_refresh.py +92 -0
- services/__init__.py +0 -0
- services/account_service.py +1031 -0
- services/auth_service.py +253 -0
- services/backup_service.py +673 -0
- services/config.py +446 -0
- services/content_filter.py +196 -0
- services/cpa_service.py +315 -0
- services/image_service.py +356 -0
- services/image_storage_service.py +405 -0
- services/image_tags_service.py +76 -0
- services/image_task_service.py +404 -0
- services/log_service.py +308 -0
- services/oauth_login_service.py +272 -0
- services/openai_backend_api.py +1264 -0
- services/protocol/__init__.py +2 -0
- services/protocol/anthropic_v1_messages.py +306 -0
- services/protocol/chat_completion_cache.py +236 -0
- services/protocol/conversation.py +835 -0
- services/protocol/error_response.py +124 -0
- services/protocol/openai_v1_chat_complete.py +231 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.idea
|
| 4 |
+
.venv
|
| 5 |
+
__pycache__
|
| 6 |
+
*.pyc
|
| 7 |
+
*.pyo
|
| 8 |
+
*.pyd
|
| 9 |
+
|
| 10 |
+
web/.next
|
| 11 |
+
web/node_modules
|
| 12 |
+
|
| 13 |
+
assets
|
| 14 |
+
README.md
|
.env.example
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================
|
| 2 |
+
# ChatGPT2API 环境变量配置示例
|
| 3 |
+
# ============================================
|
| 4 |
+
|
| 5 |
+
# 认证密钥(必填)
|
| 6 |
+
CHATGPT2API_AUTH_KEY=your_secret_key_here
|
| 7 |
+
|
| 8 |
+
# 基础 URL(可选,用于生成图片 URL)
|
| 9 |
+
# CHATGPT2API_BASE_URL=https://your-domain.com
|
| 10 |
+
|
| 11 |
+
# ============================================
|
| 12 |
+
# 存储后端配置
|
| 13 |
+
# ============================================
|
| 14 |
+
|
| 15 |
+
# 存储后端类型(可选值: json, sqlite, postgres, git)
|
| 16 |
+
# 默认: json
|
| 17 |
+
STORAGE_BACKEND=json
|
| 18 |
+
|
| 19 |
+
# ============================================
|
| 20 |
+
# 数据库配置(当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
| 21 |
+
# ============================================
|
| 22 |
+
|
| 23 |
+
# PostgreSQL 示例
|
| 24 |
+
# DATABASE_URL=postgresql://user:password@localhost:5432/chatgpt2api
|
| 25 |
+
|
| 26 |
+
# Supabase 示例
|
| 27 |
+
# DATABASE_URL=postgresql://postgres.xxx:password@aws-1-us-west-1.pooler.supabase.com:5432/postgres
|
| 28 |
+
|
| 29 |
+
# SQLite 示例(不指定时自动使用 data/accounts.db)
|
| 30 |
+
# DATABASE_URL=sqlite:///app/data/accounts.db
|
| 31 |
+
|
| 32 |
+
# ============================================
|
| 33 |
+
# Git 仓库配置(当 STORAGE_BACKEND=git 时使用)
|
| 34 |
+
# ============================================
|
| 35 |
+
|
| 36 |
+
# Git 仓库地址(必填)
|
| 37 |
+
# GIT_REPO_URL=https://github.com/your-username/your-private-repo.git
|
| 38 |
+
|
| 39 |
+
# Git 访问令牌(必填)
|
| 40 |
+
# GitHub: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
| 41 |
+
# GitLab: glpat-xxxxxxxxxxxxxxxxxxxx
|
| 42 |
+
# GIT_TOKEN=your_git_token_here
|
| 43 |
+
|
| 44 |
+
# Git 分支(可选,默认: main)
|
| 45 |
+
# GIT_BRANCH=main
|
| 46 |
+
|
| 47 |
+
# Git 仓库中的文件路径(可选,默认: accounts.json)
|
| 48 |
+
# GIT_FILE_PATH=accounts.json
|
.github/workflows/docker-publish.yml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish Docker Image
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
tags:
|
| 6 |
+
- "v*"
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
env:
|
| 10 |
+
IMAGE_NAME: chatgpt2api
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
docker:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
permissions:
|
| 16 |
+
contents: read
|
| 17 |
+
packages: write
|
| 18 |
+
|
| 19 |
+
steps:
|
| 20 |
+
- name: Checkout
|
| 21 |
+
uses: actions/checkout@v4
|
| 22 |
+
|
| 23 |
+
- name: Set up QEMU
|
| 24 |
+
uses: docker/setup-qemu-action@v3
|
| 25 |
+
with:
|
| 26 |
+
platforms: arm64
|
| 27 |
+
|
| 28 |
+
- name: Set up Docker Buildx
|
| 29 |
+
uses: docker/setup-buildx-action@v3
|
| 30 |
+
|
| 31 |
+
- name: Log in to GHCR
|
| 32 |
+
uses: docker/login-action@v3
|
| 33 |
+
with:
|
| 34 |
+
registry: ghcr.io
|
| 35 |
+
username: ${{ github.actor }}
|
| 36 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 37 |
+
|
| 38 |
+
- name: Extract Docker metadata
|
| 39 |
+
id: meta
|
| 40 |
+
uses: docker/metadata-action@v5
|
| 41 |
+
with:
|
| 42 |
+
images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
|
| 43 |
+
tags: |
|
| 44 |
+
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
| 45 |
+
type=ref,event=tag
|
| 46 |
+
type=sha
|
| 47 |
+
type=semver,pattern={{version}}
|
| 48 |
+
type=semver,pattern={{major}}.{{minor}}
|
| 49 |
+
|
| 50 |
+
- name: Build and push image
|
| 51 |
+
uses: docker/build-push-action@v6
|
| 52 |
+
with:
|
| 53 |
+
context: .
|
| 54 |
+
file: ./Dockerfile
|
| 55 |
+
target: app
|
| 56 |
+
platforms: linux/amd64,linux/arm64
|
| 57 |
+
push: true
|
| 58 |
+
tags: ${{ steps.meta.outputs.tags }}
|
| 59 |
+
labels: ${{ steps.meta.outputs.labels }}
|
| 60 |
+
cache-from: type=gha
|
| 61 |
+
cache-to: type=gha,mode=max
|
.gitignore
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
web_dist
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.idea
|
| 12 |
+
data
|
| 13 |
+
config.json
|
| 14 |
+
edit
|
| 15 |
+
|
| 16 |
+
docker-compose-local.yml
|
| 17 |
+
|
| 18 |
+
.claude/settings.local.json
|
| 19 |
+
|
| 20 |
+
# Environment variables
|
| 21 |
+
.env
|
| 22 |
+
.env.local
|
| 23 |
+
|
| 24 |
+
# Database files
|
| 25 |
+
*.db
|
| 26 |
+
*.sqlite
|
| 27 |
+
*.sqlite3
|
| 28 |
+
|
| 29 |
+
# Git cache
|
| 30 |
+
git_cache/
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.13
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## Unreleased
|
| 4 |
+
|
| 5 |
+
## 1.2.3 - 2026-05-29
|
| 6 |
+
|
| 7 |
+
+ [新增] 新增账号级代理。
|
| 8 |
+
+ [修复] 修复503异常信息、前端邮箱换行问题。
|
| 9 |
+
|
| 10 |
+
## 1.2.2 - 2026-05-29
|
| 11 |
+
|
| 12 |
+
+ [新增] 新增Codex链路生图、支持2k,4k。
|
| 13 |
+
+ [新增] 支持RT刷新账号信息。
|
| 14 |
+
|
| 15 |
+
## 1.2.0 - 2026-05-28
|
| 16 |
+
|
| 17 |
+
+ [新增] 当前版本基线,包含 Web 面板、画图、号池管理、注册机、图片管理、日志管理和设置能力。
|
| 18 |
+
+ [新增] 前端版本号支持点击查看版本更新弹窗,展示当前版本、最新版本和更新日志。
|
| 19 |
+
+ [优化] 优化注册机效率,成功率大幅提高。
|
| 20 |
+
+ [优化] 优化生图页面配置选项。
|
Dockerfile
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ARG BUILDPLATFORM
|
| 2 |
+
ARG TARGETPLATFORM
|
| 3 |
+
ARG TARGETARCH
|
| 4 |
+
|
| 5 |
+
FROM --platform=$BUILDPLATFORM node:22-alpine AS web-build
|
| 6 |
+
|
| 7 |
+
WORKDIR /app/web
|
| 8 |
+
|
| 9 |
+
COPY web/package.json web/bun.lock ./
|
| 10 |
+
RUN npm install
|
| 11 |
+
|
| 12 |
+
COPY VERSION /app/VERSION
|
| 13 |
+
COPY CHANGELOG.md /app/CHANGELOG.md
|
| 14 |
+
COPY web ./
|
| 15 |
+
RUN NEXT_PUBLIC_APP_VERSION="$(cat /app/VERSION)" npm run build
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
FROM --platform=$TARGETPLATFORM python:3.13-slim AS app
|
| 19 |
+
|
| 20 |
+
ARG TARGETPLATFORM
|
| 21 |
+
ARG TARGETARCH
|
| 22 |
+
|
| 23 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 24 |
+
PYTHONUNBUFFERED=1 \
|
| 25 |
+
UV_LINK_MODE=copy
|
| 26 |
+
|
| 27 |
+
WORKDIR /app
|
| 28 |
+
|
| 29 |
+
# 安装系统依赖
|
| 30 |
+
# - git: Git 存储后端需要
|
| 31 |
+
# - libpq-dev: PostgreSQL 客户端库
|
| 32 |
+
# - gcc: 编译 psycopg2-binary 需要
|
| 33 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 34 |
+
git \
|
| 35 |
+
libpq-dev \
|
| 36 |
+
gcc \
|
| 37 |
+
openssl \
|
| 38 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 39 |
+
|
| 40 |
+
RUN pip install --no-cache-dir uv
|
| 41 |
+
|
| 42 |
+
COPY pyproject.toml uv.lock ./
|
| 43 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 44 |
+
|
| 45 |
+
COPY main.py ./
|
| 46 |
+
COPY config.json ./
|
| 47 |
+
COPY VERSION ./
|
| 48 |
+
COPY api ./api
|
| 49 |
+
COPY services ./services
|
| 50 |
+
COPY utils ./utils
|
| 51 |
+
COPY scripts ./scripts
|
| 52 |
+
COPY --from=web-build /app/web/out ./web_dist
|
| 53 |
+
|
| 54 |
+
EXPOSE 80
|
| 55 |
+
|
| 56 |
+
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--access-log"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 kunkun
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<h1 align="center">ChatGPT2API</h1>
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
<p align="center">ChatGPT2API 主要是对 ChatGPT 官网相关能力进行逆向整理与封装,提供面向 ChatGPT 图片生成、图片编辑、多图组图编辑场景的 OpenAI 兼容图片 API / 代理,并集成在线画图、号池管理、多种账号导入方式与 Docker 自托管部署能力。</p>
|
| 5 |
+
|
| 6 |
+
> [!WARNING]
|
| 7 |
+
> 免责声明:
|
| 8 |
+
>
|
| 9 |
+
> 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。
|
| 10 |
+
>
|
| 11 |
+
> - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。
|
| 12 |
+
> - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。
|
| 13 |
+
> - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。
|
| 14 |
+
> - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。
|
| 15 |
+
> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。
|
| 16 |
+
> - 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。
|
| 17 |
+
|
| 18 |
+
## 快速开始
|
| 19 |
+
|
| 20 |
+
已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。
|
| 21 |
+
|
| 22 |
+
### Docker 运行
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
git clone git@github.com:basketikun/chatgpt2api.git
|
| 26 |
+
cd chatgpt2api
|
| 27 |
+
docker compose up -d
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。
|
| 31 |
+
|
| 32 |
+
- Web 面板:`http://localhost:3000`
|
| 33 |
+
- API 地址:`http://localhost:3000/v1`
|
| 34 |
+
- 数据目录:`./data`
|
| 35 |
+
|
| 36 |
+
### 本地开发
|
| 37 |
+
|
| 38 |
+
启动后端:
|
| 39 |
+
|
| 40 |
+
```bash
|
| 41 |
+
git clone git@github.com:basketikun/chatgpt2api.git
|
| 42 |
+
cd chatgpt2api
|
| 43 |
+
uv sync
|
| 44 |
+
uv run main.py
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
启动前端:
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
cd chatgpt2api/web
|
| 51 |
+
bun install
|
| 52 |
+
bun run dev
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
后续更新新版本:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
docker pull ghcr.io/basketikun/chatgpt2api:latest
|
| 59 |
+
docker-compose down
|
| 60 |
+
docker-compose up -d
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
### 存储后端配置
|
| 65 |
+
|
| 66 |
+
支持通过环境变量 `STORAGE_BACKEND` 切换存储方式:
|
| 67 |
+
|
| 68 |
+
- `json` - 本地 JSON 文件(默认)
|
| 69 |
+
- `sqlite` - 本地 SQLite 数据库
|
| 70 |
+
- `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`)
|
| 71 |
+
- `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`)
|
| 72 |
+
|
| 73 |
+
示例:使用 PostgreSQL
|
| 74 |
+
|
| 75 |
+
```yaml
|
| 76 |
+
environment:
|
| 77 |
+
- STORAGE_BACKEND=postgres
|
| 78 |
+
- DATABASE_URL=postgresql://user:password@host:5432/dbname
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
## 功能
|
| 82 |
+
|
| 83 |
+
### API 兼容能力
|
| 84 |
+
|
| 85 |
+
- 兼容 `POST /v1/images/generations` 图片生成接口
|
| 86 |
+
- 兼容 `POST /v1/images/edits` 图片编辑接口
|
| 87 |
+
- 兼容面向图片场景的 `POST /v1/chat/completions`
|
| 88 |
+
- 兼容面向图片场景的 `POST /v1/responses`
|
| 89 |
+
- `GET /v1/models` 返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、
|
| 90 |
+
`gpt-5-mini`
|
| 91 |
+
- 支持通过 `n` 返回多张生成结果
|
| 92 |
+
- 支持 Codex 中的画图接口逆向,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`,如有需要可自行在其他场景映射回
|
| 93 |
+
`gpt-image-2`,用于和官网画图区分;也就意味着同一账号会同时有官网和 Codex 两份生图额度
|
| 94 |
+
|
| 95 |
+
### 在线画图功能
|
| 96 |
+
|
| 97 |
+
- 内置在线画图工作台,支持生成、图片编辑与多图组图编辑
|
| 98 |
+
- 支持 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` 模型选择
|
| 99 |
+
- 编辑模式支持参考图上传
|
| 100 |
+
- 前端支持多图生成交互
|
| 101 |
+
- 本地保存图片会话历史,支持回看、删除和清空
|
| 102 |
+
- 支持服务端缓存图片URL
|
| 103 |
+
|
| 104 |
+
### 号池管理功能
|
| 105 |
+
|
| 106 |
+
- 自动刷新账号邮箱、类型、额度和恢复时间
|
| 107 |
+
- 轮询可用账号执行图片生成与图片编辑
|
| 108 |
+
- 遇到 Token 失效类错误时自动剔除无效 Token
|
| 109 |
+
- 定时检查限流账号并自动刷新
|
| 110 |
+
- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理
|
| 111 |
+
- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号
|
| 112 |
+
- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入
|
| 113 |
+
- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号
|
| 114 |
+
|
| 115 |
+
### 实验性 / 规划中
|
| 116 |
+
|
| 117 |
+
- `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用
|
| 118 |
+
- `/v1/chat/completions` 文本链路支持短 TTL 缓存、重复请求合并与相邻重复消息清理,可通过 `chat_completion_cache` 配置调整
|
| 119 |
+
- 详细状态说明见:[功能清单](./docs/feature-status.en.md)
|
| 120 |
+
|
| 121 |
+
## 效果展示
|
| 122 |
+
|
| 123 |
+
<table width="100%">
|
| 124 |
+
<tr>
|
| 125 |
+
<td width="50%"><img src="https://i.ibb.co/Jj8nfwwP/image.png" alt="image" border="0"></td>
|
| 126 |
+
<td width="50%"><img src="https://i.ibb.co/pqf235v/image-edit.png" alt="image edit" border="0"></td>
|
| 127 |
+
</tr>
|
| 128 |
+
<tr>
|
| 129 |
+
<td width="50%"><img src="https://i.ibb.co/tPcqtVfd/chery-studio.png" alt="chery studio" border="0"></td>
|
| 130 |
+
<td width="50%"><img src="https://i.ibb.co/PsT9YHBV/account-pool.png" alt="account pool" border="0"></td>
|
| 131 |
+
</tr>
|
| 132 |
+
<tr>
|
| 133 |
+
<td width="50%"><img src="https://i.ibb.co/rRWLG08q/new-api.png" alt="new api" border="0"></td>
|
| 134 |
+
</tr>
|
| 135 |
+
</table>
|
| 136 |
+
|
| 137 |
+
## API
|
| 138 |
+
|
| 139 |
+
所有 AI 接口都需要请求头:
|
| 140 |
+
|
| 141 |
+
```http
|
| 142 |
+
Authorization: Bearer <auth-key>
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
<details>
|
| 146 |
+
<summary><code>GET /v1/models</code></summary>
|
| 147 |
+
<br>
|
| 148 |
+
|
| 149 |
+
返回当前暴露的图片模型列表。
|
| 150 |
+
|
| 151 |
+
```bash
|
| 152 |
+
curl http://localhost:8000/v1/models \
|
| 153 |
+
-H "Authorization: Bearer <auth-key>"
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
<details>
|
| 157 |
+
<summary>说明</summary>
|
| 158 |
+
<br>
|
| 159 |
+
|
| 160 |
+
| 字段 | 说明 |
|
| 161 |
+
|:-----|:-----------------------------------------------------------------------------------------------------------|
|
| 162 |
+
| 返回模型 | `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini` |
|
| 163 |
+
| 接入场景 | 可接入 Cherry Studio、New API 等上游或客户端 |
|
| 164 |
+
|
| 165 |
+
<br>
|
| 166 |
+
</details>
|
| 167 |
+
</details>
|
| 168 |
+
|
| 169 |
+
<details>
|
| 170 |
+
<summary><code>POST /v1/images/generations</code></summary>
|
| 171 |
+
<br>
|
| 172 |
+
|
| 173 |
+
OpenAI 兼容图片生成接口,用于文生图。
|
| 174 |
+
|
| 175 |
+
```bash
|
| 176 |
+
curl http://localhost:8000/v1/images/generations \
|
| 177 |
+
-H "Content-Type: application/json" \
|
| 178 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 179 |
+
-d '{
|
| 180 |
+
"model": "gpt-image-2",
|
| 181 |
+
"prompt": "一只漂浮在太空里的猫",
|
| 182 |
+
"n": 1,
|
| 183 |
+
"response_format": "b64_json"
|
| 184 |
+
}'
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
<details>
|
| 188 |
+
<summary>字段说明</summary>
|
| 189 |
+
<br>
|
| 190 |
+
|
| 191 |
+
| 字段 | 说明 |
|
| 192 |
+
|:------------------|:---------------------------------------------------|
|
| 193 |
+
| `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` |
|
| 194 |
+
| `prompt` | 图片生成提示词 |
|
| 195 |
+
| `n` | 生成数量,当前后端限制为 `1-4` |
|
| 196 |
+
| `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` |
|
| 197 |
+
|
| 198 |
+
<br>
|
| 199 |
+
</details>
|
| 200 |
+
</details>
|
| 201 |
+
|
| 202 |
+
<details>
|
| 203 |
+
<summary><code>POST /v1/images/edits</code></summary>
|
| 204 |
+
<br>
|
| 205 |
+
|
| 206 |
+
OpenAI 兼容图片编辑接口,可上传图片文件,也可按官方 JSON 格式传入图片链接并生成编辑结果。
|
| 207 |
+
|
| 208 |
+
```bash
|
| 209 |
+
curl http://localhost:8000/v1/images/edits \
|
| 210 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 211 |
+
-F "model=gpt-image-2" \
|
| 212 |
+
-F "prompt=把这张图改成赛博朋克夜景风格" \
|
| 213 |
+
-F "n=1" \
|
| 214 |
+
-F "image=@./input.png"
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
也可以直接传图片 URL:
|
| 218 |
+
|
| 219 |
+
```bash
|
| 220 |
+
curl http://localhost:8000/v1/images/edits \
|
| 221 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 222 |
+
-H "Content-Type: application/json" \
|
| 223 |
+
-d '{
|
| 224 |
+
"model": "gpt-image-2",
|
| 225 |
+
"prompt": "把这张图改成赛博朋克夜景风格",
|
| 226 |
+
"images": [
|
| 227 |
+
{"image_url": "https://example.com/input.png"}
|
| 228 |
+
]
|
| 229 |
+
}'
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
<details>
|
| 233 |
+
<summary>字段说明</summary>
|
| 234 |
+
<br>
|
| 235 |
+
|
| 236 |
+
| 字段 | 说明 |
|
| 237 |
+
|:------------|:----------------------------------------------|
|
| 238 |
+
| `model` | 图片模型, `gpt-image-2` |
|
| 239 |
+
| `prompt` | 图片编辑提示词 |
|
| 240 |
+
| `n` | 生成数量,当前后端限制为 `1-4` |
|
| 241 |
+
| `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 |
|
| 242 |
+
| `images` | JSON 图片引用数组,支持 `{"image_url": "https://..."}` |
|
| 243 |
+
| `image_url` | 表单模式下也可直接传图片链接,支持重复字段传多张图 |
|
| 244 |
+
|
| 245 |
+
<br>
|
| 246 |
+
</details>
|
| 247 |
+
</details>
|
| 248 |
+
|
| 249 |
+
<details>
|
| 250 |
+
<summary><code>POST /v1/chat/completions</code></summary>
|
| 251 |
+
<br>
|
| 252 |
+
|
| 253 |
+
面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
curl http://localhost:8000/v1/chat/completions \
|
| 257 |
+
-H "Content-Type: application/json" \
|
| 258 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 259 |
+
-d '{
|
| 260 |
+
"model": "gpt-image-2",
|
| 261 |
+
"messages": [
|
| 262 |
+
{
|
| 263 |
+
"role": "user",
|
| 264 |
+
"content": "生成一张雨夜东京街头的赛博朋克猫"
|
| 265 |
+
}
|
| 266 |
+
],
|
| 267 |
+
"n": 1
|
| 268 |
+
}'
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
<details>
|
| 272 |
+
<summary>字段说明</summary>
|
| 273 |
+
<br>
|
| 274 |
+
|
| 275 |
+
| 字段 | 说明 |
|
| 276 |
+
|:-----------|:------------------|
|
| 277 |
+
| `model` | 图片模型,默认按图片生成场景处理 |
|
| 278 |
+
| `messages` | 消息数组,需要是图片相关请求内容 |
|
| 279 |
+
| `n` | 生成数量,按当前实现解析为图片数量 |
|
| 280 |
+
| `stream` | 已实现,但仍在测试 |
|
| 281 |
+
|
| 282 |
+
<br>
|
| 283 |
+
</details>
|
| 284 |
+
</details>
|
| 285 |
+
|
| 286 |
+
<details>
|
| 287 |
+
<summary><code>POST /v1/responses</code></summary>
|
| 288 |
+
<br>
|
| 289 |
+
|
| 290 |
+
面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。
|
| 291 |
+
|
| 292 |
+
```bash
|
| 293 |
+
curl http://localhost:8000/v1/responses \
|
| 294 |
+
-H "Content-Type: application/json" \
|
| 295 |
+
-H "Authorization: Bearer <auth-key>" \
|
| 296 |
+
-d '{
|
| 297 |
+
"model": "gpt-5",
|
| 298 |
+
"input": "生成一张未来感城市天际线图片",
|
| 299 |
+
"tools": [
|
| 300 |
+
{
|
| 301 |
+
"type": "image_generation"
|
| 302 |
+
}
|
| 303 |
+
]
|
| 304 |
+
}'
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
<details>
|
| 308 |
+
<summary>字段说明</summary>
|
| 309 |
+
<br>
|
| 310 |
+
|
| 311 |
+
| 字段 | 说明 |
|
| 312 |
+
|:---------|:------------------------------|
|
| 313 |
+
| `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 |
|
| 314 |
+
| `input` | 输入内容,需要能解析出图片生成提示词 |
|
| 315 |
+
| `tools` | 必须包含 `image_generation` 工具请求 |
|
| 316 |
+
| `stream` | 已实现,但仍在测试 |
|
| 317 |
+
|
| 318 |
+
<br>
|
| 319 |
+
</details>
|
| 320 |
+
</details>
|
| 321 |
+
|
| 322 |
+
## 社区支持
|
| 323 |
+
|
| 324 |
+
学 AI , 上 L 站:[LinuxDO](https://linux.do)
|
| 325 |
+
|
| 326 |
+
## Contributors
|
| 327 |
+
|
| 328 |
+
感谢所有为本项目做出贡献的开发者:
|
| 329 |
+
|
| 330 |
+
<a href="https://github.com/basketikun/chatgpt2api/graphs/contributors">
|
| 331 |
+
<img alt="Contributors" src="https://contrib.rocks/image?repo=basketikun/chatgpt2api" />
|
| 332 |
+
</a>
|
| 333 |
+
|
| 334 |
+
## Star History
|
| 335 |
+
|
| 336 |
+
[](https://www.star-history.com/?repos=basketikun%2Fchatgpt2api&type=date&legend=top-left)
|
VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
1.2.3
|
api/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from api.app import create_app
|
| 2 |
+
|
api/accounts.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import zipfile
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Any, Literal
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Header, HTTPException
|
| 11 |
+
from fastapi.concurrency import run_in_threadpool
|
| 12 |
+
from fastapi.responses import Response
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
from services.auth_service import auth_service
|
| 16 |
+
|
| 17 |
+
from api.support import (
|
| 18 |
+
require_admin,
|
| 19 |
+
sanitize_cpa_pool,
|
| 20 |
+
sanitize_cpa_pools,
|
| 21 |
+
sanitize_sub2api_server,
|
| 22 |
+
sanitize_sub2api_servers,
|
| 23 |
+
)
|
| 24 |
+
from services.account_service import account_service
|
| 25 |
+
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
| 26 |
+
from services.oauth_login_service import OAuthLoginError, oauth_login_service
|
| 27 |
+
from services.sub2api_service import (
|
| 28 |
+
list_remote_accounts as sub2api_list_remote_accounts,
|
| 29 |
+
list_remote_groups as sub2api_list_remote_groups,
|
| 30 |
+
sub2api_config,
|
| 31 |
+
sub2api_import_service,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class UserKeyCreateRequest(BaseModel):
|
| 37 |
+
name: str = ""
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class UserKeyUpdateRequest(BaseModel):
|
| 41 |
+
name: str | None = None
|
| 42 |
+
enabled: bool | None = None
|
| 43 |
+
key: str | None = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class AccountCreateRequest(BaseModel):
|
| 47 |
+
tokens: list[str] = Field(default_factory=list)
|
| 48 |
+
accounts: list[dict[str, Any]] = Field(default_factory=list)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AccountDeleteRequest(BaseModel):
|
| 52 |
+
tokens: list[str] = Field(default_factory=list)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class AccountRefreshRequest(BaseModel):
|
| 56 |
+
access_tokens: list[str] = Field(default_factory=list)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class AccountExportRequest(BaseModel):
|
| 60 |
+
access_tokens: list[str] = Field(default_factory=list)
|
| 61 |
+
format: Literal["json", "zip"] = "json"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class AccountUpdateRequest(BaseModel):
|
| 65 |
+
access_token: str = ""
|
| 66 |
+
type: str | None = None
|
| 67 |
+
status: str | None = None
|
| 68 |
+
quota: int | None = None
|
| 69 |
+
proxy: str | None = None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class CPAPoolCreateRequest(BaseModel):
|
| 73 |
+
name: str = ""
|
| 74 |
+
base_url: str = ""
|
| 75 |
+
secret_key: str = ""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class CPAPoolUpdateRequest(BaseModel):
|
| 79 |
+
name: str | None = None
|
| 80 |
+
base_url: str | None = None
|
| 81 |
+
secret_key: str | None = None
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class CPAImportRequest(BaseModel):
|
| 85 |
+
names: list[str] = Field(default_factory=list)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class Sub2APIServerCreateRequest(BaseModel):
|
| 89 |
+
name: str = ""
|
| 90 |
+
base_url: str = ""
|
| 91 |
+
email: str = ""
|
| 92 |
+
password: str = ""
|
| 93 |
+
api_key: str = ""
|
| 94 |
+
group_id: str = ""
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class Sub2APIServerUpdateRequest(BaseModel):
|
| 98 |
+
name: str | None = None
|
| 99 |
+
base_url: str | None = None
|
| 100 |
+
email: str | None = None
|
| 101 |
+
password: str | None = None
|
| 102 |
+
api_key: str | None = None
|
| 103 |
+
group_id: str | None = None
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class Sub2APIImportRequest(BaseModel):
|
| 107 |
+
account_ids: list[str] = Field(default_factory=list)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class OAuthLoginStartRequest(BaseModel):
|
| 111 |
+
"""起始 OAuth 桥。email_hint 可选,仅用于让 OpenAI 登录页预填邮箱。"""
|
| 112 |
+
email_hint: str = ""
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class OAuthLoginFinishRequest(BaseModel):
|
| 116 |
+
"""提交 callback。callback 既可以是完整 URL 也可以只填 code。"""
|
| 117 |
+
session_id: str = ""
|
| 118 |
+
callback: str = ""
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _account_payload_token(item: dict[str, Any]) -> str:
|
| 122 |
+
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _unique_tokens(tokens: list[str]) -> list[str]:
|
| 126 |
+
return list(dict.fromkeys(str(token or "").strip() for token in tokens if str(token or "").strip()))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _download_timestamp() -> str:
|
| 130 |
+
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _safe_export_name(value: str, fallback: str) -> str:
|
| 134 |
+
clean = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
| 135 |
+
return (clean or fallback)[:80]
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _account_zip_bytes(items: list[dict[str, str]]) -> bytes:
|
| 139 |
+
buf = io.BytesIO()
|
| 140 |
+
used_names: set[str] = set()
|
| 141 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
| 142 |
+
for index, item in enumerate(items, start=1):
|
| 143 |
+
raw_name = item.get("email") or item.get("account_id") or f"account-{index:03d}"
|
| 144 |
+
base_name = _safe_export_name(raw_name, f"account-{index:03d}")
|
| 145 |
+
name = base_name
|
| 146 |
+
suffix = 2
|
| 147 |
+
while name in used_names:
|
| 148 |
+
name = f"{base_name}-{suffix}"
|
| 149 |
+
suffix += 1
|
| 150 |
+
used_names.add(name)
|
| 151 |
+
archive.writestr(
|
| 152 |
+
f"{name}.json",
|
| 153 |
+
json.dumps(item, ensure_ascii=False, indent=2) + "\n",
|
| 154 |
+
)
|
| 155 |
+
return buf.getvalue()
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def create_router() -> APIRouter:
|
| 159 |
+
router = APIRouter()
|
| 160 |
+
|
| 161 |
+
@router.get("/api/auth/users")
|
| 162 |
+
async def list_user_keys(authorization: str | None = Header(default=None)):
|
| 163 |
+
require_admin(authorization)
|
| 164 |
+
return {"items": auth_service.list_keys(role="user")}
|
| 165 |
+
|
| 166 |
+
@router.post("/api/auth/users")
|
| 167 |
+
async def create_user_key(body: UserKeyCreateRequest, authorization: str | None = Header(default=None)):
|
| 168 |
+
require_admin(authorization)
|
| 169 |
+
try:
|
| 170 |
+
item, raw_key = auth_service.create_key(role="user", name=body.name)
|
| 171 |
+
except ValueError as exc:
|
| 172 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 173 |
+
return {"item": item, "key": raw_key, "items": auth_service.list_keys(role="user")}
|
| 174 |
+
|
| 175 |
+
@router.post("/api/auth/users/{key_id}")
|
| 176 |
+
async def update_user_key(
|
| 177 |
+
key_id: str,
|
| 178 |
+
body: UserKeyUpdateRequest,
|
| 179 |
+
authorization: str | None = Header(default=None),
|
| 180 |
+
):
|
| 181 |
+
require_admin(authorization)
|
| 182 |
+
updates = {
|
| 183 |
+
key: value
|
| 184 |
+
for key, value in {
|
| 185 |
+
"name": body.name,
|
| 186 |
+
"enabled": body.enabled,
|
| 187 |
+
"key": body.key,
|
| 188 |
+
}.items()
|
| 189 |
+
if value is not None
|
| 190 |
+
}
|
| 191 |
+
if not updates:
|
| 192 |
+
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
| 193 |
+
try:
|
| 194 |
+
item = auth_service.update_key(key_id, updates, role="user")
|
| 195 |
+
except ValueError as exc:
|
| 196 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 197 |
+
if item is None:
|
| 198 |
+
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
| 199 |
+
return {"item": item, "items": auth_service.list_keys(role="user")}
|
| 200 |
+
|
| 201 |
+
@router.delete("/api/auth/users/{key_id}")
|
| 202 |
+
async def delete_user_key(key_id: str, authorization: str | None = Header(default=None)):
|
| 203 |
+
require_admin(authorization)
|
| 204 |
+
if not auth_service.delete_key(key_id, role="user"):
|
| 205 |
+
raise HTTPException(status_code=404, detail={"error": "这条用户密钥不存在,可能已经被删除"})
|
| 206 |
+
return {"items": auth_service.list_keys(role="user")}
|
| 207 |
+
|
| 208 |
+
@router.get("/api/accounts")
|
| 209 |
+
async def get_accounts(authorization: str | None = Header(default=None)):
|
| 210 |
+
require_admin(authorization)
|
| 211 |
+
return {"items": account_service.list_accounts()}
|
| 212 |
+
|
| 213 |
+
@router.post("/api/accounts")
|
| 214 |
+
async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
|
| 215 |
+
require_admin(authorization)
|
| 216 |
+
account_payloads = [item for item in body.accounts if isinstance(item, dict)]
|
| 217 |
+
payload_tokens = [_account_payload_token(item) for item in account_payloads]
|
| 218 |
+
tokens = _unique_tokens([*body.tokens, *payload_tokens])
|
| 219 |
+
if not tokens:
|
| 220 |
+
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 221 |
+
if account_payloads:
|
| 222 |
+
result = account_service.add_account_items(account_payloads)
|
| 223 |
+
payload_token_set = set(_unique_tokens(payload_tokens))
|
| 224 |
+
extra_tokens = [token for token in tokens if token not in payload_token_set]
|
| 225 |
+
if extra_tokens:
|
| 226 |
+
extra_result = account_service.add_accounts(extra_tokens)
|
| 227 |
+
result["added"] = int(result.get("added") or 0) + int(extra_result.get("added") or 0)
|
| 228 |
+
result["skipped"] = int(result.get("skipped") or 0) + int(extra_result.get("skipped") or 0)
|
| 229 |
+
else:
|
| 230 |
+
result = account_service.add_accounts(tokens)
|
| 231 |
+
refresh_result = account_service.refresh_accounts(tokens)
|
| 232 |
+
return {
|
| 233 |
+
**result,
|
| 234 |
+
"refreshed": refresh_result.get("refreshed", 0),
|
| 235 |
+
"errors": refresh_result.get("errors", []),
|
| 236 |
+
"items": refresh_result.get("items", result.get("items", [])),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
@router.delete("/api/accounts")
|
| 240 |
+
async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
|
| 241 |
+
require_admin(authorization)
|
| 242 |
+
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 243 |
+
if not tokens:
|
| 244 |
+
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 245 |
+
return account_service.delete_accounts(tokens)
|
| 246 |
+
|
| 247 |
+
@router.post("/api/accounts/refresh")
|
| 248 |
+
async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
|
| 249 |
+
require_admin(authorization)
|
| 250 |
+
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
| 251 |
+
if not access_tokens:
|
| 252 |
+
access_tokens = account_service.list_tokens()
|
| 253 |
+
if not access_tokens:
|
| 254 |
+
raise HTTPException(status_code=400, detail={"error": "access_tokens is required"})
|
| 255 |
+
return account_service.refresh_accounts(access_tokens)
|
| 256 |
+
|
| 257 |
+
@router.post("/api/accounts/export")
|
| 258 |
+
async def export_accounts(body: AccountExportRequest, authorization: str | None = Header(default=None)):
|
| 259 |
+
require_admin(authorization)
|
| 260 |
+
access_tokens = _unique_tokens(body.access_tokens)
|
| 261 |
+
items = account_service.build_export_items(access_tokens)
|
| 262 |
+
if not items:
|
| 263 |
+
raise HTTPException(
|
| 264 |
+
status_code=400,
|
| 265 |
+
detail={"error": "没有可导出的完整账号,需要同时有 access_token、refresh_token 和 id_token"},
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
timestamp = _download_timestamp()
|
| 269 |
+
if body.format == "zip":
|
| 270 |
+
content = _account_zip_bytes(items)
|
| 271 |
+
return Response(
|
| 272 |
+
content,
|
| 273 |
+
media_type="application/zip",
|
| 274 |
+
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.zip"'},
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
payload: dict[str, str] | list[dict[str, str]] = items[0] if len(items) == 1 else items
|
| 278 |
+
return Response(
|
| 279 |
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
| 280 |
+
media_type="application/json",
|
| 281 |
+
headers={"Content-Disposition": f'attachment; filename="codex-accounts-{timestamp}.json"'},
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
@router.post("/api/accounts/update")
|
| 285 |
+
async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
|
| 286 |
+
require_admin(authorization)
|
| 287 |
+
access_token = str(body.access_token or "").strip()
|
| 288 |
+
if not access_token:
|
| 289 |
+
raise HTTPException(status_code=400, detail={"error": "access_token is required"})
|
| 290 |
+
updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota, "proxy": body.proxy}.items() if value is not None}
|
| 291 |
+
if not updates:
|
| 292 |
+
raise HTTPException(status_code=400, detail={"error": "还没有检测到改动,请修改后再保存"})
|
| 293 |
+
account = account_service.update_account(access_token, updates)
|
| 294 |
+
if account is None:
|
| 295 |
+
raise HTTPException(status_code=404, detail={"error": "account not found"})
|
| 296 |
+
return {"item": account, "items": account_service.list_accounts()}
|
| 297 |
+
|
| 298 |
+
@router.post("/api/accounts/oauth/start")
|
| 299 |
+
async def start_oauth_login(
|
| 300 |
+
body: OAuthLoginStartRequest,
|
| 301 |
+
authorization: str | None = Header(default=None),
|
| 302 |
+
):
|
| 303 |
+
"""登记一次 PKCE 会话,返回可让用户浏览器打开的 authorize URL。"""
|
| 304 |
+
require_admin(authorization)
|
| 305 |
+
try:
|
| 306 |
+
return await run_in_threadpool(oauth_login_service.start, body.email_hint)
|
| 307 |
+
except OAuthLoginError as exc:
|
| 308 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 309 |
+
|
| 310 |
+
@router.post("/api/accounts/oauth/finish")
|
| 311 |
+
async def finish_oauth_login(
|
| 312 |
+
body: OAuthLoginFinishRequest,
|
| 313 |
+
authorization: str | None = Header(default=None),
|
| 314 |
+
):
|
| 315 |
+
"""收用户从浏览器抓回的 callback URL / code,换出 token 三件套并落盘。"""
|
| 316 |
+
require_admin(authorization)
|
| 317 |
+
# 入参日志:截断敏感字段,仅保留前几位,方便排错而不泄密
|
| 318 |
+
cb_preview = (body.callback or "")[:80]
|
| 319 |
+
sid_preview = (body.session_id or "")[:8]
|
| 320 |
+
print(
|
| 321 |
+
f"[oauth-login] finish called: session_id={sid_preview}..., callback_preview={cb_preview!r}",
|
| 322 |
+
flush=True,
|
| 323 |
+
)
|
| 324 |
+
try:
|
| 325 |
+
tokens = await run_in_threadpool(oauth_login_service.finish, body.session_id, body.callback)
|
| 326 |
+
except OAuthLoginError as exc:
|
| 327 |
+
print(f"[oauth-login] finish rejected: {exc}", flush=True)
|
| 328 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 329 |
+
|
| 330 |
+
payload = {
|
| 331 |
+
"access_token": tokens["access_token"],
|
| 332 |
+
"refresh_token": tokens["refresh_token"],
|
| 333 |
+
"id_token": tokens["id_token"],
|
| 334 |
+
"source_type": "oauth_login",
|
| 335 |
+
}
|
| 336 |
+
add_result = await run_in_threadpool(account_service.add_account_items, [payload])
|
| 337 |
+
refresh_result = await run_in_threadpool(
|
| 338 |
+
account_service.refresh_accounts, [tokens["access_token"]]
|
| 339 |
+
)
|
| 340 |
+
return {
|
| 341 |
+
**add_result,
|
| 342 |
+
"refreshed": refresh_result.get("refreshed", 0),
|
| 343 |
+
"errors": refresh_result.get("errors", []),
|
| 344 |
+
"items": refresh_result.get("items", add_result.get("items", [])),
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
@router.get("/api/cpa/pools")
|
| 348 |
+
async def list_cpa_pools(authorization: str | None = Header(default=None)):
|
| 349 |
+
require_admin(authorization)
|
| 350 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 351 |
+
|
| 352 |
+
@router.post("/api/cpa/pools")
|
| 353 |
+
async def create_cpa_pool(body: CPAPoolCreateRequest, authorization: str | None = Header(default=None)):
|
| 354 |
+
require_admin(authorization)
|
| 355 |
+
if not body.base_url.strip():
|
| 356 |
+
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
| 357 |
+
if not body.secret_key.strip():
|
| 358 |
+
raise HTTPException(status_code=400, detail={"error": "secret_key is required"})
|
| 359 |
+
pool = cpa_config.add_pool(name=body.name, base_url=body.base_url, secret_key=body.secret_key)
|
| 360 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 361 |
+
|
| 362 |
+
@router.post("/api/cpa/pools/{pool_id}")
|
| 363 |
+
async def update_cpa_pool(pool_id: str, body: CPAPoolUpdateRequest, authorization: str | None = Header(default=None)):
|
| 364 |
+
require_admin(authorization)
|
| 365 |
+
pool = cpa_config.update_pool(pool_id, body.model_dump(exclude_none=True))
|
| 366 |
+
if pool is None:
|
| 367 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 368 |
+
return {"pool": sanitize_cpa_pool(pool), "pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 369 |
+
|
| 370 |
+
@router.delete("/api/cpa/pools/{pool_id}")
|
| 371 |
+
async def delete_cpa_pool(pool_id: str, authorization: str | None = Header(default=None)):
|
| 372 |
+
require_admin(authorization)
|
| 373 |
+
if not cpa_config.delete_pool(pool_id):
|
| 374 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 375 |
+
return {"pools": sanitize_cpa_pools(cpa_config.list_pools())}
|
| 376 |
+
|
| 377 |
+
@router.get("/api/cpa/pools/{pool_id}/files")
|
| 378 |
+
async def cpa_pool_files(pool_id: str, authorization: str | None = Header(default=None)):
|
| 379 |
+
require_admin(authorization)
|
| 380 |
+
pool = cpa_config.get_pool(pool_id)
|
| 381 |
+
if pool is None:
|
| 382 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 383 |
+
return {"pool_id": pool_id, "files": await run_in_threadpool(list_remote_files, pool)}
|
| 384 |
+
|
| 385 |
+
@router.post("/api/cpa/pools/{pool_id}/import")
|
| 386 |
+
async def cpa_pool_import(pool_id: str, body: CPAImportRequest, authorization: str | None = Header(default=None)):
|
| 387 |
+
require_admin(authorization)
|
| 388 |
+
pool = cpa_config.get_pool(pool_id)
|
| 389 |
+
if pool is None:
|
| 390 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 391 |
+
try:
|
| 392 |
+
job = cpa_import_service.start_import(pool, body.names)
|
| 393 |
+
except ValueError as exc:
|
| 394 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 395 |
+
return {"import_job": job}
|
| 396 |
+
|
| 397 |
+
@router.get("/api/cpa/pools/{pool_id}/import")
|
| 398 |
+
async def cpa_pool_import_progress(pool_id: str, authorization: str | None = Header(default=None)):
|
| 399 |
+
require_admin(authorization)
|
| 400 |
+
pool = cpa_config.get_pool(pool_id)
|
| 401 |
+
if pool is None:
|
| 402 |
+
raise HTTPException(status_code=404, detail={"error": "pool not found"})
|
| 403 |
+
return {"import_job": pool.get("import_job")}
|
| 404 |
+
|
| 405 |
+
@router.get("/api/sub2api/servers")
|
| 406 |
+
async def list_sub2api_servers(authorization: str | None = Header(default=None)):
|
| 407 |
+
require_admin(authorization)
|
| 408 |
+
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 409 |
+
|
| 410 |
+
@router.post("/api/sub2api/servers")
|
| 411 |
+
async def create_sub2api_server(body: Sub2APIServerCreateRequest, authorization: str | None = Header(default=None)):
|
| 412 |
+
require_admin(authorization)
|
| 413 |
+
if not body.base_url.strip():
|
| 414 |
+
raise HTTPException(status_code=400, detail={"error": "base_url is required"})
|
| 415 |
+
has_login = body.email.strip() and body.password.strip()
|
| 416 |
+
has_api_key = bool(body.api_key.strip())
|
| 417 |
+
if not has_login and not has_api_key:
|
| 418 |
+
raise HTTPException(status_code=400, detail={"error": "email+password or api_key is required"})
|
| 419 |
+
server = sub2api_config.add_server(
|
| 420 |
+
name=body.name,
|
| 421 |
+
base_url=body.base_url,
|
| 422 |
+
email=body.email,
|
| 423 |
+
password=body.password,
|
| 424 |
+
api_key=body.api_key,
|
| 425 |
+
group_id=body.group_id,
|
| 426 |
+
)
|
| 427 |
+
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 428 |
+
|
| 429 |
+
@router.post("/api/sub2api/servers/{server_id}")
|
| 430 |
+
async def update_sub2api_server(server_id: str, body: Sub2APIServerUpdateRequest, authorization: str | None = Header(default=None)):
|
| 431 |
+
require_admin(authorization)
|
| 432 |
+
server = sub2api_config.update_server(server_id, body.model_dump(exclude_none=True))
|
| 433 |
+
if server is None:
|
| 434 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 435 |
+
return {"server": sanitize_sub2api_server(server), "servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 436 |
+
|
| 437 |
+
@router.delete("/api/sub2api/servers/{server_id}")
|
| 438 |
+
async def delete_sub2api_server(server_id: str, authorization: str | None = Header(default=None)):
|
| 439 |
+
require_admin(authorization)
|
| 440 |
+
if not sub2api_config.delete_server(server_id):
|
| 441 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 442 |
+
return {"servers": sanitize_sub2api_servers(sub2api_config.list_servers())}
|
| 443 |
+
|
| 444 |
+
@router.get("/api/sub2api/servers/{server_id}/groups")
|
| 445 |
+
async def sub2api_server_groups(server_id: str, authorization: str | None = Header(default=None)):
|
| 446 |
+
require_admin(authorization)
|
| 447 |
+
server = sub2api_config.get_server(server_id)
|
| 448 |
+
if server is None:
|
| 449 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 450 |
+
try:
|
| 451 |
+
groups = await run_in_threadpool(sub2api_list_remote_groups, server)
|
| 452 |
+
except Exception as exc:
|
| 453 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 454 |
+
return {"server_id": server_id, "groups": groups}
|
| 455 |
+
|
| 456 |
+
@router.get("/api/sub2api/servers/{server_id}/accounts")
|
| 457 |
+
async def sub2api_server_accounts(server_id: str, authorization: str | None = Header(default=None)):
|
| 458 |
+
require_admin(authorization)
|
| 459 |
+
server = sub2api_config.get_server(server_id)
|
| 460 |
+
if server is None:
|
| 461 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 462 |
+
try:
|
| 463 |
+
accounts = await run_in_threadpool(sub2api_list_remote_accounts, server)
|
| 464 |
+
except Exception as exc:
|
| 465 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 466 |
+
return {"server_id": server_id, "accounts": accounts}
|
| 467 |
+
|
| 468 |
+
@router.post("/api/sub2api/servers/{server_id}/import")
|
| 469 |
+
async def sub2api_server_import(server_id: str, body: Sub2APIImportRequest, authorization: str | None = Header(default=None)):
|
| 470 |
+
require_admin(authorization)
|
| 471 |
+
server = sub2api_config.get_server(server_id)
|
| 472 |
+
if server is None:
|
| 473 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 474 |
+
try:
|
| 475 |
+
job = sub2api_import_service.start_import(server, body.account_ids)
|
| 476 |
+
except ValueError as exc:
|
| 477 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 478 |
+
return {"import_job": job}
|
| 479 |
+
|
| 480 |
+
@router.get("/api/sub2api/servers/{server_id}/import")
|
| 481 |
+
async def sub2api_server_import_progress(server_id: str, authorization: str | None = Header(default=None)):
|
| 482 |
+
require_admin(authorization)
|
| 483 |
+
server = sub2api_config.get_server(server_id)
|
| 484 |
+
if server is None:
|
| 485 |
+
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 486 |
+
return {"import_job": server.get("import_job")}
|
| 487 |
+
|
| 488 |
+
return router
|
api/ai.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Header, HTTPException, Request
|
| 4 |
+
from fastapi.concurrency import run_in_threadpool
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
from api.image_inputs import parse_image_edit_request, read_image_sources
|
| 8 |
+
from api.support import require_identity, resolve_image_base_url
|
| 9 |
+
from services.content_filter import check_request, request_text
|
| 10 |
+
from services.log_service import LoggedCall
|
| 11 |
+
from services.protocol import (
|
| 12 |
+
anthropic_v1_messages,
|
| 13 |
+
openai_v1_chat_complete,
|
| 14 |
+
openai_v1_image_edit,
|
| 15 |
+
openai_v1_image_generations,
|
| 16 |
+
openai_v1_models,
|
| 17 |
+
openai_v1_response,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ImageGenerationRequest(BaseModel):
|
| 22 |
+
prompt: str = Field(..., min_length=1)
|
| 23 |
+
model: str = "gpt-image-2"
|
| 24 |
+
n: int = Field(default=1, ge=1, le=4)
|
| 25 |
+
size: str | None = None
|
| 26 |
+
quality: str = "auto"
|
| 27 |
+
response_format: str = "b64_json"
|
| 28 |
+
history_disabled: bool = True
|
| 29 |
+
stream: bool | None = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ChatCompletionRequest(BaseModel):
|
| 33 |
+
model_config = ConfigDict(extra="allow")
|
| 34 |
+
model: str | None = None
|
| 35 |
+
prompt: str | None = None
|
| 36 |
+
n: int | None = None
|
| 37 |
+
stream: bool | None = None
|
| 38 |
+
modalities: list[str] | None = None
|
| 39 |
+
messages: list[dict[str, object]] | None = None
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class ResponseCreateRequest(BaseModel):
|
| 43 |
+
model_config = ConfigDict(extra="allow")
|
| 44 |
+
model: str | None = None
|
| 45 |
+
input: object | None = None
|
| 46 |
+
tools: list[dict[str, object]] | None = None
|
| 47 |
+
tool_choice: object | None = None
|
| 48 |
+
stream: bool | None = None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AnthropicMessageRequest(BaseModel):
|
| 52 |
+
model_config = ConfigDict(extra="allow")
|
| 53 |
+
model: str | None = None
|
| 54 |
+
messages: list[dict[str, object]] | None = None
|
| 55 |
+
system: object | None = None
|
| 56 |
+
stream: bool | None = None
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
| 60 |
+
try:
|
| 61 |
+
await run_in_threadpool(check_request, text)
|
| 62 |
+
except HTTPException as exc:
|
| 63 |
+
call.log("调用失败", status="failed", error=str(exc.detail))
|
| 64 |
+
raise
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def create_router() -> APIRouter:
|
| 68 |
+
router = APIRouter()
|
| 69 |
+
|
| 70 |
+
@router.get("/v1/models")
|
| 71 |
+
async def list_models(authorization: str | None = Header(default=None)):
|
| 72 |
+
require_identity(authorization)
|
| 73 |
+
try:
|
| 74 |
+
return await run_in_threadpool(openai_v1_models.list_models)
|
| 75 |
+
except Exception as exc:
|
| 76 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 77 |
+
|
| 78 |
+
@router.post("/v1/images/generations")
|
| 79 |
+
async def generate_images(
|
| 80 |
+
body: ImageGenerationRequest,
|
| 81 |
+
request: Request,
|
| 82 |
+
authorization: str | None = Header(default=None),
|
| 83 |
+
):
|
| 84 |
+
identity = require_identity(authorization)
|
| 85 |
+
payload = body.model_dump(mode="python")
|
| 86 |
+
payload["base_url"] = resolve_image_base_url(request)
|
| 87 |
+
call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图", request_text=body.prompt)
|
| 88 |
+
await filter_or_log(call, body.prompt)
|
| 89 |
+
return await call.run(openai_v1_image_generations.handle, payload)
|
| 90 |
+
|
| 91 |
+
@router.post("/v1/images/edits")
|
| 92 |
+
async def edit_images(
|
| 93 |
+
request: Request,
|
| 94 |
+
authorization: str | None = Header(default=None),
|
| 95 |
+
):
|
| 96 |
+
identity = require_identity(authorization)
|
| 97 |
+
payload, image_sources = await parse_image_edit_request(request)
|
| 98 |
+
prompt = str(payload["prompt"])
|
| 99 |
+
model = str(payload["model"])
|
| 100 |
+
call = LoggedCall(identity, "/v1/images/edits", model, "图生图", request_text=prompt)
|
| 101 |
+
await filter_or_log(call, prompt)
|
| 102 |
+
payload["images"] = await read_image_sources(image_sources)
|
| 103 |
+
payload["base_url"] = resolve_image_base_url(request)
|
| 104 |
+
return await call.run(openai_v1_image_edit.handle, payload)
|
| 105 |
+
|
| 106 |
+
@router.post("/v1/chat/completions")
|
| 107 |
+
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
| 108 |
+
identity = require_identity(authorization)
|
| 109 |
+
payload = body.model_dump(mode="python")
|
| 110 |
+
model = str(payload.get("model") or "auto")
|
| 111 |
+
request_preview = request_text(payload.get("prompt"), payload.get("messages"))
|
| 112 |
+
call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成", request_text=request_preview)
|
| 113 |
+
await filter_or_log(call, request_preview)
|
| 114 |
+
return await call.run(openai_v1_chat_complete.handle, payload)
|
| 115 |
+
|
| 116 |
+
@router.post("/v1/responses")
|
| 117 |
+
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
| 118 |
+
identity = require_identity(authorization)
|
| 119 |
+
payload = body.model_dump(mode="python")
|
| 120 |
+
model = str(payload.get("model") or "auto")
|
| 121 |
+
request_preview = request_text(payload.get("input"), payload.get("instructions"))
|
| 122 |
+
call = LoggedCall(identity, "/v1/responses", model, "Responses", request_text=request_preview)
|
| 123 |
+
await filter_or_log(call, request_preview)
|
| 124 |
+
return await call.run(openai_v1_response.handle, payload)
|
| 125 |
+
|
| 126 |
+
@router.post("/v1/messages")
|
| 127 |
+
async def create_message(
|
| 128 |
+
body: AnthropicMessageRequest,
|
| 129 |
+
authorization: str | None = Header(default=None),
|
| 130 |
+
x_api_key: str | None = Header(default=None, alias="x-api-key"),
|
| 131 |
+
anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
|
| 132 |
+
):
|
| 133 |
+
identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
|
| 134 |
+
payload = body.model_dump(mode="python")
|
| 135 |
+
model = str(payload.get("model") or "auto")
|
| 136 |
+
request_preview = request_text(payload.get("system"), payload.get("messages"), payload.get("tools"))
|
| 137 |
+
call = LoggedCall(identity, "/v1/messages", model, "Messages", request_text=request_preview)
|
| 138 |
+
await filter_or_log(call, request_preview)
|
| 139 |
+
return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
|
| 140 |
+
|
| 141 |
+
return router
|
api/app.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from threading import Event
|
| 5 |
+
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
|
| 10 |
+
from api import accounts, ai, image_tasks, register, system
|
| 11 |
+
from api.errors import install_exception_handlers
|
| 12 |
+
from api.support import resolve_web_asset, start_limited_account_watcher
|
| 13 |
+
from services.backup_service import backup_service
|
| 14 |
+
from services.config import config
|
| 15 |
+
from services.image_service import start_image_cleanup_scheduler
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def create_app() -> FastAPI:
|
| 19 |
+
app_version = config.app_version
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(_: FastAPI):
|
| 23 |
+
stop_event = Event()
|
| 24 |
+
thread = start_limited_account_watcher(stop_event)
|
| 25 |
+
cleanup_thread = start_image_cleanup_scheduler(stop_event)
|
| 26 |
+
backup_service.start()
|
| 27 |
+
config.cleanup_old_images()
|
| 28 |
+
try:
|
| 29 |
+
yield
|
| 30 |
+
finally:
|
| 31 |
+
stop_event.set()
|
| 32 |
+
thread.join(timeout=1)
|
| 33 |
+
cleanup_thread.join(timeout=1)
|
| 34 |
+
backup_service.stop()
|
| 35 |
+
|
| 36 |
+
app = FastAPI(title="chatgpt2api", version=app_version, lifespan=lifespan)
|
| 37 |
+
install_exception_handlers(app)
|
| 38 |
+
app.add_middleware(
|
| 39 |
+
CORSMiddleware,
|
| 40 |
+
allow_origins=["*"],
|
| 41 |
+
allow_credentials=False,
|
| 42 |
+
allow_methods=["*"],
|
| 43 |
+
allow_headers=["*"],
|
| 44 |
+
)
|
| 45 |
+
app.include_router(ai.create_router())
|
| 46 |
+
app.include_router(accounts.create_router())
|
| 47 |
+
app.include_router(image_tasks.create_router())
|
| 48 |
+
app.include_router(register.create_router())
|
| 49 |
+
app.include_router(system.create_router(app_version))
|
| 50 |
+
|
| 51 |
+
@app.get("/{full_path:path}", include_in_schema=False)
|
| 52 |
+
async def serve_web(full_path: str):
|
| 53 |
+
asset = resolve_web_asset(full_path)
|
| 54 |
+
if asset is not None:
|
| 55 |
+
return FileResponse(asset)
|
| 56 |
+
if full_path.strip("/").startswith("_next/"):
|
| 57 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 58 |
+
fallback = resolve_web_asset("")
|
| 59 |
+
if fallback is None:
|
| 60 |
+
raise HTTPException(status_code=404, detail="Not Found")
|
| 61 |
+
return FileResponse(fallback)
|
| 62 |
+
|
| 63 |
+
return app
|
api/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, Request
|
| 4 |
+
from fastapi.encoders import jsonable_encoder
|
| 5 |
+
from fastapi.exceptions import RequestValidationError
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 8 |
+
|
| 9 |
+
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _is_openai_compatible_path(path: str) -> bool:
|
| 13 |
+
return path == "/v1" or path.startswith("/v1/")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _is_anthropic_messages_path(path: str) -> bool:
|
| 17 |
+
return path == "/v1/messages"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _compatible_error_response(
|
| 21 |
+
request: Request,
|
| 22 |
+
detail: object,
|
| 23 |
+
status_code: int,
|
| 24 |
+
headers: dict[str, str] | None = None,
|
| 25 |
+
) -> JSONResponse:
|
| 26 |
+
if _is_anthropic_messages_path(request.url.path):
|
| 27 |
+
return anthropic_error_response(detail, status_code, headers=headers)
|
| 28 |
+
return openai_error_response(detail, status_code, headers=headers)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def install_exception_handlers(app: FastAPI) -> None:
|
| 32 |
+
@app.exception_handler(StarletteHTTPException)
|
| 33 |
+
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
| 34 |
+
if _is_openai_compatible_path(request.url.path):
|
| 35 |
+
return _compatible_error_response(request, exc.detail, exc.status_code, exc.headers)
|
| 36 |
+
return JSONResponse(
|
| 37 |
+
status_code=exc.status_code,
|
| 38 |
+
content={"detail": jsonable_encoder(exc.detail)},
|
| 39 |
+
headers=exc.headers,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@app.exception_handler(RequestValidationError)
|
| 43 |
+
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
| 44 |
+
if _is_openai_compatible_path(request.url.path):
|
| 45 |
+
return _compatible_error_response(request, exc.errors(), 422)
|
| 46 |
+
return JSONResponse(status_code=422, content={"detail": jsonable_encoder(exc.errors())})
|
api/image_inputs.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import binascii
|
| 5 |
+
import json
|
| 6 |
+
import mimetypes
|
| 7 |
+
import re
|
| 8 |
+
from pathlib import PurePosixPath
|
| 9 |
+
from typing import Any, TypeGuard
|
| 10 |
+
from urllib.parse import unquote, unquote_to_bytes, urlparse
|
| 11 |
+
|
| 12 |
+
from curl_cffi import requests
|
| 13 |
+
from fastapi import HTTPException, Request
|
| 14 |
+
from fastapi.concurrency import run_in_threadpool
|
| 15 |
+
from starlette.datastructures import UploadFile
|
| 16 |
+
|
| 17 |
+
from services.proxy_service import proxy_settings
|
| 18 |
+
|
| 19 |
+
ImageInput = tuple[bytes, str, str]
|
| 20 |
+
ImageSource = str | UploadFile | ImageInput
|
| 21 |
+
|
| 22 |
+
MAX_IMAGE_REFERENCE_BYTES = 50 * 1024 * 1024
|
| 23 |
+
IMAGE_REFERENCE_FIELDS = {"image", "image[]", "images", "images[]", "image_url", "image_url[]"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _clean(value: object, default: str = "") -> str:
|
| 27 |
+
"""清理字符串:转换为字符串并去掉首尾空白。"""
|
| 28 |
+
text = str(value if value is not None else default).strip()
|
| 29 |
+
return text or default
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _is_upload(value: object) -> TypeGuard[UploadFile]:
|
| 33 |
+
"""识别上传文件:兼容 Starlette 表单返回的 UploadFile。"""
|
| 34 |
+
return isinstance(value, UploadFile)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _parse_bool(value: object) -> bool | None:
|
| 38 |
+
"""解析布尔字段:兼容 JSON 布尔值和表单字符串。"""
|
| 39 |
+
if value is None or value == "":
|
| 40 |
+
return None
|
| 41 |
+
if isinstance(value, bool):
|
| 42 |
+
return value
|
| 43 |
+
text = _clean(value).lower()
|
| 44 |
+
if text in {"true", "1", "yes", "y", "on"}:
|
| 45 |
+
return True
|
| 46 |
+
if text in {"false", "0", "no", "n", "off"}:
|
| 47 |
+
return False
|
| 48 |
+
raise HTTPException(status_code=400, detail={"error": "stream must be a boolean"})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _parse_count(value: object) -> int:
|
| 52 |
+
"""解析生成数量:保持图片接口的 1 到 4 限制。"""
|
| 53 |
+
try:
|
| 54 |
+
count = int(value or 1)
|
| 55 |
+
except (TypeError, ValueError) as exc:
|
| 56 |
+
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
| 57 |
+
if count < 1 or count > 4:
|
| 58 |
+
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 59 |
+
return count
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _payload_from_fields(fields: dict[str, Any]) -> dict[str, Any]:
|
| 63 |
+
"""构造图片编辑载荷:从表单或 JSON 字段提取通用参数。"""
|
| 64 |
+
prompt = _clean(fields.get("prompt"))
|
| 65 |
+
if not prompt:
|
| 66 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 67 |
+
payload = {
|
| 68 |
+
"prompt": prompt,
|
| 69 |
+
"model": _clean(fields.get("model"), "gpt-image-2"),
|
| 70 |
+
"n": _parse_count(fields.get("n")),
|
| 71 |
+
"size": _clean(fields.get("size")) or None,
|
| 72 |
+
"quality": _clean(fields.get("quality"), "auto"),
|
| 73 |
+
"response_format": _clean(fields.get("response_format"), "b64_json"),
|
| 74 |
+
"stream": _parse_bool(fields.get("stream")),
|
| 75 |
+
}
|
| 76 |
+
if "client_task_id" in fields:
|
| 77 |
+
payload["client_task_id"] = _clean(fields.get("client_task_id"))
|
| 78 |
+
return payload
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _json_reference_value(value: object) -> object:
|
| 82 |
+
"""解析表单图片引用:支持把 images 字段写成 JSON 字符串。"""
|
| 83 |
+
if not isinstance(value, str):
|
| 84 |
+
return value
|
| 85 |
+
text = value.strip()
|
| 86 |
+
if not text or text[0] not in "[{":
|
| 87 |
+
return value
|
| 88 |
+
try:
|
| 89 |
+
return json.loads(text)
|
| 90 |
+
except json.JSONDecodeError:
|
| 91 |
+
return value
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _decode_base64_image(value: object, filename: str, mime_type: str) -> ImageInput:
|
| 95 |
+
try:
|
| 96 |
+
data = base64.b64decode(str(value).strip(), validate=True)
|
| 97 |
+
except (binascii.Error, ValueError) as exc:
|
| 98 |
+
raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
|
| 99 |
+
if not data:
|
| 100 |
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 101 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 102 |
+
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
| 103 |
+
return data, filename, mime_type
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _source_from_object(value: dict[str, Any]) -> list[ImageSource]:
|
| 107 |
+
"""提取图片引用对象:支持 image_url 或 url,明确拒绝 file_id。"""
|
| 108 |
+
has_url = "image_url" in value or "url" in value
|
| 109 |
+
if value.get("file_id"):
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=400,
|
| 112 |
+
detail={"error": "file_id image references are not supported; use image_url instead"},
|
| 113 |
+
)
|
| 114 |
+
inline = value.get("b64_json") or value.get("base64")
|
| 115 |
+
if inline:
|
| 116 |
+
filename = _clean(value.get("filename") or value.get("file_name"), "image.png")
|
| 117 |
+
mime_type = _clean(value.get("mime_type") or value.get("mimeType"), "image/png")
|
| 118 |
+
return [_decode_base64_image(inline, filename, mime_type)]
|
| 119 |
+
if not has_url:
|
| 120 |
+
raise HTTPException(status_code=400, detail={"error": "image reference must include image_url"})
|
| 121 |
+
image_url = value.get("image_url", value.get("url"))
|
| 122 |
+
if isinstance(image_url, dict):
|
| 123 |
+
image_url = image_url.get("url")
|
| 124 |
+
return _sources_from_value(image_url)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _sources_from_value(value: object) -> list[ImageSource]:
|
| 128 |
+
"""展开图片引用:把字符串、数组和对象统一成图片来源列表。"""
|
| 129 |
+
value = _json_reference_value(value)
|
| 130 |
+
if _is_upload(value):
|
| 131 |
+
return [value]
|
| 132 |
+
if isinstance(value, str):
|
| 133 |
+
text = value.strip()
|
| 134 |
+
if not text:
|
| 135 |
+
return []
|
| 136 |
+
if text.lower().startswith(("data:", "http://", "https://")):
|
| 137 |
+
return [text]
|
| 138 |
+
return [_decode_base64_image(text, "image.png", "image/png")]
|
| 139 |
+
if isinstance(value, list):
|
| 140 |
+
sources: list[ImageSource] = []
|
| 141 |
+
for item in value:
|
| 142 |
+
sources.extend(_sources_from_value(item))
|
| 143 |
+
return sources
|
| 144 |
+
if isinstance(value, dict):
|
| 145 |
+
return _source_from_object(value)
|
| 146 |
+
if value is None:
|
| 147 |
+
return []
|
| 148 |
+
raise HTTPException(status_code=400, detail={"error": "invalid image reference"})
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _json_image_sources(body: dict[str, Any]) -> list[ImageSource]:
|
| 152 |
+
"""读取 JSON 图片引用:优先支持官方 images 数组字段。"""
|
| 153 |
+
sources: list[ImageSource] = []
|
| 154 |
+
for key in ("images", "image", "image_url"):
|
| 155 |
+
if key in body:
|
| 156 |
+
sources.extend(_sources_from_value(body.get(key)))
|
| 157 |
+
return sources
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def parse_image_edit_request(request: Request) -> tuple[dict[str, Any], list[ImageSource]]:
|
| 161 |
+
"""解析图片编辑请求:同时支持 multipart 上传和官方 JSON 图片 URL。"""
|
| 162 |
+
content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
| 163 |
+
if content_type == "application/json":
|
| 164 |
+
try:
|
| 165 |
+
body = await request.json()
|
| 166 |
+
except json.JSONDecodeError as exc:
|
| 167 |
+
raise HTTPException(status_code=400, detail={"error": "invalid JSON body"}) from exc
|
| 168 |
+
if not isinstance(body, dict):
|
| 169 |
+
raise HTTPException(status_code=400, detail={"error": "JSON body must be an object"})
|
| 170 |
+
return _payload_from_fields(body), _json_image_sources(body)
|
| 171 |
+
|
| 172 |
+
form = await request.form()
|
| 173 |
+
fields: dict[str, Any] = {}
|
| 174 |
+
for key in ("client_task_id", "prompt", "model", "n", "size", "quality", "response_format", "stream"):
|
| 175 |
+
value = form.get(key)
|
| 176 |
+
if isinstance(value, str):
|
| 177 |
+
fields[key] = value
|
| 178 |
+
sources: list[ImageSource] = []
|
| 179 |
+
for key, value in form.multi_items():
|
| 180 |
+
if key in IMAGE_REFERENCE_FIELDS:
|
| 181 |
+
sources.extend(_sources_from_value(value))
|
| 182 |
+
return _payload_from_fields(fields), sources
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _extension_from_mime(mime_type: str) -> str:
|
| 186 |
+
"""推导图片扩展名:把 MIME 类型转换为常见文件后缀。"""
|
| 187 |
+
subtype = mime_type.split("/", 1)[1].split("+", 1)[0] if "/" in mime_type else "png"
|
| 188 |
+
if subtype == "jpeg":
|
| 189 |
+
return "jpg"
|
| 190 |
+
return re.sub(r"[^a-z0-9]+", "", subtype.lower()) or "png"
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _safe_filename(name: str, mime_type: str, fallback: str) -> str:
|
| 194 |
+
"""生成安全文件名:清理 URL 文件名并补齐扩展名。"""
|
| 195 |
+
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("._")
|
| 196 |
+
if not cleaned:
|
| 197 |
+
cleaned = fallback
|
| 198 |
+
if "." not in cleaned:
|
| 199 |
+
cleaned = f"{cleaned}.{_extension_from_mime(mime_type)}"
|
| 200 |
+
return cleaned
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _decode_data_url(url: str) -> ImageInput:
|
| 204 |
+
"""解码 data URL:把内联图片转成标准图片输入元组。"""
|
| 205 |
+
header, separator, payload = url.partition(",")
|
| 206 |
+
if not separator:
|
| 207 |
+
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"})
|
| 208 |
+
mime_type = header.split(";", 1)[0].removeprefix("data:") or "image/png"
|
| 209 |
+
if not mime_type.startswith("image/"):
|
| 210 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 211 |
+
try:
|
| 212 |
+
data = base64.b64decode(payload, validate=True) if ";base64" in header else unquote_to_bytes(payload)
|
| 213 |
+
except (binascii.Error, ValueError) as exc:
|
| 214 |
+
raise HTTPException(status_code=400, detail={"error": "invalid data image URL"}) from exc
|
| 215 |
+
if not data:
|
| 216 |
+
raise HTTPException(status_code=400, detail={"error": "image URL is empty"})
|
| 217 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 218 |
+
raise HTTPException(status_code=400, detail={"error": "image URL exceeds 50MB limit"})
|
| 219 |
+
return data, f"image_url.{_extension_from_mime(mime_type)}", mime_type
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _response_mime_type(response: requests.Response, parsed_path: str) -> str:
|
| 223 |
+
"""识别下载图片类型:优先响应头,必要时按 URL 后缀推断。"""
|
| 224 |
+
header_type = str(response.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
|
| 225 |
+
guessed_type = mimetypes.guess_type(parsed_path)[0] or ""
|
| 226 |
+
if header_type.startswith("image/"):
|
| 227 |
+
return header_type
|
| 228 |
+
if header_type and header_type not in {"application/octet-stream", "binary/octet-stream"}:
|
| 229 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 230 |
+
if guessed_type.startswith("image/"):
|
| 231 |
+
return guessed_type
|
| 232 |
+
if not header_type or header_type in {"application/octet-stream", "binary/octet-stream"}:
|
| 233 |
+
return "image/png"
|
| 234 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _filename_from_url(parsed_path: str, mime_type: str) -> str:
|
| 238 |
+
"""生成 URL 图片文件名:从链接路径提取名称并做安全化。"""
|
| 239 |
+
raw_name = PurePosixPath(unquote(parsed_path)).name
|
| 240 |
+
return _safe_filename(raw_name, mime_type, "image_url")
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def _download_image_url(url: str) -> ImageInput:
|
| 244 |
+
"""下载远程图片:把 http/https 图片链接转成标准图片输入元组。"""
|
| 245 |
+
source = _clean(url)
|
| 246 |
+
if source.startswith("data:"):
|
| 247 |
+
return _decode_data_url(source)
|
| 248 |
+
parsed = urlparse(source)
|
| 249 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 250 |
+
raise HTTPException(status_code=400, detail={"error": "image_url must be an http or https URL"})
|
| 251 |
+
try:
|
| 252 |
+
response = requests.get(
|
| 253 |
+
source,
|
| 254 |
+
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api image fetcher"},
|
| 255 |
+
timeout=60,
|
| 256 |
+
allow_redirects=True,
|
| 257 |
+
**proxy_settings.build_session_kwargs(),
|
| 258 |
+
)
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
|
| 261 |
+
if not 200 <= response.status_code < 300:
|
| 262 |
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
|
| 263 |
+
content_length = _clean(response.headers.get("content-length"))
|
| 264 |
+
if content_length and content_length.isdigit() and int(content_length) > MAX_IMAGE_REFERENCE_BYTES:
|
| 265 |
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
| 266 |
+
data = response.content
|
| 267 |
+
if not data:
|
| 268 |
+
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
|
| 269 |
+
if len(data) > MAX_IMAGE_REFERENCE_BYTES:
|
| 270 |
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 50MB limit"})
|
| 271 |
+
mime_type = _response_mime_type(response, parsed.path)
|
| 272 |
+
return data, _filename_from_url(parsed.path, mime_type), mime_type
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def read_image_sources(sources: list[ImageSource]) -> list[ImageInput]:
|
| 276 |
+
"""读取图片来源:上传文件直接读取,URL 下载后统一返回图片元组。"""
|
| 277 |
+
images: list[ImageInput] = []
|
| 278 |
+
for source in sources:
|
| 279 |
+
if isinstance(source, tuple):
|
| 280 |
+
images.append(source)
|
| 281 |
+
continue
|
| 282 |
+
if _is_upload(source):
|
| 283 |
+
try:
|
| 284 |
+
image_data = await source.read()
|
| 285 |
+
finally:
|
| 286 |
+
await source.close()
|
| 287 |
+
if not image_data:
|
| 288 |
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 289 |
+
images.append((image_data, source.filename or "image.png", source.content_type or "image/png"))
|
| 290 |
+
continue
|
| 291 |
+
images.append(await run_in_threadpool(_download_image_url, source))
|
| 292 |
+
if not images:
|
| 293 |
+
raise HTTPException(status_code=400, detail={"error": "image file or image_url is required"})
|
| 294 |
+
return images
|
api/image_tasks.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
| 4 |
+
from fastapi.concurrency import run_in_threadpool
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
|
| 7 |
+
from api.image_inputs import parse_image_edit_request, read_image_sources
|
| 8 |
+
from api.support import require_identity, resolve_image_base_url
|
| 9 |
+
from services.content_filter import check_request
|
| 10 |
+
from services.image_task_service import image_task_service
|
| 11 |
+
from services.log_service import LoggedCall
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ImageGenerationTaskRequest(BaseModel):
|
| 15 |
+
client_task_id: str = Field(..., min_length=1)
|
| 16 |
+
prompt: str = Field(..., min_length=1)
|
| 17 |
+
model: str = "gpt-image-2"
|
| 18 |
+
size: str | None = None
|
| 19 |
+
quality: str = "auto"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _parse_task_ids(value: str) -> list[str]:
|
| 23 |
+
return [item.strip() for item in value.split(",") if item.strip()]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def filter_or_log(call: LoggedCall, text: str) -> None:
|
| 27 |
+
try:
|
| 28 |
+
await run_in_threadpool(check_request, text)
|
| 29 |
+
except HTTPException as exc:
|
| 30 |
+
call.log("调用失败", status="failed", error=str(exc.detail))
|
| 31 |
+
raise
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def create_router() -> APIRouter:
|
| 35 |
+
router = APIRouter()
|
| 36 |
+
|
| 37 |
+
@router.get("/api/image-tasks")
|
| 38 |
+
async def list_image_tasks(
|
| 39 |
+
ids: str = Query(default=""),
|
| 40 |
+
authorization: str | None = Header(default=None),
|
| 41 |
+
):
|
| 42 |
+
identity = require_identity(authorization)
|
| 43 |
+
return await run_in_threadpool(image_task_service.list_tasks, identity, _parse_task_ids(ids))
|
| 44 |
+
|
| 45 |
+
@router.post("/api/image-tasks/generations")
|
| 46 |
+
async def create_generation_task(
|
| 47 |
+
body: ImageGenerationTaskRequest,
|
| 48 |
+
request: Request,
|
| 49 |
+
authorization: str | None = Header(default=None),
|
| 50 |
+
):
|
| 51 |
+
identity = require_identity(authorization)
|
| 52 |
+
await filter_or_log(LoggedCall(identity, "/api/image-tasks/generations", body.model, "文生图任务", request_text=body.prompt), body.prompt)
|
| 53 |
+
try:
|
| 54 |
+
return await run_in_threadpool(
|
| 55 |
+
image_task_service.submit_generation,
|
| 56 |
+
identity,
|
| 57 |
+
client_task_id=body.client_task_id,
|
| 58 |
+
prompt=body.prompt,
|
| 59 |
+
model=body.model,
|
| 60 |
+
size=body.size,
|
| 61 |
+
quality=body.quality,
|
| 62 |
+
base_url=resolve_image_base_url(request),
|
| 63 |
+
)
|
| 64 |
+
except ValueError as exc:
|
| 65 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 66 |
+
|
| 67 |
+
@router.post("/api/image-tasks/edits")
|
| 68 |
+
async def create_edit_task(
|
| 69 |
+
request: Request,
|
| 70 |
+
authorization: str | None = Header(default=None),
|
| 71 |
+
):
|
| 72 |
+
identity = require_identity(authorization)
|
| 73 |
+
payload, image_sources = await parse_image_edit_request(request)
|
| 74 |
+
client_task_id = str(payload.get("client_task_id") or "").strip()
|
| 75 |
+
if not client_task_id:
|
| 76 |
+
raise HTTPException(status_code=400, detail={"error": "client_task_id is required"})
|
| 77 |
+
prompt = str(payload["prompt"])
|
| 78 |
+
model = str(payload["model"])
|
| 79 |
+
await filter_or_log(LoggedCall(identity, "/api/image-tasks/edits", model, "图生图任务", request_text=prompt), prompt)
|
| 80 |
+
images = await read_image_sources(image_sources)
|
| 81 |
+
try:
|
| 82 |
+
return await run_in_threadpool(
|
| 83 |
+
image_task_service.submit_edit,
|
| 84 |
+
identity,
|
| 85 |
+
client_task_id=client_task_id,
|
| 86 |
+
prompt=prompt,
|
| 87 |
+
model=model,
|
| 88 |
+
size=payload["size"],
|
| 89 |
+
quality=payload["quality"],
|
| 90 |
+
base_url=resolve_image_base_url(request),
|
| 91 |
+
images=images,
|
| 92 |
+
)
|
| 93 |
+
except ValueError as exc:
|
| 94 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 95 |
+
|
| 96 |
+
return router
|
api/register.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Header
|
| 7 |
+
from fastapi.responses import StreamingResponse
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from api.support import require_admin
|
| 11 |
+
from services.register_service import register_service
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class RegisterConfigRequest(BaseModel):
|
| 15 |
+
mail: dict | None = None
|
| 16 |
+
proxy: str | None = None
|
| 17 |
+
total: int | None = None
|
| 18 |
+
threads: int | None = None
|
| 19 |
+
mode: str | None = None
|
| 20 |
+
target_quota: int | None = None
|
| 21 |
+
target_available: int | None = None
|
| 22 |
+
check_interval: int | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def create_router() -> APIRouter:
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
@router.get("/api/register")
|
| 29 |
+
async def get_register_config(authorization: str | None = Header(default=None)):
|
| 30 |
+
require_admin(authorization)
|
| 31 |
+
return {"register": register_service.get()}
|
| 32 |
+
|
| 33 |
+
@router.post("/api/register")
|
| 34 |
+
async def update_register_config(body: RegisterConfigRequest, authorization: str | None = Header(default=None)):
|
| 35 |
+
require_admin(authorization)
|
| 36 |
+
return {"register": register_service.update(body.model_dump(exclude_none=True))}
|
| 37 |
+
|
| 38 |
+
@router.post("/api/register/start")
|
| 39 |
+
async def start_register(authorization: str | None = Header(default=None)):
|
| 40 |
+
require_admin(authorization)
|
| 41 |
+
return {"register": register_service.start()}
|
| 42 |
+
|
| 43 |
+
@router.post("/api/register/stop")
|
| 44 |
+
async def stop_register(authorization: str | None = Header(default=None)):
|
| 45 |
+
require_admin(authorization)
|
| 46 |
+
return {"register": register_service.stop()}
|
| 47 |
+
|
| 48 |
+
@router.post("/api/register/reset")
|
| 49 |
+
async def reset_register(authorization: str | None = Header(default=None)):
|
| 50 |
+
require_admin(authorization)
|
| 51 |
+
return {"register": register_service.reset()}
|
| 52 |
+
|
| 53 |
+
@router.get("/api/register/events")
|
| 54 |
+
async def register_events(token: str = ""):
|
| 55 |
+
require_admin(f"Bearer {token}")
|
| 56 |
+
|
| 57 |
+
async def stream():
|
| 58 |
+
last = ""
|
| 59 |
+
while True:
|
| 60 |
+
payload = json.dumps(register_service.get(), ensure_ascii=False)
|
| 61 |
+
if payload != last:
|
| 62 |
+
last = payload
|
| 63 |
+
yield f"data: {payload}\n\n"
|
| 64 |
+
await asyncio.sleep(0.5)
|
| 65 |
+
|
| 66 |
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
| 67 |
+
|
| 68 |
+
return router
|
api/support.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from threading import Event, Thread
|
| 5 |
+
|
| 6 |
+
from fastapi import HTTPException, Request
|
| 7 |
+
|
| 8 |
+
from services.account_service import account_service
|
| 9 |
+
from services.auth_service import auth_service
|
| 10 |
+
from services.config import config
|
| 11 |
+
|
| 12 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
+
WEB_DIST_DIR = BASE_DIR / "web_dist"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def extract_bearer_token(authorization: str | None) -> str:
|
| 17 |
+
scheme, _, value = str(authorization or "").partition(" ")
|
| 18 |
+
if scheme.lower() != "bearer" or not value.strip():
|
| 19 |
+
return ""
|
| 20 |
+
return value.strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _legacy_admin_identity(token: str) -> dict[str, object] | None:
|
| 24 |
+
auth_key = str(config.auth_key or "").strip()
|
| 25 |
+
if auth_key and token == auth_key:
|
| 26 |
+
return {"id": "admin", "name": "管理员", "role": "admin"}
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def require_identity(authorization: str | None) -> dict[str, object]:
|
| 31 |
+
token = extract_bearer_token(authorization)
|
| 32 |
+
identity = _legacy_admin_identity(token) or auth_service.authenticate(token)
|
| 33 |
+
if identity is None:
|
| 34 |
+
raise HTTPException(status_code=401, detail={"error": "密钥无效或已失效,请重新登录"})
|
| 35 |
+
return identity
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def require_auth_key(authorization: str | None) -> None:
|
| 39 |
+
require_identity(authorization)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def require_admin(authorization: str | None) -> dict[str, object]:
|
| 43 |
+
identity = require_identity(authorization)
|
| 44 |
+
if identity.get("role") != "admin":
|
| 45 |
+
raise HTTPException(status_code=403, detail={"error": "需要管理员权限才能执行这个操作"})
|
| 46 |
+
return identity
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def resolve_image_base_url(request: Request) -> str:
|
| 50 |
+
return config.base_url or f"{request.url.scheme}://{request.headers.get('host', request.url.netloc)}"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def raise_image_quota_error(exc: Exception) -> None:
|
| 54 |
+
message = str(exc)
|
| 55 |
+
if "no available image quota" in message.lower():
|
| 56 |
+
raise HTTPException(status_code=429, detail={"error": "no available image quota"}) from exc
|
| 57 |
+
raise HTTPException(status_code=502, detail={"error": message}) from exc
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def sanitize_cpa_pool(pool: dict | None) -> dict | None:
|
| 61 |
+
if not isinstance(pool, dict):
|
| 62 |
+
return None
|
| 63 |
+
return {key: value for key, value in pool.items() if key != "secret_key"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def sanitize_cpa_pools(pools: list[dict]) -> list[dict]:
|
| 67 |
+
return [sanitized for pool in pools if (sanitized := sanitize_cpa_pool(pool)) is not None]
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def sanitize_sub2api_server(server: dict | None) -> dict | None:
|
| 71 |
+
if not isinstance(server, dict):
|
| 72 |
+
return None
|
| 73 |
+
sanitized = {key: value for key, value in server.items() if key not in {"password", "api_key"}}
|
| 74 |
+
sanitized["has_api_key"] = bool(str(server.get("api_key") or "").strip())
|
| 75 |
+
return sanitized
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def sanitize_sub2api_servers(servers: list[dict]) -> list[dict]:
|
| 79 |
+
return [sanitized for server in servers if (sanitized := sanitize_sub2api_server(server)) is not None]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def start_limited_account_watcher(stop_event: Event) -> Thread:
|
| 83 |
+
interval_seconds = config.refresh_account_interval_minute * 60
|
| 84 |
+
|
| 85 |
+
def worker() -> None:
|
| 86 |
+
while not stop_event.is_set():
|
| 87 |
+
try:
|
| 88 |
+
limited_tokens = account_service.list_limited_tokens()
|
| 89 |
+
expiring_tokens = account_service.list_expiring_access_tokens()
|
| 90 |
+
keepalive_tokens = account_service.list_refresh_token_keepalive_tokens()
|
| 91 |
+
tokens = list(dict.fromkeys([*limited_tokens, *expiring_tokens]))
|
| 92 |
+
expiring_token_set = set(expiring_tokens)
|
| 93 |
+
keepalive_tokens = [token for token in keepalive_tokens if token not in expiring_token_set]
|
| 94 |
+
if tokens:
|
| 95 |
+
print(
|
| 96 |
+
"[account-watcher] checking "
|
| 97 |
+
f"{len(limited_tokens)} limited accounts, "
|
| 98 |
+
f"{len(expiring_tokens)} expiring access tokens"
|
| 99 |
+
)
|
| 100 |
+
account_service.refresh_accounts(tokens)
|
| 101 |
+
if keepalive_tokens:
|
| 102 |
+
print(f"[account-watcher] keepalive {len(keepalive_tokens)} refresh tokens")
|
| 103 |
+
result = account_service.keepalive_refresh_tokens(keepalive_tokens)
|
| 104 |
+
if result.get("errors"):
|
| 105 |
+
print(f"[account-watcher] keepalive errors: {result['errors']}")
|
| 106 |
+
except Exception as exc:
|
| 107 |
+
print(f"[account-watcher] fail {exc}")
|
| 108 |
+
stop_event.wait(interval_seconds)
|
| 109 |
+
|
| 110 |
+
thread = Thread(target=worker, name="account-watcher", daemon=True)
|
| 111 |
+
thread.start()
|
| 112 |
+
return thread
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def resolve_web_asset(requested_path: str) -> Path | None:
|
| 116 |
+
if not WEB_DIST_DIR.exists():
|
| 117 |
+
return None
|
| 118 |
+
clean_path = requested_path.strip("/")
|
| 119 |
+
base_dir = WEB_DIST_DIR.resolve()
|
| 120 |
+
candidates = [base_dir / "index.html"] if not clean_path else [
|
| 121 |
+
base_dir / Path(clean_path),
|
| 122 |
+
base_dir / clean_path / "index.html",
|
| 123 |
+
base_dir / f"{clean_path}.html",
|
| 124 |
+
]
|
| 125 |
+
for candidate in candidates:
|
| 126 |
+
try:
|
| 127 |
+
candidate.resolve().relative_to(base_dir)
|
| 128 |
+
except ValueError:
|
| 129 |
+
continue
|
| 130 |
+
if candidate.is_file():
|
| 131 |
+
return candidate
|
| 132 |
+
return None
|
api/system.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from urllib.parse import quote
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
| 6 |
+
from fastapi.concurrency import run_in_threadpool
|
| 7 |
+
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
| 8 |
+
from pydantic import BaseModel, ConfigDict
|
| 9 |
+
|
| 10 |
+
from api.support import require_admin, require_identity, resolve_image_base_url
|
| 11 |
+
from services.backup_service import BackupError, backup_service
|
| 12 |
+
from services.config import config
|
| 13 |
+
from services.image_service import (
|
| 14 |
+
compress_images,
|
| 15 |
+
delete_images,
|
| 16 |
+
delete_to_target,
|
| 17 |
+
download_images_zip,
|
| 18 |
+
get_image_download_response,
|
| 19 |
+
get_image_response,
|
| 20 |
+
get_thumbnail_response,
|
| 21 |
+
list_images,
|
| 22 |
+
storage_stats,
|
| 23 |
+
)
|
| 24 |
+
from services.image_storage_service import ImageStorageError, image_storage_service
|
| 25 |
+
from services.image_tags_service import delete_tag, get_all_tags, set_tags
|
| 26 |
+
from services.log_service import log_service
|
| 27 |
+
from services.proxy_service import test_proxy
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SettingsUpdateRequest(BaseModel):
|
| 31 |
+
model_config = ConfigDict(extra="allow")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ProxyTestRequest(BaseModel):
|
| 35 |
+
url: str = ""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ImageDeleteRequest(BaseModel):
|
| 39 |
+
paths: list[str] = []
|
| 40 |
+
start_date: str = ""
|
| 41 |
+
end_date: str = ""
|
| 42 |
+
all_matching: bool = False
|
| 43 |
+
|
| 44 |
+
class ImageDownloadRequest(BaseModel):
|
| 45 |
+
paths: list[str]
|
| 46 |
+
|
| 47 |
+
class ImageTagsRequest(BaseModel):
|
| 48 |
+
path: str
|
| 49 |
+
tags: list[str]
|
| 50 |
+
|
| 51 |
+
class LogDeleteRequest(BaseModel):
|
| 52 |
+
ids: list[str] = []
|
| 53 |
+
class BackupDeleteRequest(BaseModel):
|
| 54 |
+
key: str = ""
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def create_router(app_version: str) -> APIRouter:
|
| 58 |
+
router = APIRouter()
|
| 59 |
+
|
| 60 |
+
@router.post("/auth/login")
|
| 61 |
+
async def login(authorization: str | None = Header(default=None)):
|
| 62 |
+
identity = require_identity(authorization)
|
| 63 |
+
return {
|
| 64 |
+
"ok": True,
|
| 65 |
+
"version": app_version,
|
| 66 |
+
"role": identity.get("role"),
|
| 67 |
+
"subject_id": identity.get("id"),
|
| 68 |
+
"name": identity.get("name"),
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
@router.get("/version")
|
| 72 |
+
async def get_version():
|
| 73 |
+
return {"version": app_version}
|
| 74 |
+
|
| 75 |
+
@router.get("/api/settings")
|
| 76 |
+
async def get_settings(authorization: str | None = Header(default=None)):
|
| 77 |
+
require_admin(authorization)
|
| 78 |
+
return {"config": config.get()}
|
| 79 |
+
|
| 80 |
+
@router.post("/api/settings")
|
| 81 |
+
async def save_settings(body: SettingsUpdateRequest, authorization: str | None = Header(default=None)):
|
| 82 |
+
require_admin(authorization)
|
| 83 |
+
try:
|
| 84 |
+
return {"config": config.update(body.model_dump(mode="python"))}
|
| 85 |
+
except ValueError as exc:
|
| 86 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 87 |
+
|
| 88 |
+
@router.get("/api/images")
|
| 89 |
+
async def get_images(request: Request, start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
|
| 90 |
+
require_admin(authorization)
|
| 91 |
+
return list_images(resolve_image_base_url(request), start_date=start_date.strip(), end_date=end_date.strip())
|
| 92 |
+
|
| 93 |
+
@router.get("/images/{image_path:path}", include_in_schema=False)
|
| 94 |
+
async def get_image(image_path: str):
|
| 95 |
+
return get_image_response(image_path)
|
| 96 |
+
|
| 97 |
+
@router.get("/image-thumbnails/{image_path:path}", include_in_schema=False)
|
| 98 |
+
async def get_image_thumbnail(image_path: str):
|
| 99 |
+
return get_thumbnail_response(image_path)
|
| 100 |
+
|
| 101 |
+
@router.post("/api/images/delete")
|
| 102 |
+
async def delete_images_endpoint(body: ImageDeleteRequest, authorization: str | None = Header(default=None)):
|
| 103 |
+
require_admin(authorization)
|
| 104 |
+
return delete_images(body.paths, start_date=body.start_date.strip(), end_date=body.end_date.strip(), all_matching=body.all_matching)
|
| 105 |
+
|
| 106 |
+
@router.post("/api/images/download")
|
| 107 |
+
async def download_images_endpoint(body: ImageDownloadRequest, authorization: str | None = Header(default=None)):
|
| 108 |
+
require_admin(authorization)
|
| 109 |
+
buf = download_images_zip(body.paths)
|
| 110 |
+
return StreamingResponse(
|
| 111 |
+
buf,
|
| 112 |
+
media_type="application/zip",
|
| 113 |
+
headers={"Content-Disposition": 'attachment; filename="images.zip"'},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
@router.get("/api/images/download/{image_path:path}")
|
| 117 |
+
async def download_single_image_endpoint(image_path: str, authorization: str | None = Header(default=None)):
|
| 118 |
+
require_admin(authorization)
|
| 119 |
+
return get_image_download_response(image_path)
|
| 120 |
+
|
| 121 |
+
@router.get("/api/logs")
|
| 122 |
+
async def get_logs(type: str = "", start_date: str = "", end_date: str = "", authorization: str | None = Header(default=None)):
|
| 123 |
+
require_admin(authorization)
|
| 124 |
+
return {"items": log_service.list(type=type.strip(), start_date=start_date.strip(), end_date=end_date.strip())}
|
| 125 |
+
|
| 126 |
+
@router.post("/api/logs/delete")
|
| 127 |
+
async def delete_logs(body: LogDeleteRequest, authorization: str | None = Header(default=None)):
|
| 128 |
+
require_admin(authorization)
|
| 129 |
+
return log_service.delete(body.ids)
|
| 130 |
+
|
| 131 |
+
@router.post("/api/proxy/test")
|
| 132 |
+
async def test_proxy_endpoint(body: ProxyTestRequest, authorization: str | None = Header(default=None)):
|
| 133 |
+
require_admin(authorization)
|
| 134 |
+
candidate = (body.url or "").strip() or config.get_proxy_settings()
|
| 135 |
+
if not candidate:
|
| 136 |
+
raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
|
| 137 |
+
return {"result": await run_in_threadpool(test_proxy, candidate)}
|
| 138 |
+
|
| 139 |
+
@router.get("/api/storage/info")
|
| 140 |
+
async def get_storage_info(authorization: str | None = Header(default=None)):
|
| 141 |
+
require_admin(authorization)
|
| 142 |
+
storage = config.get_storage_backend()
|
| 143 |
+
return {
|
| 144 |
+
"backend": storage.get_backend_info(),
|
| 145 |
+
"health": storage.health_check(),
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
@router.post("/api/backup/test")
|
| 149 |
+
async def test_backup_connection(authorization: str | None = Header(default=None)):
|
| 150 |
+
require_admin(authorization)
|
| 151 |
+
try:
|
| 152 |
+
return {"result": await run_in_threadpool(backup_service.test_connection)}
|
| 153 |
+
except BackupError as exc:
|
| 154 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 155 |
+
|
| 156 |
+
@router.post("/api/image-storage/test")
|
| 157 |
+
async def test_image_storage_endpoint(authorization: str | None = Header(default=None)):
|
| 158 |
+
require_admin(authorization)
|
| 159 |
+
return {"result": await run_in_threadpool(image_storage_service.test_webdav)}
|
| 160 |
+
|
| 161 |
+
@router.post("/api/image-storage/sync")
|
| 162 |
+
async def sync_image_storage_endpoint(authorization: str | None = Header(default=None)):
|
| 163 |
+
require_admin(authorization)
|
| 164 |
+
try:
|
| 165 |
+
return {"result": await run_in_threadpool(image_storage_service.sync_all)}
|
| 166 |
+
except ImageStorageError as exc:
|
| 167 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 168 |
+
|
| 169 |
+
@router.get("/api/backups")
|
| 170 |
+
async def get_backups(authorization: str | None = Header(default=None)):
|
| 171 |
+
require_admin(authorization)
|
| 172 |
+
try:
|
| 173 |
+
return {
|
| 174 |
+
"items": await run_in_threadpool(backup_service.list_backups),
|
| 175 |
+
"state": backup_service.get_status(),
|
| 176 |
+
"settings": backup_service.get_settings(),
|
| 177 |
+
}
|
| 178 |
+
except BackupError as exc:
|
| 179 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 180 |
+
|
| 181 |
+
@router.post("/api/backups/run")
|
| 182 |
+
async def run_backup_endpoint(authorization: str | None = Header(default=None)):
|
| 183 |
+
require_admin(authorization)
|
| 184 |
+
try:
|
| 185 |
+
return {"result": await run_in_threadpool(backup_service.run_backup)}
|
| 186 |
+
except BackupError as exc:
|
| 187 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 188 |
+
|
| 189 |
+
@router.post("/api/backups/delete")
|
| 190 |
+
async def delete_backup_endpoint(body: BackupDeleteRequest, authorization: str | None = Header(default=None)):
|
| 191 |
+
require_admin(authorization)
|
| 192 |
+
try:
|
| 193 |
+
await run_in_threadpool(backup_service.delete_backup, body.key)
|
| 194 |
+
return {"ok": True}
|
| 195 |
+
except BackupError as exc:
|
| 196 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 197 |
+
|
| 198 |
+
@router.get("/api/backups/detail")
|
| 199 |
+
async def get_backup_detail(key: str = "", authorization: str | None = Header(default=None)):
|
| 200 |
+
require_admin(authorization)
|
| 201 |
+
try:
|
| 202 |
+
return {"item": await run_in_threadpool(backup_service.get_backup_detail, key)}
|
| 203 |
+
except BackupError as exc:
|
| 204 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 205 |
+
|
| 206 |
+
@router.get("/api/backups/download")
|
| 207 |
+
async def download_backup_endpoint(key: str = "", authorization: str | None = Header(default=None)):
|
| 208 |
+
require_admin(authorization)
|
| 209 |
+
try:
|
| 210 |
+
item = await run_in_threadpool(backup_service.download_backup, key)
|
| 211 |
+
except BackupError as exc:
|
| 212 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 213 |
+
filename = str(item.get("name") or "backup.bin")
|
| 214 |
+
quoted = quote(filename)
|
| 215 |
+
headers = {
|
| 216 |
+
"Content-Disposition": f"attachment; filename*=UTF-8''{quoted}",
|
| 217 |
+
"Content-Length": str(int(item.get("size") or 0)),
|
| 218 |
+
}
|
| 219 |
+
return Response(
|
| 220 |
+
content=bytes(item.get("payload") or b""),
|
| 221 |
+
media_type=str(item.get("content_type") or "application/octet-stream"),
|
| 222 |
+
headers=headers,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@router.get("/api/images/tags")
|
| 227 |
+
async def list_image_tags(authorization: str | None = Header(default=None)):
|
| 228 |
+
require_admin(authorization)
|
| 229 |
+
return {"tags": get_all_tags()}
|
| 230 |
+
|
| 231 |
+
@router.post("/api/images/tags")
|
| 232 |
+
async def update_image_tags(body: ImageTagsRequest, authorization: str | None = Header(default=None)):
|
| 233 |
+
require_admin(authorization)
|
| 234 |
+
rel = body.path.strip().lstrip("/")
|
| 235 |
+
if not rel:
|
| 236 |
+
raise HTTPException(status_code=400, detail={"error": "path is required"})
|
| 237 |
+
tags = set_tags(rel, body.tags)
|
| 238 |
+
return {"ok": True, "tags": tags}
|
| 239 |
+
|
| 240 |
+
@router.delete("/api/images/tags/{tag}")
|
| 241 |
+
async def delete_image_tag(tag: str, authorization: str | None = Header(default=None)):
|
| 242 |
+
require_admin(authorization)
|
| 243 |
+
count = delete_tag(tag)
|
| 244 |
+
return {"ok": True, "removed_from": count}
|
| 245 |
+
|
| 246 |
+
@router.get("/api/images/storage")
|
| 247 |
+
async def get_image_storage(authorization: str | None = Header(default=None)):
|
| 248 |
+
require_admin(authorization)
|
| 249 |
+
return storage_stats()
|
| 250 |
+
|
| 251 |
+
@router.post("/api/images/storage/compress")
|
| 252 |
+
async def compress_all_images(authorization: str | None = Header(default=None)):
|
| 253 |
+
require_admin(authorization)
|
| 254 |
+
return await run_in_threadpool(compress_images)
|
| 255 |
+
|
| 256 |
+
@router.post("/api/images/storage/cleanup-to-target")
|
| 257 |
+
async def cleanup_to_target(
|
| 258 |
+
target_free_mb: int = 500,
|
| 259 |
+
dry_run: bool = False,
|
| 260 |
+
authorization: str | None = Header(default=None),
|
| 261 |
+
):
|
| 262 |
+
require_admin(authorization)
|
| 263 |
+
return await run_in_threadpool(delete_to_target, target_free_mb, dry_run)
|
| 264 |
+
|
| 265 |
+
@router.get("/health", response_model=None)
|
| 266 |
+
async def health_dashboard(format: str = Query(default="html")):
|
| 267 |
+
from services.account_service import account_service as acct_svc
|
| 268 |
+
stats = acct_svc.get_stats()
|
| 269 |
+
storage = config.get_storage_backend()
|
| 270 |
+
storage_health = storage.health_check()
|
| 271 |
+
healthy = stats["active"] > 0 or stats["unlimited_quota_count"] > 0
|
| 272 |
+
|
| 273 |
+
stats_json = {
|
| 274 |
+
"status": "ok" if healthy else "degraded",
|
| 275 |
+
"healthy": healthy,
|
| 276 |
+
"version": app_version,
|
| 277 |
+
"storage": {"backend": storage.get_backend_info(), "health": storage_health},
|
| 278 |
+
"accounts": stats,
|
| 279 |
+
}
|
| 280 |
+
if format == "json":
|
| 281 |
+
return stats_json
|
| 282 |
+
return HTMLResponse(f"""<!DOCTYPE html>
|
| 283 |
+
<html lang="zh">
|
| 284 |
+
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 285 |
+
<title>号池健康监控 - chatgpt2api</title>
|
| 286 |
+
<style>
|
| 287 |
+
*{{margin:0;padding:0;box-sizing:border-box}}
|
| 288 |
+
body{{font-family:system-ui,-apple-system,sans-serif;background:#0f1117;color:#e2e8f0;min-height:100vh}}
|
| 289 |
+
.header{{background:#1a1d27;border-bottom:1px solid #2a2d3a;padding:16px 24px;display:flex;justify-content:space-between;align-items:center}}
|
| 290 |
+
.header h1{{font-size:20px}}
|
| 291 |
+
.status-dot{{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}}
|
| 292 |
+
.status-ok{{background:#22c55e;box-shadow:0 0 8px #22c55e88}}
|
| 293 |
+
.status-degraded{{background:#f59e0b;box-shadow:0 0 8px #f59e0b88}}
|
| 294 |
+
.container{{max-width:960px;margin:0 auto;padding:24px}}
|
| 295 |
+
.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
|
| 296 |
+
.card{{background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;padding:16px}}
|
| 297 |
+
.card .value{{font-size:28px;font-weight:700;margin:4px 0}}
|
| 298 |
+
.card .label{{font-size:13px;color:#94a3b8}}
|
| 299 |
+
.green{{color:#22c55e}}.yellow{{color:#f59e0b}}.red{{color:#ef4444}}.blue{{color:#6c63ff}}
|
| 300 |
+
table{{width:100%;border-collapse:collapse;background:#1a1d27;border:1px solid #2a2d3a;border-radius:10px;overflow:hidden}}
|
| 301 |
+
th{{background:#242836;font-weight:600;text-align:left;padding:10px 12px;font-size:12px;color:#94a3b8;text-transform:uppercase}}
|
| 302 |
+
td{{padding:8px 12px;border-top:1px solid #2a2d3a;font-size:14px}}tr:hover td{{background:rgba(108,99,255,.05)}}
|
| 303 |
+
.api-url{{font-family:monospace;font-size:12px;color:#6c63ff}}
|
| 304 |
+
.refresh{{font-size:12px;color:#64748b;text-align:center;margin-top:24px}}
|
| 305 |
+
</style>
|
| 306 |
+
<meta http-equiv="refresh" content="30">
|
| 307 |
+
</head>
|
| 308 |
+
<body>
|
| 309 |
+
<div class="header">
|
| 310 |
+
<h1><span class="status-dot {'status-ok' if healthy else 'status-degraded'}"></span>号池健康监控</h1>
|
| 311 |
+
<div style="font-size:13px;color:#94a3b8">v{app_version} · 30s 自动刷新</div>
|
| 312 |
+
</div>
|
| 313 |
+
<div class="container">
|
| 314 |
+
<div class="cards">
|
| 315 |
+
<div class="card"><div class="label">号池状态</div><div class="value {'green' if healthy else 'yellow'}">{'正常' if healthy else '异常'}</div></div>
|
| 316 |
+
<div class="card"><div class="label">当前账号</div><div class="value blue">{stats['total']}</div></div>
|
| 317 |
+
<div class="card"><div class="label">累计入库</div><div class="value">{stats['cumulative_total']}</div></div>
|
| 318 |
+
<div class="card"><div class="label">可用账号</div><div class="value green">{stats['active']}</div></div>
|
| 319 |
+
<div class="card"><div class="label">无限额</div><div class="value">{stats['unlimited_quota_count']}</div></div>
|
| 320 |
+
<div class="card"><div class="label">剩余额度</div><div class="value">{stats['total_quota']}</div></div>
|
| 321 |
+
<div class="card"><div class="label">限流</div><div class="value yellow">{stats['limited']}</div></div>
|
| 322 |
+
<div class="card"><div class="label">异常</div><div class="value red">{stats['abnormal']}</div></div>
|
| 323 |
+
<div class="card"><div class="label">禁用</div><div class="value">{stats['disabled']}</div></div>
|
| 324 |
+
<div class="card"><div class="label">成功/失败</div><div class="value">{stats['total_success']}<span style="font-size:18px;color:#94a3b8">/</span><span class="red">{stats['total_fail']}</span></div></div>
|
| 325 |
+
</div>
|
| 326 |
+
<h2 style="margin-bottom:12px;font-size:16px">账号类型分布</h2>
|
| 327 |
+
<table>
|
| 328 |
+
<tr><th>类型</th><th>数量</th></tr>
|
| 329 |
+
{''.join(f'<tr><td>{t}</td><td>{c}</td></tr>' for t,c in sorted(stats['by_type'].items()))}
|
| 330 |
+
</table>
|
| 331 |
+
<div class="refresh">JSON: <span class="api-url">/health?format=json</span></div>
|
| 332 |
+
</div></body></html>""")
|
| 333 |
+
|
| 334 |
+
return router
|
docker-compose.local.yml
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
build:
|
| 4 |
+
context: .
|
| 5 |
+
dockerfile: Dockerfile
|
| 6 |
+
image: chatgpt2api:local
|
| 7 |
+
container_name: chatgpt2api-local
|
| 8 |
+
ports:
|
| 9 |
+
- "8000:80"
|
| 10 |
+
volumes:
|
| 11 |
+
- ./data:/app/data
|
| 12 |
+
- ./config.json:/app/config.json
|
| 13 |
+
environment:
|
| 14 |
+
STORAGE_BACKEND: sqlite
|
| 15 |
+
DATABASE_URL: sqlite:////app/data/accounts.db
|
| 16 |
+
# 存储后端配置 (可选值: json, sqlite, postgres, git)
|
| 17 |
+
# environment:
|
| 18 |
+
# STORAGE_BACKEND: json
|
| 19 |
+
|
| 20 |
+
# 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
| 21 |
+
# DATABASE_URL: postgresql://user:password@host:5432/dbname
|
| 22 |
+
# DATABASE_URL: sqlite:////app/data/accounts.db
|
| 23 |
+
|
| 24 |
+
# Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
|
| 25 |
+
# GIT_REPO_URL: https://github.com/user/repo.git
|
| 26 |
+
# GIT_TOKEN: ghp_xxxxxxxxxxxx
|
| 27 |
+
# GIT_BRANCH: main
|
| 28 |
+
# GIT_FILE_PATH: accounts.json
|
| 29 |
+
|
| 30 |
+
# 认证密钥 (可选,覆盖 config.json)
|
| 31 |
+
# CHATGPT2API_AUTH_KEY: your_secret_key
|
| 32 |
+
|
| 33 |
+
# 基础 URL (可选)
|
| 34 |
+
# CHATGPT2API_BASE_URL: https://your-domain.com
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
app:
|
| 3 |
+
image: ghcr.io/basketikun/chatgpt2api:latest
|
| 4 |
+
container_name: chatgpt2api
|
| 5 |
+
restart: unless-stopped
|
| 6 |
+
ports:
|
| 7 |
+
- "3000:80"
|
| 8 |
+
volumes:
|
| 9 |
+
- ./data:/app/data
|
| 10 |
+
- ./config.json:/app/config.json
|
| 11 |
+
environment:
|
| 12 |
+
# 存储后端配置 (可选值: json, sqlite, postgres, git)
|
| 13 |
+
- STORAGE_BACKEND=json
|
| 14 |
+
|
| 15 |
+
# 数据库配置 (当 STORAGE_BACKEND=sqlite/postgres 时使用)
|
| 16 |
+
# - DATABASE_URL=postgresql://user:password@host:5432/dbname
|
| 17 |
+
# - DATABASE_URL=sqlite:///app/data/accounts.db
|
| 18 |
+
|
| 19 |
+
# Git 仓库配置 (当 STORAGE_BACKEND=git 时使用)
|
| 20 |
+
# - GIT_REPO_URL=https://github.com/user/repo.git
|
| 21 |
+
# - GIT_TOKEN=ghp_xxxxxxxxxxxx
|
| 22 |
+
# - GIT_BRANCH=main
|
| 23 |
+
# - GIT_FILE_PATH=accounts.json
|
| 24 |
+
|
| 25 |
+
# 认证密钥 (可选,覆盖 config.json)
|
| 26 |
+
# - CHATGPT2API_AUTH_KEY=your_secret_key
|
| 27 |
+
|
| 28 |
+
# 基础 URL (可选)
|
| 29 |
+
# - CHATGPT2API_BASE_URL=https://your-domain.com
|
docs/feature-status.en.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 功能状态
|
| 2 |
+
|
| 3 |
+
本文基于当前仓库当前实现整理,用于帮助用户快速了解哪些功能已经可用、哪些仍在完善、哪些待实现。
|
| 4 |
+
|
| 5 |
+
| 功能 | 状态 | 说明 |
|
| 6 |
+
|:----------------------------------------|:--:|:--------------------------------------------------------------|
|
| 7 |
+
| OpenAI 兼容 `POST /v1/images/generations` | ✅ | 已支持,用于图片生成,并可通过 `n` 返回多张图片。 |
|
| 8 |
+
| OpenAI 兼容 `POST /v1/images/edits` | ✅ | 已支持,可上传图片进行编辑。 |
|
| 9 |
+
| 面向图片工作流的 `POST /v1/chat/completions` | ✅ | 已支持图片相关请求。 |
|
| 10 |
+
| 面向图片工作流的 `POST /v1/responses` | ✅ | 已支持图片生成工具调用。 |
|
| 11 |
+
| `GET /v1/models` 接口 | ✅ | 当前返回 `gpt-image-2`、`codex-gpt-image-2`、`auto`、`gpt-5`、`gpt-5-1`、`gpt-5-2`、`gpt-5-3`、`gpt-5-3-mini`、`gpt-5-mini`。 |
|
| 12 |
+
| 同时生成多张图片 | ✅ | 已支持,后端与前端都可进行多图生成。 |
|
| 13 |
+
| 前端图片工作台 | ✅ | 已支持图片生成、图片编辑、模型选择、历史记录与查看大图。 |
|
| 14 |
+
| 前端图片输入 / 参考图交互 | ✅ | 已支持参考图上传、预览、移除和编辑模式工作流。 |
|
| 15 |
+
| Codex 画图接口逆向 | ✅ | 已支持,仅 `Plus` / `Team` / `Pro` 订阅可用,模型别名为 `codex-gpt-image-2`;如有需要可自行在其他场景映射回 `gpt-image-2`。这是 Codex 逆向链路,用于和官网画图区分,同一账号通常会同时支持官网和 Codex 两份生图额度。 |
|
| 16 |
+
| Cherry Studio 接入 | ✅ | 已支持作为绘图接口接入 Cherry Studio。 |
|
| 17 |
+
| New API 接入 | ✅ | 已支持接入 New API。 |
|
| 18 |
+
| 账号池管理 | ✅ | 已支持列表、筛选、批量操作、导出、手动编辑、刷新和删除。 |
|
| 19 |
+
| 账号额度刷新与恢复时间同步 | ✅ | 已支持账号信息刷新,限流账号也会自动继续检查。 |
|
| 20 |
+
| 失效 Token 自动清理 | ✅ | 已支持自动移除失效 Token。 |
|
| 21 |
+
| CPA 连接管理 | ✅ | 已支持 CPA 连接的新增、修改、查询和删除。 |
|
| 22 |
+
| CPA 文件浏览与按需导入 | ✅ | 已支持读取远程文件列表、筛选、勾选并导入到本地号池。 |
|
| 23 |
+
| CPA 导入进度跟踪 | ✅ | 已支持导入进度展示与轮询更新。 |
|
| 24 |
+
| `sub2api` 连接管理与账号浏览 | ✅ | 已支持 `sub2api` 服务器的新增、修改、删除、分组查询和 OpenAI OAuth 账号列表读取。 |
|
| 25 |
+
| `sub2api` 导入 | ✅ | 已支持勾选 `sub2api` 中的 OpenAI OAuth 账号,批量拉取 `access_token` 导入本地号池,并展示导入进度。 |
|
| 26 |
+
| Docker 自托管部署 | ✅ | 已支持 Docker Compose 部署,并提供多架构镜像。 |
|
| 27 |
+
| 兼容接口中的多参考图能力 | ✅ | 已实现,支持在兼容接口中传入多参考图。 |
|
| 28 |
+
| 更高级的 Token 调度策略 | ⚠️ | 当前已有基础轮询与限流刷新机制,更复杂的调度策略仍在完善中。 |
|
| 29 |
+
| Render / Vercel 等部署表述 | ⚠️ | 当前主要以 Docker 部署为主,其他平台部署方式暂未重点说明。 |
|
| 30 |
+
| `/v1/complete` 文本补全与流式输出 | ✅ | 已实现。 |
|
| 31 |
+
| 流式输出支持 | ✅ | 已实现。 |
|
| 32 |
+
| 文本补全缓存与重复请求合并 | ✅ | `/v1/chat/completions` 文本链路默认启用 60 秒短缓存、流式结果回放、in-flight 请求合并和相邻重复消息清理;可通过 `chat_completion_cache` 配置关闭或调整。 |
|
| 33 |
+
| 图片尺寸参数 | ❌ | 待实现。 |
|
| 34 |
+
| 服务端图片 URL 缓存 | ✅ | 已实现。 |
|
| 35 |
+
| `rt_token` 刷新 | ❌ | 待实现。 |
|
| 36 |
+
| 代理配置功能 | ✅ | 已支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理,并应用到出站请求。 |
|
| 37 |
+
| Anthropic 协议支持 | ❌ | 待实现。 |
|
docs/review.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Review
|
| 2 |
+
|
| 3 |
+
- [P2] Poll when the image tool was invoked
|
| 4 |
+
`services/protocol/conversation.py:586`
|
| 5 |
+
对于没有输入图片的图像生成任务,延迟结果仍可能在 SSE 返回 `tool_invoked: true` 之后到达,但这里新的判断条件忽略了 `tool_invoked`。这样会直接返回中间文本,而不会继续轮询会话来拿图片 ID。
|
| 6 |
+
|
| 7 |
+
- [P2] Align CloudMail domain validation with the UI
|
| 8 |
+
`web/src/app/register/components/register-card.tsx:266`
|
| 9 |
+
`cloudmail_gen` 的 placeholder 写的是留空会使用服务默认域名,但后端 `create_mailbox` 不接受空域名。用户按 UI 提示保存后,这个 provider 会在真正创建地址前失败。
|
docs/upstream-sse-conversation.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 上游 Conversation SSE 协议说明
|
| 2 |
+
|
| 3 |
+
Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。
|
| 4 |
+
|
| 5 |
+
## 基本形态
|
| 6 |
+
|
| 7 |
+
常见 payload 示例:
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
"v1"
|
| 11 |
+
{"type":"resume_conversation_token",...}
|
| 12 |
+
{"p":"","o":"add","v":{...}}
|
| 13 |
+
{"v":{...}}
|
| 14 |
+
{"p":"/message/content/parts/0","o":"append","v":"..."}
|
| 15 |
+
{"type":"server_ste_metadata","metadata":{...}}
|
| 16 |
+
[DONE]
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
处理建议:
|
| 20 |
+
|
| 21 |
+
| payload | 含义 | 处理方式 |
|
| 22 |
+
|:--|:--|:--|
|
| 23 |
+
| `"v1"` | 协议版本标记 | 可记录,通常不影响业务 |
|
| 24 |
+
| `[DONE]` | 当前 SSE 流结束 | 停止继续读取 |
|
| 25 |
+
| JSON object | 事件、消息或 patch | 按字段更新会话状态 |
|
| 26 |
+
| JSON string | 短文本 patch 或协议标记 | 结合上下文处理 |
|
| 27 |
+
| 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 |
|
| 28 |
+
|
| 29 |
+
## 常用字段
|
| 30 |
+
|
| 31 |
+
| 字段 | 说明 |
|
| 32 |
+
|:--|:--|
|
| 33 |
+
| `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` |
|
| 34 |
+
| `conversation_id` | 当前会话 ID,可从多个事件中获得 |
|
| 35 |
+
| `p` | patch 路径,例如 `/message/content/parts/0` |
|
| 36 |
+
| `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` |
|
| 37 |
+
| `v` | patch 值,可能是字符串、数组,也可能包含完整 message |
|
| 38 |
+
| `c` | 消息序号或游标,常见于 add 类事件 |
|
| 39 |
+
| `message.id` | 消息 ID |
|
| 40 |
+
| `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` |
|
| 41 |
+
| `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` |
|
| 42 |
+
| `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 |
|
| 43 |
+
| `message.status` | 消息状态,如 `in_progress`、`finished_successfully` |
|
| 44 |
+
| `message.end_turn` | 是否结束当前轮次 |
|
| 45 |
+
| `metadata.tool_invoked` | 本轮是否调用工具 |
|
| 46 |
+
| `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` |
|
| 47 |
+
| `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` |
|
| 48 |
+
|
| 49 |
+
## 会话启动事件
|
| 50 |
+
|
| 51 |
+
上游通常会先返回恢复令牌或会话令牌:
|
| 52 |
+
|
| 53 |
+
```json
|
| 54 |
+
{
|
| 55 |
+
"type": "resume_conversation_token",
|
| 56 |
+
"kind": "topic",
|
| 57 |
+
"token": "...",
|
| 58 |
+
"conversation_id": "..."
|
| 59 |
+
}
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。
|
| 63 |
+
|
| 64 |
+
## 消息 add 场景
|
| 65 |
+
|
| 66 |
+
完整消息可能通过 `add` 或带 `v.message` 的事件出现:
|
| 67 |
+
|
| 68 |
+
```json
|
| 69 |
+
{
|
| 70 |
+
"p": "",
|
| 71 |
+
"o": "add",
|
| 72 |
+
"v": {
|
| 73 |
+
"message": {
|
| 74 |
+
"author": {"role": "assistant"},
|
| 75 |
+
"content": {"content_type": "text", "parts": [""]},
|
| 76 |
+
"status": "in_progress"
|
| 77 |
+
},
|
| 78 |
+
"conversation_id": "..."
|
| 79 |
+
},
|
| 80 |
+
"c": 3
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。
|
| 85 |
+
|
| 86 |
+
## 文本增量场景
|
| 87 |
+
|
| 88 |
+
文本输出通常由多条 patch 组成:
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{"p":"/message/content/parts/0","o":"append","v":"Hello"}
|
| 92 |
+
{"v":" world"}
|
| 93 |
+
{"p":"","o":"patch","v":[
|
| 94 |
+
{"p":"/message/content/parts/0","o":"append","v":"!"},
|
| 95 |
+
{"p":"/message/status","o":"replace","v":"finished_successfully"},
|
| 96 |
+
{"p":"/message/end_turn","o":"replace","v":true}
|
| 97 |
+
]}
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
处理要点:
|
| 101 |
+
|
| 102 |
+
| 形态 | 含义 |
|
| 103 |
+
|:--|:--|
|
| 104 |
+
| `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 |
|
| 105 |
+
| `o == "replace"` | 用新值替换目标字段 |
|
| 106 |
+
| `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 |
|
| 107 |
+
| 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 |
|
| 108 |
+
|
| 109 |
+
## 输入消息场景
|
| 110 |
+
|
| 111 |
+
用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图:
|
| 112 |
+
|
| 113 |
+
```json
|
| 114 |
+
{
|
| 115 |
+
"type": "input_message",
|
| 116 |
+
"input_message": {
|
| 117 |
+
"author": {"role": "user"},
|
| 118 |
+
"content": {
|
| 119 |
+
"content_type": "multimodal_text",
|
| 120 |
+
"parts": [
|
| 121 |
+
{"asset_pointer": "sediment://file_input"},
|
| 122 |
+
"编辑提示词"
|
| 123 |
+
]
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
"conversation_id": "..."
|
| 127 |
+
}
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。
|
| 131 |
+
|
| 132 |
+
## 图片工具成功场景
|
| 133 |
+
|
| 134 |
+
图片生成或图片编辑成功时,上游一般会出现工具消息:
|
| 135 |
+
|
| 136 |
+
```json
|
| 137 |
+
{
|
| 138 |
+
"v": {
|
| 139 |
+
"message": {
|
| 140 |
+
"author": {"role": "tool"},
|
| 141 |
+
"content": {
|
| 142 |
+
"content_type": "multimodal_text",
|
| 143 |
+
"parts": [
|
| 144 |
+
{"asset_pointer": "file-service://file_result"},
|
| 145 |
+
{"asset_pointer": "sediment://file_result"}
|
| 146 |
+
]
|
| 147 |
+
},
|
| 148 |
+
"metadata": {"async_task_type": "image_gen"}
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"conversation_id": "..."
|
| 152 |
+
}
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
只有同时满足以下条件的图片指针,才应该视为输出结果:
|
| 156 |
+
|
| 157 |
+
| 条件 | 说明 |
|
| 158 |
+
|:--|:--|
|
| 159 |
+
| `message.author.role == "tool"` | 来源是工具消息 |
|
| 160 |
+
| `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 |
|
| 161 |
+
| `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 |
|
| 162 |
+
|
| 163 |
+
## 图片指针类型
|
| 164 |
+
|
| 165 |
+
| 指针 | 常见来源 | 说明 |
|
| 166 |
+
|:--|:--|:--|
|
| 167 |
+
| `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 |
|
| 168 |
+
| `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 |
|
| 169 |
+
| `file_upload` | 上传过程占位 | 通常不应作为输出 |
|
| 170 |
+
|
| 171 |
+
不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。
|
| 172 |
+
|
| 173 |
+
## 策略拒绝场景
|
| 174 |
+
|
| 175 |
+
当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本:
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
I can't assist with that request. If you have another type of modification...
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
常见伴随事件:
|
| 182 |
+
|
| 183 |
+
```json
|
| 184 |
+
{"type":"title_generation","title":"Request Denied","conversation_id":"..."}
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
```json
|
| 188 |
+
{
|
| 189 |
+
"type": "server_ste_metadata",
|
| 190 |
+
"metadata": {
|
| 191 |
+
"tool_invoked": false,
|
| 192 |
+
"turn_use_case": "multimodal",
|
| 193 |
+
"did_prompt_contain_image": true
|
| 194 |
+
},
|
| 195 |
+
"conversation_id": "..."
|
| 196 |
+
}
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
处理要点:
|
| 200 |
+
|
| 201 |
+
| 条件 | 行为 |
|
| 202 |
+
|:--|:--|
|
| 203 |
+
| 有 assistant 拒绝文本 | 应返回文本消息 |
|
| 204 |
+
| `tool_invoked == false` | 说明没有实际工具结果 |
|
| 205 |
+
| 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 |
|
| 206 |
+
| 用户输入消息里有图片指针 | 仍然只视为输入附件 |
|
| 207 |
+
|
| 208 |
+
## moderation 场景
|
| 209 |
+
|
| 210 |
+
部分请求可能返回 moderation 事件:
|
| 211 |
+
|
| 212 |
+
```json
|
| 213 |
+
{
|
| 214 |
+
"type": "moderation",
|
| 215 |
+
"moderation_response": {
|
| 216 |
+
"blocked": true
|
| 217 |
+
},
|
| 218 |
+
"conversation_id": "..."
|
| 219 |
+
}
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。
|
| 223 |
+
|
| 224 |
+
## marker 和 title 事件
|
| 225 |
+
|
| 226 |
+
上游会返回一些辅助事件:
|
| 227 |
+
|
| 228 |
+
```json
|
| 229 |
+
{"type":"message_marker","marker":"user_visible_token","event":"first"}
|
| 230 |
+
{"type":"message_marker","marker":"last_token","event":"last"}
|
| 231 |
+
{"type":"title_generation","title":"...","conversation_id":"..."}
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。
|
| 235 |
+
|
| 236 |
+
## metadata 事件
|
| 237 |
+
|
| 238 |
+
`server_ste_metadata` 用于描述本轮调度和工具状态:
|
| 239 |
+
|
| 240 |
+
```json
|
| 241 |
+
{
|
| 242 |
+
"type": "server_ste_metadata",
|
| 243 |
+
"metadata": {
|
| 244 |
+
"tool_invoked": true,
|
| 245 |
+
"turn_use_case": "multimodal",
|
| 246 |
+
"model_slug": "i-mini-m",
|
| 247 |
+
"did_prompt_contain_image": true
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
常用判断:
|
| 253 |
+
|
| 254 |
+
| 字段 | 说明 |
|
| 255 |
+
|:--|:--|
|
| 256 |
+
| `tool_invoked == true` | 上游认为本轮调用过工具 |
|
| 257 |
+
| `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 |
|
| 258 |
+
| `turn_use_case == "text"` | 按文本响应处理 |
|
| 259 |
+
| `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 |
|
| 260 |
+
| `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 |
|
| 261 |
+
|
| 262 |
+
## 结束后的结果判断
|
| 263 |
+
|
| 264 |
+
SSE 结束后可按以下顺序判断结果:
|
| 265 |
+
|
| 266 |
+
1. 如果已经收集到图片工具输出指针,解析并下载输出图片。
|
| 267 |
+
2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。
|
| 268 |
+
3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。
|
| 269 |
+
4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。
|
| 270 |
+
5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。
|
| 271 |
+
|
main.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uvicorn
|
| 4 |
+
from api import create_app
|
| 5 |
+
|
| 6 |
+
app = create_app()
|
| 7 |
+
|
| 8 |
+
if __name__ == "__main__":
|
| 9 |
+
uvicorn.run(app, access_log=False, log_level="info")
|
pyproject.toml
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "chatgpt2api"
|
| 3 |
+
version = "0.1.0"
|
| 4 |
+
description = "Add your description here"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
requires-python = ">=3.13"
|
| 7 |
+
dependencies = [
|
| 8 |
+
"curl-cffi>=0.15.0",
|
| 9 |
+
"fastapi>=0.136.0",
|
| 10 |
+
"pillow>=12.2.0",
|
| 11 |
+
"pybase64>=1.4.3",
|
| 12 |
+
"python-multipart>=0.0.26",
|
| 13 |
+
"tiktoken>=0.12.0",
|
| 14 |
+
"uvicorn>=0.44.0",
|
| 15 |
+
"sqlalchemy>=2.0.0",
|
| 16 |
+
"psycopg2-binary>=2.9.0",
|
| 17 |
+
"gitpython>=3.1.0",
|
| 18 |
+
]
|
| 19 |
+
|
| 20 |
+
[dependency-groups]
|
| 21 |
+
dev = [
|
| 22 |
+
"httpx>=0.28.1",
|
| 23 |
+
]
|
| 24 |
+
|
| 25 |
+
[[tool.uv.index]]
|
| 26 |
+
url = "https://mirrors.aliyun.com/pypi/simple"
|
| 27 |
+
default = true
|
scripts/migrate_storage.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
存储后端数据迁移脚本
|
| 4 |
+
|
| 5 |
+
用法:
|
| 6 |
+
python scripts/migrate_storage.py --from json --to postgres
|
| 7 |
+
python scripts/migrate_storage.py --from postgres --to git
|
| 8 |
+
python scripts/migrate_storage.py --export accounts.json
|
| 9 |
+
python scripts/migrate_storage.py --import accounts.json
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
# 添加项目根目录到 Python 路径
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 20 |
+
|
| 21 |
+
DATA_DIR = Path(__file__).resolve().parents[1] / "data"
|
| 22 |
+
|
| 23 |
+
from services.storage.factory import create_storage_backend
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def export_to_json(output_file: str):
|
| 27 |
+
"""导出当前存储后端的数据到 JSON 文件"""
|
| 28 |
+
print(f"[migrate] Exporting data to {output_file}")
|
| 29 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
storage = create_storage_backend(DATA_DIR)
|
| 31 |
+
accounts = storage.load_accounts()
|
| 32 |
+
|
| 33 |
+
output_path = Path(output_file)
|
| 34 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
output_path.write_text(
|
| 36 |
+
json.dumps(accounts, ensure_ascii=False, indent=2) + "\n",
|
| 37 |
+
encoding="utf-8",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
print(f"[migrate] Exported {len(accounts)} accounts to {output_file}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def import_from_json(input_file: str):
|
| 44 |
+
"""从 JSON 文件导入数据到当前存储后端"""
|
| 45 |
+
print(f"[migrate] Importing data from {input_file}")
|
| 46 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 47 |
+
input_path = Path(input_file)
|
| 48 |
+
if not input_path.exists():
|
| 49 |
+
print(f"[migrate] Error: File not found: {input_file}")
|
| 50 |
+
sys.exit(1)
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
accounts = json.loads(input_path.read_text(encoding="utf-8"))
|
| 54 |
+
if not isinstance(accounts, list):
|
| 55 |
+
print(f"[migrate] Error: Invalid JSON format, expected array")
|
| 56 |
+
sys.exit(1)
|
| 57 |
+
except json.JSONDecodeError as e:
|
| 58 |
+
print(f"[migrate] Error: Invalid JSON: {e}")
|
| 59 |
+
sys.exit(1)
|
| 60 |
+
|
| 61 |
+
storage = create_storage_backend(DATA_DIR)
|
| 62 |
+
storage.save_accounts(accounts)
|
| 63 |
+
|
| 64 |
+
print(f"[migrate] Imported {len(accounts)} accounts")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def migrate_data(from_backend: str, to_backend: str):
|
| 68 |
+
"""从一个存储后端迁移到另一个"""
|
| 69 |
+
print(f"[migrate] Migrating from {from_backend} to {to_backend}")
|
| 70 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
# 保存原始环境变量
|
| 72 |
+
original_backend = os.environ.get("STORAGE_BACKEND")
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
# 从源后端读取数据
|
| 76 |
+
os.environ["STORAGE_BACKEND"] = from_backend
|
| 77 |
+
from_storage = create_storage_backend(DATA_DIR)
|
| 78 |
+
accounts = from_storage.load_accounts()
|
| 79 |
+
print(f"[migrate] Loaded {len(accounts)} accounts from {from_backend}")
|
| 80 |
+
|
| 81 |
+
# 写入目标后端
|
| 82 |
+
os.environ["STORAGE_BACKEND"] = to_backend
|
| 83 |
+
to_storage = create_storage_backend(DATA_DIR)
|
| 84 |
+
to_storage.save_accounts(accounts)
|
| 85 |
+
print(f"[migrate] Saved {len(accounts)} accounts to {to_backend}")
|
| 86 |
+
|
| 87 |
+
print(f"[migrate] Migration completed successfully!")
|
| 88 |
+
|
| 89 |
+
finally:
|
| 90 |
+
# 恢复原始环境变量
|
| 91 |
+
if original_backend:
|
| 92 |
+
os.environ["STORAGE_BACKEND"] = original_backend
|
| 93 |
+
elif "STORAGE_BACKEND" in os.environ:
|
| 94 |
+
del os.environ["STORAGE_BACKEND"]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def main():
|
| 98 |
+
parser = argparse.ArgumentParser(
|
| 99 |
+
description="ChatGPT2API 存储后端数据迁移工具",
|
| 100 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 101 |
+
epilog="""
|
| 102 |
+
示例:
|
| 103 |
+
# 从 JSON 迁移到 PostgreSQL
|
| 104 |
+
python scripts/migrate_storage.py --from json --to postgres
|
| 105 |
+
|
| 106 |
+
# 从 PostgreSQL 迁移到 Git
|
| 107 |
+
python scripts/migrate_storage.py --from postgres --to git
|
| 108 |
+
|
| 109 |
+
# 导出当前数据到 JSON 文件
|
| 110 |
+
python scripts/migrate_storage.py --export backup.json
|
| 111 |
+
|
| 112 |
+
# 从 JSON 文件导入数据
|
| 113 |
+
python scripts/migrate_storage.py --import backup.json
|
| 114 |
+
|
| 115 |
+
环境变量:
|
| 116 |
+
STORAGE_BACKEND - 存储后端类型 (json, sqlite, postgres, git)
|
| 117 |
+
DATABASE_URL - 数据库连接字符串
|
| 118 |
+
GIT_REPO_URL - Git 仓库地址
|
| 119 |
+
GIT_TOKEN - Git 访问令牌
|
| 120 |
+
"""
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
parser.add_argument(
|
| 124 |
+
"--from",
|
| 125 |
+
dest="from_backend",
|
| 126 |
+
choices=["json", "sqlite", "postgres", "git"],
|
| 127 |
+
help="源存储后端",
|
| 128 |
+
)
|
| 129 |
+
parser.add_argument(
|
| 130 |
+
"--to",
|
| 131 |
+
dest="to_backend",
|
| 132 |
+
choices=["json", "sqlite", "postgres", "git"],
|
| 133 |
+
help="目标存储后端",
|
| 134 |
+
)
|
| 135 |
+
parser.add_argument(
|
| 136 |
+
"--export",
|
| 137 |
+
dest="export_file",
|
| 138 |
+
metavar="FILE",
|
| 139 |
+
help="导出数据到 JSON 文件",
|
| 140 |
+
)
|
| 141 |
+
parser.add_argument(
|
| 142 |
+
"--import",
|
| 143 |
+
dest="import_file",
|
| 144 |
+
metavar="FILE",
|
| 145 |
+
help="从 JSON 文件导入数据",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
args = parser.parse_args()
|
| 149 |
+
|
| 150 |
+
# 检查参数
|
| 151 |
+
if args.from_backend and args.to_backend:
|
| 152 |
+
migrate_data(args.from_backend, args.to_backend)
|
| 153 |
+
elif args.export_file:
|
| 154 |
+
export_to_json(args.export_file)
|
| 155 |
+
elif args.import_file:
|
| 156 |
+
import_from_json(args.import_file)
|
| 157 |
+
else:
|
| 158 |
+
parser.print_help()
|
| 159 |
+
sys.exit(1)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
if __name__ == "__main__":
|
| 163 |
+
main()
|
scripts/test_storage.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
存储后端测试脚本
|
| 4 |
+
|
| 5 |
+
用法:
|
| 6 |
+
python scripts/test_storage.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
# 添加项目根目录到 Python 路径
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 15 |
+
|
| 16 |
+
DATA_DIR = Path(__file__).resolve().parents[1] / "data"
|
| 17 |
+
|
| 18 |
+
from services.storage.factory import create_storage_backend
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_storage():
|
| 22 |
+
"""测试当前配置的存储后端"""
|
| 23 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 24 |
+
print("=" * 60)
|
| 25 |
+
print("ChatGPT2API 存储后端测试")
|
| 26 |
+
print("=" * 60)
|
| 27 |
+
|
| 28 |
+
# 显示当前配置
|
| 29 |
+
backend_type = os.getenv("STORAGE_BACKEND", "json")
|
| 30 |
+
print(f"\n当前存储后端: {backend_type}")
|
| 31 |
+
|
| 32 |
+
if backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
|
| 33 |
+
database_url = os.getenv("DATABASE_URL", "")
|
| 34 |
+
if database_url:
|
| 35 |
+
# 隐藏密码
|
| 36 |
+
if "://" in database_url and "@" in database_url:
|
| 37 |
+
protocol, rest = database_url.split("://", 1)
|
| 38 |
+
if "@" in rest:
|
| 39 |
+
credentials, host = rest.split("@", 1)
|
| 40 |
+
if ":" in credentials:
|
| 41 |
+
username, _ = credentials.split(":", 1)
|
| 42 |
+
database_url = f"{protocol}://{username}:****@{host}"
|
| 43 |
+
print(f"数据库连接: {database_url}")
|
| 44 |
+
else:
|
| 45 |
+
print(f"数据库连接: 本地 SQLite (data/accounts.db)")
|
| 46 |
+
|
| 47 |
+
elif backend_type == "git":
|
| 48 |
+
repo_url = os.getenv("GIT_REPO_URL", "")
|
| 49 |
+
branch = os.getenv("GIT_BRANCH", "main")
|
| 50 |
+
file_path = os.getenv("GIT_FILE_PATH", "accounts.json")
|
| 51 |
+
print(f"Git 仓库: {repo_url}")
|
| 52 |
+
print(f"Git 分支: {branch}")
|
| 53 |
+
print(f"文件路径: {file_path}")
|
| 54 |
+
|
| 55 |
+
print("\n" + "=" * 60)
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
# 创建存储后端
|
| 59 |
+
print("\n[1/5] 创建存储后端...")
|
| 60 |
+
storage = create_storage_backend(DATA_DIR)
|
| 61 |
+
print("✅ 存储后端创建成功")
|
| 62 |
+
|
| 63 |
+
# 获取后端信息
|
| 64 |
+
print("\n[2/5] 获取后端信息...")
|
| 65 |
+
info = storage.get_backend_info()
|
| 66 |
+
print(f"✅ 后端类型: {info.get('type')}")
|
| 67 |
+
print(f" 描述: {info.get('description')}")
|
| 68 |
+
for key, value in info.items():
|
| 69 |
+
if key not in ('type', 'description'):
|
| 70 |
+
print(f" {key}: {value}")
|
| 71 |
+
|
| 72 |
+
# 健康检查
|
| 73 |
+
print("\n[3/5] 执行健康检查...")
|
| 74 |
+
health = storage.health_check()
|
| 75 |
+
status = health.get("status")
|
| 76 |
+
if status == "healthy":
|
| 77 |
+
print(f"✅ 健康状态: {status}")
|
| 78 |
+
else:
|
| 79 |
+
print(f"❌ 健康状态: {status}")
|
| 80 |
+
print(f" 错误: {health.get('error')}")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
# 读取数据
|
| 84 |
+
print("\n[4/5] 读取账号数据...")
|
| 85 |
+
accounts = storage.load_accounts()
|
| 86 |
+
print(f"✅ 成功读取 {len(accounts)} 个账号")
|
| 87 |
+
|
| 88 |
+
# 写入测试(可选)
|
| 89 |
+
print("\n[5/5] 测试写入功能...")
|
| 90 |
+
test_account = {
|
| 91 |
+
"access_token": "test_token_" + str(os.getpid()),
|
| 92 |
+
"type": "Free",
|
| 93 |
+
"status": "测试",
|
| 94 |
+
"quota": 0,
|
| 95 |
+
"email": "test@example.com",
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# 添加测试账号
|
| 99 |
+
test_accounts = accounts + [test_account]
|
| 100 |
+
storage.save_accounts(test_accounts)
|
| 101 |
+
print("✅ 写入测试账号成功")
|
| 102 |
+
|
| 103 |
+
# 验证写入
|
| 104 |
+
reloaded = storage.load_accounts()
|
| 105 |
+
if len(reloaded) == len(test_accounts):
|
| 106 |
+
print("✅ 验证写入成功")
|
| 107 |
+
else:
|
| 108 |
+
print(f"❌ 验证失败: 期望 {len(test_accounts)} 个账号,实际 {len(reloaded)} 个")
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
# 恢复原始数据
|
| 112 |
+
storage.save_accounts(accounts)
|
| 113 |
+
print("✅ 恢复原始数据")
|
| 114 |
+
|
| 115 |
+
print("\n" + "=" * 60)
|
| 116 |
+
print("✅ 所有测试通过!")
|
| 117 |
+
print("=" * 60)
|
| 118 |
+
return True
|
| 119 |
+
|
| 120 |
+
except Exception as e:
|
| 121 |
+
print(f"\n❌ 测试失败: {e}")
|
| 122 |
+
import traceback
|
| 123 |
+
traceback.print_exc()
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
success = test_storage()
|
| 129 |
+
sys.exit(0 if success else 1)
|
scripts/verify_oauth_refresh.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""验证 OAuth 账号的自动刷新是否生效。
|
| 2 |
+
|
| 3 |
+
用法(在容器内,工作目录 /app):
|
| 4 |
+
uv run python scripts/verify_oauth_refresh.py # 只读诊断,安全
|
| 5 |
+
uv run python scripts/verify_oauth_refresh.py --force # 真正触发一次刷新
|
| 6 |
+
|
| 7 |
+
只读模式:列出每个账号的 access_token 剩余有效期、是否带 refresh_token、
|
| 8 |
+
上次刷新时间与上次刷新错误,判断"有没有料可刷"。
|
| 9 |
+
--force :对每个带 refresh_token 的账号强制走一次 refresh_access_token(force=True),
|
| 10 |
+
直接验证 refresh_token + 本项目 client_id 能否从 OpenAI 换出新 access_token。
|
| 11 |
+
这会真实轮换 access_token(新 token 有效,不损坏账号)。
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
from services.account_service import account_service
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _fmt_remaining(seconds: int | None) -> str:
|
| 21 |
+
if seconds is None:
|
| 22 |
+
return "无法解析 exp"
|
| 23 |
+
if seconds <= 0:
|
| 24 |
+
return f"已过期 {(-seconds) // 3600}h"
|
| 25 |
+
return f"{seconds // 3600}h{(seconds % 3600) // 60}m 后过期"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def diagnose() -> list[str]:
|
| 29 |
+
"""只读:打印每个账号的刷新就绪状态,返回带 refresh_token 的 token 列表。"""
|
| 30 |
+
tokens = account_service.list_tokens()
|
| 31 |
+
print(f"账号总数: {len(tokens)}\n")
|
| 32 |
+
refreshable: list[str] = []
|
| 33 |
+
for token in tokens:
|
| 34 |
+
account = account_service.get_account(token) or {}
|
| 35 |
+
remaining = account_service._token_expires_in(token)
|
| 36 |
+
has_rt = bool(str(account.get("refresh_token") or "").strip())
|
| 37 |
+
if has_rt:
|
| 38 |
+
refreshable.append(token)
|
| 39 |
+
print(f"- email={account.get('email') or '(未知)'}")
|
| 40 |
+
print(f" access_token[:20] = {token[:20]}...")
|
| 41 |
+
print(f" 距过期 = {_fmt_remaining(remaining)}")
|
| 42 |
+
print(f" refresh_token = {'有 ✅' if has_rt else '无 ❌(无法自动刷新)'}")
|
| 43 |
+
print(f" last_token_refresh_at = {account.get('last_token_refresh_at')}")
|
| 44 |
+
print(f" last_token_refresh_error = {account.get('last_token_refresh_error')}")
|
| 45 |
+
print()
|
| 46 |
+
return refreshable
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def force_refresh(tokens: list[str]) -> None:
|
| 50 |
+
"""对每个账号 force 刷新一次,并对比前后状态判断成败。"""
|
| 51 |
+
if not tokens:
|
| 52 |
+
print("没有带 refresh_token 的账号,无法验证刷新。")
|
| 53 |
+
return
|
| 54 |
+
print("=" * 60)
|
| 55 |
+
print(f"开始对 {len(tokens)} 个账号 force 刷新(真实调用 OpenAI)...\n")
|
| 56 |
+
ok = 0
|
| 57 |
+
for token in tokens:
|
| 58 |
+
before = account_service.get_account(token) or {}
|
| 59 |
+
new_token = account_service.refresh_access_token(token, force=True, event="manual_verify")
|
| 60 |
+
after = account_service.get_account(new_token) or {}
|
| 61 |
+
err = str(after.get("last_token_refresh_error") or "").strip()
|
| 62 |
+
rotated = new_token != token
|
| 63 |
+
success = bool(new_token) and not err
|
| 64 |
+
if success:
|
| 65 |
+
ok += 1
|
| 66 |
+
print(f"- email={before.get('email') or '(未知)'}")
|
| 67 |
+
print(f" 旧 access_token[:20] = {token[:20]}...")
|
| 68 |
+
print(f" 新 access_token[:20] = {new_token[:20]}...")
|
| 69 |
+
print(f" token 是否轮换 = {'是' if rotated else '否(exp 未到刷新窗口时可能返回原值)'}")
|
| 70 |
+
print(f" last_token_refresh_at = {after.get('last_token_refresh_at')}")
|
| 71 |
+
print(f" last_token_refresh_error = {after.get('last_token_refresh_error') or '无'}")
|
| 72 |
+
print(f" >>> 刷新结果 = {'成功 ✅' if success else '失败 ❌'}")
|
| 73 |
+
print()
|
| 74 |
+
print("=" * 60)
|
| 75 |
+
print(f"汇总: {ok}/{len(tokens)} 个账号刷新成功")
|
| 76 |
+
if ok == len(tokens):
|
| 77 |
+
print("✅ 自动刷新机制对这些账号完全可用——refresh_token 与 client_id 匹配。")
|
| 78 |
+
else:
|
| 79 |
+
print("❌ 有账号刷新失败,看上面的 last_token_refresh_error,或 docker logs 里的 [oauth-login]/refresh 日志。")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def main() -> None:
|
| 83 |
+
do_force = "--force" in sys.argv[1:]
|
| 84 |
+
refreshable = diagnose()
|
| 85 |
+
if do_force:
|
| 86 |
+
force_refresh(refreshable)
|
| 87 |
+
else:
|
| 88 |
+
print("提示: 加 --force 参数可真正触发一次刷新以验证能否成功。")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
main()
|
services/__init__.py
ADDED
|
File without changes
|
services/account_service.py
ADDED
|
@@ -0,0 +1,1031 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from threading import Condition, Lock
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from services.config import config
|
| 11 |
+
from services.log_service import (
|
| 12 |
+
LOG_TYPE_ACCOUNT,
|
| 13 |
+
log_service,
|
| 14 |
+
)
|
| 15 |
+
from services.storage.base import StorageBackend
|
| 16 |
+
from utils.helper import anonymize_token
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class AccountService:
|
| 20 |
+
"""账号池服务,使用 token -> account 的 dict 保存账号。"""
|
| 21 |
+
|
| 22 |
+
_NEW_ACCOUNT_INVALID_GRACE_SECONDS = 10 * 60
|
| 23 |
+
_INVALID_CONFIRM_SECONDS = 30
|
| 24 |
+
_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 24 * 60 * 60
|
| 25 |
+
_REFRESH_TOKEN_KEEPALIVE_SECONDS = 3 * 24 * 60 * 60
|
| 26 |
+
_REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS = 6 * 60 * 60
|
| 27 |
+
_REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE = 3
|
| 28 |
+
_TOKEN_REFRESH_ERROR_BACKOFF_SECONDS = 5 * 60
|
| 29 |
+
_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
| 30 |
+
_OAUTH_CLIENT_ID = "app_2SKx67EdpoN0G6j64rFvigXD"
|
| 31 |
+
_OAUTH_USER_AGENT = (
|
| 32 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 33 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 34 |
+
"Chrome/145.0.0.0 Safari/537.36"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
def __init__(self, storage_backend: StorageBackend):
|
| 38 |
+
self.storage = storage_backend
|
| 39 |
+
self._lock = Lock()
|
| 40 |
+
self._token_refresh_lock = Lock()
|
| 41 |
+
self._image_slot_condition = Condition(self._lock)
|
| 42 |
+
self._index = 0
|
| 43 |
+
self._accounts = self._load_accounts()
|
| 44 |
+
self._image_inflight: dict[str, int] = {}
|
| 45 |
+
self._token_aliases: dict[str, str] = {}
|
| 46 |
+
self._cumulative_total = self._load_cumulative_total()
|
| 47 |
+
|
| 48 |
+
def _get_cumulative_file(self) -> Path:
|
| 49 |
+
from services.config import DATA_DIR
|
| 50 |
+
return DATA_DIR / ".cumulative_total"
|
| 51 |
+
|
| 52 |
+
def _load_cumulative_total(self) -> int:
|
| 53 |
+
try:
|
| 54 |
+
f = self._get_cumulative_file()
|
| 55 |
+
if f.exists():
|
| 56 |
+
return int(f.read_text().strip())
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
return len(self._accounts)
|
| 60 |
+
|
| 61 |
+
def _save_cumulative_total(self) -> None:
|
| 62 |
+
try:
|
| 63 |
+
self._get_cumulative_file().write_text(str(self._cumulative_total))
|
| 64 |
+
except Exception:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
@staticmethod
|
| 68 |
+
def _now() -> str:
|
| 69 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _decode_jwt_payload(token: str) -> dict:
|
| 73 |
+
try:
|
| 74 |
+
payload = str(token or "").split(".")[1]
|
| 75 |
+
payload += "=" * ((4 - len(payload) % 4) % 4)
|
| 76 |
+
import base64
|
| 77 |
+
import json
|
| 78 |
+
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
| 79 |
+
return data if isinstance(data, dict) else {}
|
| 80 |
+
except Exception:
|
| 81 |
+
return {}
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def _parse_time(value: object) -> datetime | None:
|
| 85 |
+
raw = str(value or "").strip()
|
| 86 |
+
if not raw:
|
| 87 |
+
return None
|
| 88 |
+
try:
|
| 89 |
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
| 90 |
+
except Exception:
|
| 91 |
+
try:
|
| 92 |
+
parsed = datetime.strptime(raw, "%Y-%m-%d %H:%M:%S")
|
| 93 |
+
except Exception:
|
| 94 |
+
return None
|
| 95 |
+
if parsed.tzinfo is None:
|
| 96 |
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
| 97 |
+
return parsed.astimezone(timezone.utc)
|
| 98 |
+
|
| 99 |
+
@staticmethod
|
| 100 |
+
def _timestamp_to_iso(value: object) -> str:
|
| 101 |
+
try:
|
| 102 |
+
ts = int(value)
|
| 103 |
+
except (TypeError, ValueError):
|
| 104 |
+
return ""
|
| 105 |
+
tz = timezone(timedelta(hours=8))
|
| 106 |
+
return datetime.fromtimestamp(ts, tz=timezone.utc).astimezone(tz).isoformat()
|
| 107 |
+
|
| 108 |
+
def _load_accounts(self) -> dict[str, dict]:
|
| 109 |
+
accounts = self.storage.load_accounts()
|
| 110 |
+
return {
|
| 111 |
+
normalized["access_token"]: normalized
|
| 112 |
+
for item in accounts
|
| 113 |
+
if (normalized := self._normalize_account(item)) is not None
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
def _save_accounts(self) -> None:
|
| 117 |
+
self.storage.save_accounts(list(self._accounts.values()))
|
| 118 |
+
|
| 119 |
+
@staticmethod
|
| 120 |
+
def _is_image_account_available(account: dict) -> bool:
|
| 121 |
+
if not isinstance(account, dict):
|
| 122 |
+
return False
|
| 123 |
+
if account.get("status") in {"禁用", "限流", "异常"}:
|
| 124 |
+
return False
|
| 125 |
+
if bool(account.get("image_quota_unknown")):
|
| 126 |
+
return True
|
| 127 |
+
return int(account.get("quota") or 0) > 0
|
| 128 |
+
|
| 129 |
+
@classmethod
|
| 130 |
+
def _account_matches_plan_type(cls, account: dict, plan_type: str | None = None) -> bool:
|
| 131 |
+
if not plan_type:
|
| 132 |
+
return True
|
| 133 |
+
normalized_plan = cls._normalize_account_type(plan_type)
|
| 134 |
+
normalized_account = cls._normalize_account_type(account.get("type"))
|
| 135 |
+
if not normalized_plan or not normalized_account:
|
| 136 |
+
return False
|
| 137 |
+
return normalized_plan.lower() == normalized_account.lower()
|
| 138 |
+
|
| 139 |
+
@classmethod
|
| 140 |
+
def _account_matches_source_type(cls, account: dict, source_type: str | None = None) -> bool:
|
| 141 |
+
if not source_type:
|
| 142 |
+
return True
|
| 143 |
+
return cls._normalize_source_type(account.get("source_type")) == cls._normalize_source_type(source_type)
|
| 144 |
+
|
| 145 |
+
@classmethod
|
| 146 |
+
def _account_matches_any_plan_type(cls, account: dict, plan_types: set[str] | tuple[str, ...] | None = None) -> bool:
|
| 147 |
+
if not plan_types:
|
| 148 |
+
return True
|
| 149 |
+
normalized_account = cls._normalize_account_type(account.get("type"))
|
| 150 |
+
normalized_plans = {
|
| 151 |
+
normalized
|
| 152 |
+
for plan_type in plan_types
|
| 153 |
+
if (normalized := cls._normalize_account_type(plan_type))
|
| 154 |
+
}
|
| 155 |
+
return bool(normalized_account and normalized_account in normalized_plans)
|
| 156 |
+
|
| 157 |
+
@staticmethod
|
| 158 |
+
def _normalize_source_type(value: object) -> str:
|
| 159 |
+
return str(value or "web").strip().lower() or "web"
|
| 160 |
+
|
| 161 |
+
@staticmethod
|
| 162 |
+
def _normalize_account_type(value: object) -> str | None:
|
| 163 |
+
raw = str(value or "").strip()
|
| 164 |
+
if not raw:
|
| 165 |
+
return None
|
| 166 |
+
key = raw.lower().replace("-", "_").replace(" ", "_")
|
| 167 |
+
compact = key.replace("_", "")
|
| 168 |
+
aliases = {
|
| 169 |
+
"free": "free",
|
| 170 |
+
"plus": "Plus",
|
| 171 |
+
"pro": "Pro",
|
| 172 |
+
"prolite": "ProLite",
|
| 173 |
+
"team": "Team",
|
| 174 |
+
"business": "Team",
|
| 175 |
+
"enterprise": "Enterprise",
|
| 176 |
+
}
|
| 177 |
+
return aliases.get(compact) or aliases.get(key) or raw
|
| 178 |
+
|
| 179 |
+
def _search_account_type(self, payload: object) -> str | None:
|
| 180 |
+
if isinstance(payload, dict):
|
| 181 |
+
for key in ("plan_type", "account_plan", "account_type", "subscription_type", "type"):
|
| 182 |
+
plan = self._normalize_account_type(payload.get(key))
|
| 183 |
+
if plan:
|
| 184 |
+
return plan
|
| 185 |
+
for value in payload.values():
|
| 186 |
+
plan = self._search_account_type(value)
|
| 187 |
+
if plan:
|
| 188 |
+
return plan
|
| 189 |
+
elif isinstance(payload, list):
|
| 190 |
+
for value in payload:
|
| 191 |
+
plan = self._search_account_type(value)
|
| 192 |
+
if plan:
|
| 193 |
+
return plan
|
| 194 |
+
return None
|
| 195 |
+
|
| 196 |
+
def _normalize_account(self, item: dict) -> dict | None:
|
| 197 |
+
if not isinstance(item, dict):
|
| 198 |
+
return None
|
| 199 |
+
access_token = item.get("access_token") or item.get("accessToken") or ""
|
| 200 |
+
if not access_token:
|
| 201 |
+
return None
|
| 202 |
+
normalized = dict(item)
|
| 203 |
+
normalized.pop("accessToken", None)
|
| 204 |
+
normalized["access_token"] = access_token
|
| 205 |
+
if str(normalized.get("type") or "").strip().lower() == "codex":
|
| 206 |
+
normalized["export_type"] = "codex"
|
| 207 |
+
normalized.pop("type", None)
|
| 208 |
+
normalized["type"] = normalized.get("type") or "free"
|
| 209 |
+
normalized["status"] = normalized.get("status") or "正常"
|
| 210 |
+
normalized["quota"] = max(0, int(normalized.get("quota") if normalized.get("quota") is not None else 0))
|
| 211 |
+
normalized["image_quota_unknown"] = bool(normalized.get("image_quota_unknown"))
|
| 212 |
+
normalized["email"] = normalized.get("email") or None
|
| 213 |
+
normalized["user_id"] = normalized.get("user_id") or None
|
| 214 |
+
normalized["proxy"] = str(normalized.get("proxy") or "").strip()
|
| 215 |
+
source_type = normalized.get("source_type")
|
| 216 |
+
if not source_type and str(normalized.get("export_type") or "").strip().lower() == "codex":
|
| 217 |
+
source_type = "codex"
|
| 218 |
+
normalized["source_type"] = self._normalize_source_type(source_type)
|
| 219 |
+
limits_progress = normalized.get("limits_progress")
|
| 220 |
+
normalized["limits_progress"] = limits_progress if isinstance(limits_progress, list) else []
|
| 221 |
+
normalized["default_model_slug"] = normalized.get("default_model_slug") or None
|
| 222 |
+
normalized["restore_at"] = normalized.get("restore_at") or None
|
| 223 |
+
normalized["success"] = int(normalized.get("success") or 0)
|
| 224 |
+
normalized["fail"] = int(normalized.get("fail") or 0)
|
| 225 |
+
normalized["invalid_count"] = int(normalized.get("invalid_count") or 0)
|
| 226 |
+
normalized["last_used_at"] = normalized.get("last_used_at")
|
| 227 |
+
normalized["last_invalid_at"] = normalized.get("last_invalid_at") or None
|
| 228 |
+
normalized["last_refresh_error"] = normalized.get("last_refresh_error") or None
|
| 229 |
+
normalized["last_refresh_error_at"] = normalized.get("last_refresh_error_at") or None
|
| 230 |
+
normalized["last_token_refresh_at"] = normalized.get("last_token_refresh_at") or None
|
| 231 |
+
normalized["last_token_refresh_error"] = normalized.get("last_token_refresh_error") or None
|
| 232 |
+
normalized["last_token_refresh_error_at"] = normalized.get("last_token_refresh_error_at") or None
|
| 233 |
+
normalized["created_at"] = normalized.get("created_at") or AccountService._now()
|
| 234 |
+
return normalized
|
| 235 |
+
|
| 236 |
+
@staticmethod
|
| 237 |
+
def _jwt_exp(access_token: str) -> int:
|
| 238 |
+
try:
|
| 239 |
+
return int(AccountService._decode_jwt_payload(access_token).get("exp") or 0)
|
| 240 |
+
except (TypeError, ValueError):
|
| 241 |
+
return 0
|
| 242 |
+
|
| 243 |
+
@classmethod
|
| 244 |
+
def _token_expires_in(cls, access_token: str) -> int | None:
|
| 245 |
+
exp = cls._jwt_exp(access_token)
|
| 246 |
+
if exp <= 0:
|
| 247 |
+
return None
|
| 248 |
+
return exp - int(time.time())
|
| 249 |
+
|
| 250 |
+
@classmethod
|
| 251 |
+
def _token_needs_refresh(cls, access_token: str, *, force: bool = False) -> bool:
|
| 252 |
+
if force:
|
| 253 |
+
return True
|
| 254 |
+
remaining = cls._token_expires_in(access_token)
|
| 255 |
+
return remaining is not None and remaining <= cls._ACCESS_TOKEN_REFRESH_SKEW_SECONDS
|
| 256 |
+
|
| 257 |
+
@classmethod
|
| 258 |
+
def _token_issued_at(cls, access_token: str) -> datetime | None:
|
| 259 |
+
try:
|
| 260 |
+
iat = int(cls._decode_jwt_payload(access_token).get("iat") or 0)
|
| 261 |
+
except (TypeError, ValueError):
|
| 262 |
+
return None
|
| 263 |
+
if iat <= 0:
|
| 264 |
+
return None
|
| 265 |
+
return datetime.fromtimestamp(iat, tz=timezone.utc)
|
| 266 |
+
|
| 267 |
+
@staticmethod
|
| 268 |
+
def _safe_response_text(response: object, limit: int = 300) -> str:
|
| 269 |
+
try:
|
| 270 |
+
return str(getattr(response, "text", "") or "")[:limit]
|
| 271 |
+
except Exception:
|
| 272 |
+
return ""
|
| 273 |
+
|
| 274 |
+
def _resolve_access_token_locked(self, access_token: str) -> str:
|
| 275 |
+
token = str(access_token or "").strip()
|
| 276 |
+
seen: set[str] = set()
|
| 277 |
+
while token and token not in self._accounts and token in self._token_aliases and token not in seen:
|
| 278 |
+
seen.add(token)
|
| 279 |
+
token = self._token_aliases.get(token, token)
|
| 280 |
+
return token
|
| 281 |
+
|
| 282 |
+
def resolve_access_token(self, access_token: str) -> str:
|
| 283 |
+
if not access_token:
|
| 284 |
+
return ""
|
| 285 |
+
with self._lock:
|
| 286 |
+
return self._resolve_access_token_locked(access_token)
|
| 287 |
+
|
| 288 |
+
def _get_account_for_token(self, access_token: str) -> tuple[str, dict | None]:
|
| 289 |
+
with self._lock:
|
| 290 |
+
resolved = self._resolve_access_token_locked(access_token)
|
| 291 |
+
account = self._accounts.get(resolved)
|
| 292 |
+
return resolved, dict(account) if account else None
|
| 293 |
+
|
| 294 |
+
def _record_token_refresh_error(self, access_token: str, event: str, error: str) -> None:
|
| 295 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 296 |
+
with self._lock:
|
| 297 |
+
resolved = self._resolve_access_token_locked(access_token)
|
| 298 |
+
current = self._accounts.get(resolved)
|
| 299 |
+
if current is None:
|
| 300 |
+
return
|
| 301 |
+
next_item = dict(current)
|
| 302 |
+
next_item["last_token_refresh_error"] = str(error or "refresh token failed")
|
| 303 |
+
next_item["last_token_refresh_error_at"] = now
|
| 304 |
+
account = self._normalize_account(next_item)
|
| 305 |
+
if account is not None:
|
| 306 |
+
self._accounts[resolved] = account
|
| 307 |
+
self._save_accounts()
|
| 308 |
+
log_service.add(
|
| 309 |
+
LOG_TYPE_ACCOUNT,
|
| 310 |
+
"refresh_token 刷新 access_token 失败",
|
| 311 |
+
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
def _recent_token_refresh_error(self, account: dict) -> bool:
|
| 315 |
+
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
| 316 |
+
if last_error_at is None:
|
| 317 |
+
return False
|
| 318 |
+
return (datetime.now(timezone.utc) - last_error_at).total_seconds() < self._TOKEN_REFRESH_ERROR_BACKOFF_SECONDS
|
| 319 |
+
|
| 320 |
+
def _recent_refresh_token_keepalive_error(self, account: dict, now: datetime) -> bool:
|
| 321 |
+
last_error_at = self._parse_time(account.get("last_token_refresh_error_at"))
|
| 322 |
+
if last_error_at is None:
|
| 323 |
+
return False
|
| 324 |
+
return (now - last_error_at).total_seconds() < self._REFRESH_TOKEN_KEEPALIVE_ERROR_BACKOFF_SECONDS
|
| 325 |
+
|
| 326 |
+
def _refresh_token_keepalive_anchor(self, account: dict) -> datetime | None:
|
| 327 |
+
return (
|
| 328 |
+
self._parse_time(account.get("last_token_refresh_at"))
|
| 329 |
+
or self._token_issued_at(str(account.get("access_token") or ""))
|
| 330 |
+
or self._parse_time(account.get("created_at"))
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
def _refresh_token_keepalive_due_at(self, account: dict, now: datetime) -> datetime | None:
|
| 334 |
+
if not str(account.get("refresh_token") or "").strip():
|
| 335 |
+
return None
|
| 336 |
+
if account.get("status") == "禁用":
|
| 337 |
+
return None
|
| 338 |
+
if self._recent_refresh_token_keepalive_error(account, now):
|
| 339 |
+
return None
|
| 340 |
+
anchor = self._refresh_token_keepalive_anchor(account)
|
| 341 |
+
if anchor is None:
|
| 342 |
+
return now
|
| 343 |
+
due_at = anchor + timedelta(seconds=self._REFRESH_TOKEN_KEEPALIVE_SECONDS)
|
| 344 |
+
return due_at if due_at <= now else None
|
| 345 |
+
|
| 346 |
+
def _request_access_token_refresh(self, refresh_token: str, account: dict | None = None) -> dict[str, str]:
|
| 347 |
+
from curl_cffi import requests
|
| 348 |
+
from services.proxy_service import proxy_settings
|
| 349 |
+
|
| 350 |
+
session = requests.Session(**proxy_settings.build_session_kwargs(account=account, impersonate="chrome", verify=True))
|
| 351 |
+
try:
|
| 352 |
+
response = session.post(
|
| 353 |
+
self._OAUTH_TOKEN_URL,
|
| 354 |
+
headers={
|
| 355 |
+
"Accept": "application/json",
|
| 356 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 357 |
+
"User-Agent": self._OAUTH_USER_AGENT,
|
| 358 |
+
},
|
| 359 |
+
data={
|
| 360 |
+
"grant_type": "refresh_token",
|
| 361 |
+
"refresh_token": refresh_token,
|
| 362 |
+
"client_id": self._OAUTH_CLIENT_ID,
|
| 363 |
+
},
|
| 364 |
+
timeout=60,
|
| 365 |
+
)
|
| 366 |
+
data = response.json() if response.text else {}
|
| 367 |
+
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
|
| 368 |
+
detail = ""
|
| 369 |
+
if isinstance(data, dict):
|
| 370 |
+
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
|
| 371 |
+
detail = detail or self._safe_response_text(response)
|
| 372 |
+
raise RuntimeError(f"oauth_refresh_http_{response.status_code}{': ' + detail if detail else ''}")
|
| 373 |
+
return {
|
| 374 |
+
"access_token": str(data.get("access_token") or "").strip(),
|
| 375 |
+
"refresh_token": str(data.get("refresh_token") or refresh_token).strip(),
|
| 376 |
+
"id_token": str(data.get("id_token") or "").strip(),
|
| 377 |
+
}
|
| 378 |
+
finally:
|
| 379 |
+
session.close()
|
| 380 |
+
|
| 381 |
+
def _apply_refreshed_tokens(self, old_access_token: str, token_data: dict, event: str) -> str:
|
| 382 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 383 |
+
with self._image_slot_condition:
|
| 384 |
+
old_token = self._resolve_access_token_locked(old_access_token)
|
| 385 |
+
current = self._accounts.get(old_token)
|
| 386 |
+
if current is None:
|
| 387 |
+
return old_token
|
| 388 |
+
new_token = str(token_data.get("access_token") or old_token).strip()
|
| 389 |
+
if not new_token:
|
| 390 |
+
return old_token
|
| 391 |
+
|
| 392 |
+
next_item = dict(current)
|
| 393 |
+
next_item["access_token"] = new_token
|
| 394 |
+
if token_data.get("refresh_token"):
|
| 395 |
+
next_item["refresh_token"] = str(token_data.get("refresh_token") or "").strip()
|
| 396 |
+
if token_data.get("id_token"):
|
| 397 |
+
next_item["id_token"] = str(token_data.get("id_token") or "").strip()
|
| 398 |
+
next_item["last_token_refresh_at"] = now
|
| 399 |
+
next_item["last_token_refresh_error"] = None
|
| 400 |
+
next_item["last_token_refresh_error_at"] = None
|
| 401 |
+
next_item["invalid_count"] = 0
|
| 402 |
+
next_item["last_invalid_at"] = None
|
| 403 |
+
next_item["last_refresh_error"] = None
|
| 404 |
+
next_item["last_refresh_error_at"] = None
|
| 405 |
+
|
| 406 |
+
account = self._normalize_account(next_item)
|
| 407 |
+
if account is None:
|
| 408 |
+
return old_token
|
| 409 |
+
|
| 410 |
+
rotated = new_token != old_token
|
| 411 |
+
if rotated:
|
| 412 |
+
self._accounts.pop(old_token, None)
|
| 413 |
+
self._token_aliases[old_token] = new_token
|
| 414 |
+
old_inflight = int(self._image_inflight.pop(old_token, 0))
|
| 415 |
+
if old_inflight:
|
| 416 |
+
self._image_inflight[new_token] = int(self._image_inflight.get(new_token, 0)) + old_inflight
|
| 417 |
+
self._accounts[new_token] = account
|
| 418 |
+
self._save_accounts()
|
| 419 |
+
self._image_slot_condition.notify_all()
|
| 420 |
+
|
| 421 |
+
log_service.add(
|
| 422 |
+
LOG_TYPE_ACCOUNT,
|
| 423 |
+
"refresh_token 已刷新 access_token",
|
| 424 |
+
{"source": event, "token": anonymize_token(new_token), "rotated": rotated},
|
| 425 |
+
)
|
| 426 |
+
return new_token
|
| 427 |
+
|
| 428 |
+
def refresh_access_token(self, access_token: str, *, force: bool = False, event: str = "refresh_access_token") -> str:
|
| 429 |
+
if not access_token:
|
| 430 |
+
return ""
|
| 431 |
+
with self._token_refresh_lock:
|
| 432 |
+
resolved_token, account = self._get_account_for_token(access_token)
|
| 433 |
+
if not account:
|
| 434 |
+
return access_token
|
| 435 |
+
active_token = str(account.get("access_token") or resolved_token or access_token)
|
| 436 |
+
if not self._token_needs_refresh(active_token, force=force):
|
| 437 |
+
return active_token
|
| 438 |
+
refresh_token = str(account.get("refresh_token") or "").strip()
|
| 439 |
+
if not refresh_token:
|
| 440 |
+
return active_token
|
| 441 |
+
if not force and self._recent_token_refresh_error(account):
|
| 442 |
+
return active_token
|
| 443 |
+
try:
|
| 444 |
+
token_data = self._request_access_token_refresh(refresh_token, account)
|
| 445 |
+
except Exception as exc:
|
| 446 |
+
self._record_token_refresh_error(active_token, event, str(exc))
|
| 447 |
+
return active_token
|
| 448 |
+
return self._apply_refreshed_tokens(active_token, token_data, event)
|
| 449 |
+
|
| 450 |
+
def list_expiring_access_tokens(self) -> list[str]:
|
| 451 |
+
with self._lock:
|
| 452 |
+
return [
|
| 453 |
+
token
|
| 454 |
+
for account in self._accounts.values()
|
| 455 |
+
if str(account.get("refresh_token") or "").strip()
|
| 456 |
+
and (token := str(account.get("access_token") or "").strip())
|
| 457 |
+
and self._token_needs_refresh(token)
|
| 458 |
+
]
|
| 459 |
+
|
| 460 |
+
def list_refresh_token_keepalive_tokens(self) -> list[str]:
|
| 461 |
+
now = datetime.now(timezone.utc)
|
| 462 |
+
due_items: list[tuple[datetime, str]] = []
|
| 463 |
+
with self._lock:
|
| 464 |
+
for account in self._accounts.values():
|
| 465 |
+
due_at = self._refresh_token_keepalive_due_at(account, now)
|
| 466 |
+
token = str(account.get("access_token") or "").strip()
|
| 467 |
+
if due_at is not None and token:
|
| 468 |
+
due_items.append((due_at, token))
|
| 469 |
+
due_items.sort(key=lambda item: item[0])
|
| 470 |
+
return [token for _, token in due_items[: self._REFRESH_TOKEN_KEEPALIVE_BATCH_SIZE]]
|
| 471 |
+
|
| 472 |
+
def keepalive_refresh_tokens(self, access_tokens: list[str]) -> dict[str, Any]:
|
| 473 |
+
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
| 474 |
+
if not access_tokens:
|
| 475 |
+
return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
|
| 476 |
+
|
| 477 |
+
refreshed = 0
|
| 478 |
+
errors = []
|
| 479 |
+
for access_token in access_tokens:
|
| 480 |
+
before = self.resolve_access_token(access_token)
|
| 481 |
+
after = self.refresh_access_token(before, force=True, event="refresh_token_keepalive")
|
| 482 |
+
account = self.get_account(after)
|
| 483 |
+
if account and str(account.get("last_token_refresh_error") or "").strip():
|
| 484 |
+
errors.append({
|
| 485 |
+
"token": anonymize_token(before),
|
| 486 |
+
"error": str(account.get("last_token_refresh_error") or "refresh token failed"),
|
| 487 |
+
})
|
| 488 |
+
continue
|
| 489 |
+
if account:
|
| 490 |
+
refreshed += 1
|
| 491 |
+
|
| 492 |
+
return {
|
| 493 |
+
"refreshed": refreshed,
|
| 494 |
+
"errors": errors,
|
| 495 |
+
"items": self.list_accounts(),
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
def list_tokens(self) -> list[str]:
|
| 499 |
+
with self._lock:
|
| 500 |
+
return list(self._accounts)
|
| 501 |
+
|
| 502 |
+
def _list_ready_candidate_tokens(
|
| 503 |
+
self,
|
| 504 |
+
excluded_tokens: set[str] | None = None,
|
| 505 |
+
plan_type: str | None = None,
|
| 506 |
+
source_type: str | None = None,
|
| 507 |
+
plan_types: set[str] | tuple[str, ...] | None = None,
|
| 508 |
+
) -> list[str]:
|
| 509 |
+
excluded = set(excluded_tokens or set())
|
| 510 |
+
return [
|
| 511 |
+
token
|
| 512 |
+
for item in self._accounts.values()
|
| 513 |
+
if self._is_image_account_available(item)
|
| 514 |
+
and self._account_matches_plan_type(item, plan_type)
|
| 515 |
+
and self._account_matches_any_plan_type(item, plan_types)
|
| 516 |
+
and self._account_matches_source_type(item, source_type)
|
| 517 |
+
and (token := item.get("access_token") or "")
|
| 518 |
+
and token not in excluded
|
| 519 |
+
]
|
| 520 |
+
|
| 521 |
+
def _list_available_candidate_tokens(
|
| 522 |
+
self,
|
| 523 |
+
excluded_tokens: set[str] | None = None,
|
| 524 |
+
plan_type: str | None = None,
|
| 525 |
+
source_type: str | None = None,
|
| 526 |
+
plan_types: set[str] | tuple[str, ...] | None = None,
|
| 527 |
+
) -> list[str]:
|
| 528 |
+
max_concurrency = max(1, int(config.image_account_concurrency or 1))
|
| 529 |
+
return [
|
| 530 |
+
token
|
| 531 |
+
for token in self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
|
| 532 |
+
if int(self._image_inflight.get(token, 0)) < max_concurrency
|
| 533 |
+
]
|
| 534 |
+
|
| 535 |
+
def _acquire_next_candidate_token(
|
| 536 |
+
self,
|
| 537 |
+
excluded_tokens: set[str] | None = None,
|
| 538 |
+
plan_type: str | None = None,
|
| 539 |
+
source_type: str | None = None,
|
| 540 |
+
plan_types: set[str] | tuple[str, ...] | None = None,
|
| 541 |
+
) -> str:
|
| 542 |
+
with self._image_slot_condition:
|
| 543 |
+
while True:
|
| 544 |
+
if not self._list_ready_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types):
|
| 545 |
+
raise RuntimeError(
|
| 546 |
+
f"no available {plan_type or source_type or ''} image quota".replace(" ", " ").strip()
|
| 547 |
+
if plan_type or source_type else "no available image quota"
|
| 548 |
+
)
|
| 549 |
+
tokens = self._list_available_candidate_tokens(excluded_tokens, plan_type, source_type, plan_types)
|
| 550 |
+
if tokens:
|
| 551 |
+
access_token = tokens[self._index % len(tokens)]
|
| 552 |
+
self._index += 1
|
| 553 |
+
self._image_inflight[access_token] = int(self._image_inflight.get(access_token, 0)) + 1
|
| 554 |
+
return access_token
|
| 555 |
+
self._image_slot_condition.wait(timeout=1.0)
|
| 556 |
+
|
| 557 |
+
def release_image_slot(self, access_token: str) -> None:
|
| 558 |
+
if not access_token:
|
| 559 |
+
return
|
| 560 |
+
with self._image_slot_condition:
|
| 561 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 562 |
+
current_inflight = int(self._image_inflight.get(access_token, 0))
|
| 563 |
+
if current_inflight <= 1:
|
| 564 |
+
self._image_inflight.pop(access_token, None)
|
| 565 |
+
else:
|
| 566 |
+
self._image_inflight[access_token] = current_inflight - 1
|
| 567 |
+
self._image_slot_condition.notify_all()
|
| 568 |
+
|
| 569 |
+
def get_available_access_token(
|
| 570 |
+
self,
|
| 571 |
+
plan_type: str | None = None,
|
| 572 |
+
source_type: str | None = None,
|
| 573 |
+
plan_types: set[str] | tuple[str, ...] | None = None,
|
| 574 |
+
) -> str:
|
| 575 |
+
attempted_tokens: set[str] = set()
|
| 576 |
+
while True:
|
| 577 |
+
access_token = self._acquire_next_candidate_token(
|
| 578 |
+
excluded_tokens=attempted_tokens,
|
| 579 |
+
plan_type=plan_type,
|
| 580 |
+
source_type=source_type,
|
| 581 |
+
plan_types=plan_types,
|
| 582 |
+
)
|
| 583 |
+
attempted_tokens.add(access_token)
|
| 584 |
+
try:
|
| 585 |
+
account = self.fetch_remote_info(access_token, "get_available_access_token")
|
| 586 |
+
except Exception:
|
| 587 |
+
self.release_image_slot(access_token)
|
| 588 |
+
continue
|
| 589 |
+
if (
|
| 590 |
+
self._is_image_account_available(account or {})
|
| 591 |
+
and self._account_matches_plan_type(account or {}, plan_type)
|
| 592 |
+
and self._account_matches_any_plan_type(account or {}, plan_types)
|
| 593 |
+
and self._account_matches_source_type(account or {}, source_type)
|
| 594 |
+
):
|
| 595 |
+
return str((account or {}).get("access_token") or access_token)
|
| 596 |
+
self.release_image_slot(access_token)
|
| 597 |
+
|
| 598 |
+
def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str:
|
| 599 |
+
excluded = set(excluded_tokens or set())
|
| 600 |
+
with self._lock:
|
| 601 |
+
candidates = [
|
| 602 |
+
token
|
| 603 |
+
for account in self._accounts.values()
|
| 604 |
+
if account.get("status") not in {"禁用", "异常"}
|
| 605 |
+
and (token := account.get("access_token") or "")
|
| 606 |
+
and token not in excluded
|
| 607 |
+
]
|
| 608 |
+
if not candidates:
|
| 609 |
+
return ""
|
| 610 |
+
access_token = candidates[self._index % len(candidates)]
|
| 611 |
+
self._index += 1
|
| 612 |
+
return self.refresh_access_token(access_token, event="get_text_access_token") or access_token
|
| 613 |
+
|
| 614 |
+
def mark_text_used(self, access_token: str) -> None:
|
| 615 |
+
if not access_token:
|
| 616 |
+
return
|
| 617 |
+
with self._lock:
|
| 618 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 619 |
+
current = self._accounts.get(access_token)
|
| 620 |
+
if current is None:
|
| 621 |
+
return
|
| 622 |
+
next_item = dict(current)
|
| 623 |
+
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 624 |
+
account = self._normalize_account(next_item)
|
| 625 |
+
if account is None:
|
| 626 |
+
return
|
| 627 |
+
self._accounts[access_token] = account
|
| 628 |
+
self._save_accounts()
|
| 629 |
+
|
| 630 |
+
def remove_invalid_token(self, access_token: str, event: str) -> bool:
|
| 631 |
+
if not config.auto_remove_invalid_accounts:
|
| 632 |
+
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 633 |
+
return False
|
| 634 |
+
removed = bool(self.delete_accounts([access_token])["removed"])
|
| 635 |
+
if removed:
|
| 636 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号",
|
| 637 |
+
{"source": event, "token": anonymize_token(access_token)})
|
| 638 |
+
elif access_token:
|
| 639 |
+
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 640 |
+
return removed
|
| 641 |
+
|
| 642 |
+
def get_account(self, access_token: str) -> dict | None:
|
| 643 |
+
if not access_token:
|
| 644 |
+
return None
|
| 645 |
+
with self._lock:
|
| 646 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 647 |
+
account = self._accounts.get(access_token)
|
| 648 |
+
return dict(account) if account else None
|
| 649 |
+
|
| 650 |
+
def list_accounts(self) -> list[dict]:
|
| 651 |
+
with self._lock:
|
| 652 |
+
return [dict(item) for item in self._accounts.values()]
|
| 653 |
+
|
| 654 |
+
def list_limited_tokens(self) -> list[str]:
|
| 655 |
+
with self._lock:
|
| 656 |
+
return [
|
| 657 |
+
token
|
| 658 |
+
for item in self._accounts.values()
|
| 659 |
+
if item.get("status") == "限流"
|
| 660 |
+
and (token := item.get("access_token") or "")
|
| 661 |
+
]
|
| 662 |
+
|
| 663 |
+
@staticmethod
|
| 664 |
+
def _account_payload_token(item: dict) -> str:
|
| 665 |
+
return str(item.get("access_token") or item.get("accessToken") or "").strip()
|
| 666 |
+
|
| 667 |
+
@staticmethod
|
| 668 |
+
def _prepare_account_payload(item: dict) -> dict | None:
|
| 669 |
+
if not isinstance(item, dict):
|
| 670 |
+
return None
|
| 671 |
+
access_token = AccountService._account_payload_token(item)
|
| 672 |
+
if not access_token:
|
| 673 |
+
return None
|
| 674 |
+
payload = dict(item)
|
| 675 |
+
payload.pop("accessToken", None)
|
| 676 |
+
payload["access_token"] = access_token
|
| 677 |
+
# CPA/Codex 导出文件里的 `type=codex` 是导出格式,不是号池套餐类型。
|
| 678 |
+
if str(payload.get("type") or "").strip().lower() == "codex":
|
| 679 |
+
payload["export_type"] = "codex"
|
| 680 |
+
payload["source_type"] = "codex"
|
| 681 |
+
payload.pop("type", None)
|
| 682 |
+
if str(payload.get("export_type") or "").strip().lower() == "codex":
|
| 683 |
+
payload["source_type"] = "codex"
|
| 684 |
+
if payload.get("plan_type") and not payload.get("type"):
|
| 685 |
+
payload["type"] = str(payload.get("plan_type") or "").strip()
|
| 686 |
+
return payload
|
| 687 |
+
|
| 688 |
+
def add_account_items(self, items: list[dict]) -> dict:
|
| 689 |
+
payloads = [
|
| 690 |
+
payload
|
| 691 |
+
for item in items
|
| 692 |
+
if (payload := self._prepare_account_payload(item)) is not None
|
| 693 |
+
]
|
| 694 |
+
return self._add_account_payloads(payloads)
|
| 695 |
+
|
| 696 |
+
def add_accounts(self, tokens: list[str], source_type: str = "web") -> dict:
|
| 697 |
+
tokens = list(dict.fromkeys(token for token in tokens if token))
|
| 698 |
+
if not tokens:
|
| 699 |
+
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
| 700 |
+
return self._add_account_payloads([
|
| 701 |
+
{"access_token": token, "source_type": self._normalize_source_type(source_type)}
|
| 702 |
+
for token in tokens
|
| 703 |
+
])
|
| 704 |
+
|
| 705 |
+
def _add_account_payloads(self, payloads: list[dict]) -> dict:
|
| 706 |
+
deduped: dict[str, dict] = {}
|
| 707 |
+
for payload in payloads:
|
| 708 |
+
if not isinstance(payload, dict):
|
| 709 |
+
continue
|
| 710 |
+
access_token = self._account_payload_token(payload)
|
| 711 |
+
if not access_token:
|
| 712 |
+
continue
|
| 713 |
+
current = deduped.get(access_token, {})
|
| 714 |
+
deduped[access_token] = {**current, **payload, "access_token": access_token}
|
| 715 |
+
|
| 716 |
+
if not deduped:
|
| 717 |
+
return {"added": 0, "skipped": 0, "items": self.list_accounts()}
|
| 718 |
+
|
| 719 |
+
with self._lock:
|
| 720 |
+
added = 0
|
| 721 |
+
skipped = 0
|
| 722 |
+
for access_token, payload in deduped.items():
|
| 723 |
+
current = self._accounts.get(access_token)
|
| 724 |
+
if current is None:
|
| 725 |
+
added += 1
|
| 726 |
+
self._cumulative_total += 1
|
| 727 |
+
self._save_cumulative_total()
|
| 728 |
+
current = {"created_at": self._now()}
|
| 729 |
+
else:
|
| 730 |
+
skipped += 1
|
| 731 |
+
incoming = dict(payload)
|
| 732 |
+
if not incoming.get("created_at"):
|
| 733 |
+
incoming.pop("created_at", None)
|
| 734 |
+
account = self._normalize_account(
|
| 735 |
+
{
|
| 736 |
+
**current,
|
| 737 |
+
**incoming,
|
| 738 |
+
"access_token": access_token,
|
| 739 |
+
"type": str(incoming.get("type") or current.get("type") or "free"),
|
| 740 |
+
}
|
| 741 |
+
)
|
| 742 |
+
if account is not None:
|
| 743 |
+
self._accounts[access_token] = account
|
| 744 |
+
self._save_accounts()
|
| 745 |
+
items = [dict(item) for item in self._accounts.values()]
|
| 746 |
+
log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} 个账号,跳过 {skipped} 个",
|
| 747 |
+
{"added": added, "skipped": skipped})
|
| 748 |
+
return {"added": added, "skipped": skipped, "items": items}
|
| 749 |
+
|
| 750 |
+
def delete_accounts(self, tokens: list[str]) -> dict:
|
| 751 |
+
target_set = set(token for token in tokens if token)
|
| 752 |
+
if not target_set:
|
| 753 |
+
return {"removed": 0, "items": self.list_accounts()}
|
| 754 |
+
with self._lock:
|
| 755 |
+
target_set = {self._resolve_access_token_locked(token) for token in target_set if token}
|
| 756 |
+
removed = sum(self._accounts.pop(token, None) is not None for token in target_set)
|
| 757 |
+
for token in target_set:
|
| 758 |
+
self._image_inflight.pop(token, None)
|
| 759 |
+
self._token_aliases = {
|
| 760 |
+
old: new
|
| 761 |
+
for old, new in self._token_aliases.items()
|
| 762 |
+
if old not in target_set and new not in target_set
|
| 763 |
+
}
|
| 764 |
+
if removed:
|
| 765 |
+
if self._accounts:
|
| 766 |
+
self._index %= len(self._accounts)
|
| 767 |
+
else:
|
| 768 |
+
self._index = 0
|
| 769 |
+
self._save_accounts()
|
| 770 |
+
log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed})
|
| 771 |
+
items = [dict(item) for item in self._accounts.values()]
|
| 772 |
+
return {"removed": removed, "items": items}
|
| 773 |
+
|
| 774 |
+
def update_account(self, access_token: str, updates: dict) -> dict | None:
|
| 775 |
+
if not access_token:
|
| 776 |
+
return None
|
| 777 |
+
with self._lock:
|
| 778 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 779 |
+
current = self._accounts.get(access_token)
|
| 780 |
+
if current is None:
|
| 781 |
+
return None
|
| 782 |
+
account = self._normalize_account({**current, **updates, "access_token": access_token})
|
| 783 |
+
if account is None:
|
| 784 |
+
return None
|
| 785 |
+
if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
|
| 786 |
+
self._accounts.pop(access_token, None)
|
| 787 |
+
self._save_accounts()
|
| 788 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
| 789 |
+
return None
|
| 790 |
+
self._accounts[access_token] = account
|
| 791 |
+
self._save_accounts()
|
| 792 |
+
log_service.add(LOG_TYPE_ACCOUNT, "更新账号",
|
| 793 |
+
{"token": anonymize_token(access_token), "status": account.get("status")})
|
| 794 |
+
return dict(account)
|
| 795 |
+
return None
|
| 796 |
+
|
| 797 |
+
def _record_refresh_success(self, access_token: str) -> None:
|
| 798 |
+
with self._lock:
|
| 799 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 800 |
+
current = self._accounts.get(access_token)
|
| 801 |
+
if current is None:
|
| 802 |
+
return
|
| 803 |
+
next_item = dict(current)
|
| 804 |
+
next_item["invalid_count"] = 0
|
| 805 |
+
next_item["last_invalid_at"] = None
|
| 806 |
+
next_item["last_refresh_error"] = None
|
| 807 |
+
next_item["last_refresh_error_at"] = None
|
| 808 |
+
account = self._normalize_account(next_item)
|
| 809 |
+
if account is not None:
|
| 810 |
+
self._accounts[access_token] = account
|
| 811 |
+
|
| 812 |
+
def _should_defer_invalid_token(self, account: dict | None, now: datetime) -> bool:
|
| 813 |
+
if not isinstance(account, dict):
|
| 814 |
+
return False
|
| 815 |
+
created_at = self._parse_time(account.get("created_at"))
|
| 816 |
+
if created_at is not None and (now - created_at).total_seconds() < self._NEW_ACCOUNT_INVALID_GRACE_SECONDS:
|
| 817 |
+
return True
|
| 818 |
+
last_invalid_at = self._parse_time(account.get("last_invalid_at"))
|
| 819 |
+
invalid_count = int(account.get("invalid_count") or 0)
|
| 820 |
+
if invalid_count <= 1:
|
| 821 |
+
return True
|
| 822 |
+
if last_invalid_at is not None and (now - last_invalid_at).total_seconds() < self._INVALID_CONFIRM_SECONDS:
|
| 823 |
+
return True
|
| 824 |
+
return False
|
| 825 |
+
|
| 826 |
+
def _record_invalid_token_seen(self, access_token: str, event: str, error: str) -> bool:
|
| 827 |
+
now = datetime.now(timezone.utc)
|
| 828 |
+
with self._lock:
|
| 829 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 830 |
+
current = self._accounts.get(access_token)
|
| 831 |
+
if current is None:
|
| 832 |
+
return True
|
| 833 |
+
should_defer = self._should_defer_invalid_token(current, now)
|
| 834 |
+
next_item = dict(current)
|
| 835 |
+
next_item["invalid_count"] = int(next_item.get("invalid_count") or 0) + 1
|
| 836 |
+
next_item["last_invalid_at"] = now.isoformat()
|
| 837 |
+
next_item["last_refresh_error"] = str(error or "invalid access token")
|
| 838 |
+
next_item["last_refresh_error_at"] = now.isoformat()
|
| 839 |
+
account = self._normalize_account(next_item)
|
| 840 |
+
if account is not None:
|
| 841 |
+
self._accounts[access_token] = account
|
| 842 |
+
self._save_accounts()
|
| 843 |
+
if should_defer:
|
| 844 |
+
log_service.add(
|
| 845 |
+
LOG_TYPE_ACCOUNT,
|
| 846 |
+
"暂缓标记异常账号",
|
| 847 |
+
{"source": event, "token": anonymize_token(access_token), "error": str(error or "")},
|
| 848 |
+
)
|
| 849 |
+
return False
|
| 850 |
+
return True
|
| 851 |
+
|
| 852 |
+
def mark_image_result(self, access_token: str, success: bool) -> dict | None:
|
| 853 |
+
if not access_token:
|
| 854 |
+
return None
|
| 855 |
+
self.release_image_slot(access_token)
|
| 856 |
+
with self._lock:
|
| 857 |
+
access_token = self._resolve_access_token_locked(access_token)
|
| 858 |
+
current = self._accounts.get(access_token)
|
| 859 |
+
if current is None:
|
| 860 |
+
return None
|
| 861 |
+
next_item = dict(current)
|
| 862 |
+
next_item["last_used_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 863 |
+
image_quota_unknown = bool(next_item.get("image_quota_unknown"))
|
| 864 |
+
if success:
|
| 865 |
+
next_item["success"] = int(next_item.get("success") or 0) + 1
|
| 866 |
+
if not image_quota_unknown:
|
| 867 |
+
next_item["quota"] = max(0, int(next_item.get("quota") or 0) - 1)
|
| 868 |
+
if not image_quota_unknown and next_item["quota"] == 0:
|
| 869 |
+
next_item["status"] = "限流"
|
| 870 |
+
next_item["restore_at"] = next_item.get("restore_at") or None
|
| 871 |
+
elif next_item.get("status") == "限流":
|
| 872 |
+
next_item["status"] = "正常"
|
| 873 |
+
else:
|
| 874 |
+
next_item["fail"] = int(next_item.get("fail") or 0) + 1
|
| 875 |
+
account = self._normalize_account(next_item)
|
| 876 |
+
if account is None:
|
| 877 |
+
return None
|
| 878 |
+
if account.get("status") == "限流" and config.auto_remove_rate_limited_accounts:
|
| 879 |
+
self._accounts.pop(access_token, None)
|
| 880 |
+
self._save_accounts()
|
| 881 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除限流账号", {"token": anonymize_token(access_token)})
|
| 882 |
+
return None
|
| 883 |
+
self._accounts[access_token] = account
|
| 884 |
+
self._save_accounts()
|
| 885 |
+
return dict(account)
|
| 886 |
+
return None
|
| 887 |
+
|
| 888 |
+
def fetch_remote_info(self, access_token: str, event: str = "fetch_remote_info") -> dict[str, Any] | None:
|
| 889 |
+
if not access_token:
|
| 890 |
+
raise ValueError("access_token is required")
|
| 891 |
+
|
| 892 |
+
active_token = self.refresh_access_token(access_token, event=f"{event}:preflight") or access_token
|
| 893 |
+
try:
|
| 894 |
+
from services.openai_backend_api import InvalidAccessTokenError, OpenAIBackendAPI
|
| 895 |
+
result = OpenAIBackendAPI(active_token).get_user_info()
|
| 896 |
+
except InvalidAccessTokenError as exc:
|
| 897 |
+
refreshed_token = self.refresh_access_token(active_token, force=True, event=f"{event}:invalid_access_token")
|
| 898 |
+
if refreshed_token and refreshed_token != active_token:
|
| 899 |
+
try:
|
| 900 |
+
result = OpenAIBackendAPI(refreshed_token).get_user_info()
|
| 901 |
+
except InvalidAccessTokenError as retry_exc:
|
| 902 |
+
if self._record_invalid_token_seen(refreshed_token, event, str(retry_exc)):
|
| 903 |
+
self.remove_invalid_token(refreshed_token, event)
|
| 904 |
+
raise
|
| 905 |
+
active_token = refreshed_token
|
| 906 |
+
else:
|
| 907 |
+
if self._record_invalid_token_seen(active_token, event, str(exc)):
|
| 908 |
+
self.remove_invalid_token(active_token, event)
|
| 909 |
+
raise
|
| 910 |
+
self._record_refresh_success(active_token)
|
| 911 |
+
return self.update_account(active_token, result)
|
| 912 |
+
|
| 913 |
+
def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]:
|
| 914 |
+
access_tokens = list(dict.fromkeys(token for token in access_tokens if token))
|
| 915 |
+
if not access_tokens:
|
| 916 |
+
return {"refreshed": 0, "errors": [], "items": self.list_accounts()}
|
| 917 |
+
|
| 918 |
+
refreshed = 0
|
| 919 |
+
errors = []
|
| 920 |
+
max_workers = min(10, len(access_tokens))
|
| 921 |
+
|
| 922 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 923 |
+
futures = {
|
| 924 |
+
executor.submit(self.fetch_remote_info, token, "refresh_accounts"): token
|
| 925 |
+
for token in access_tokens
|
| 926 |
+
}
|
| 927 |
+
for future in as_completed(futures):
|
| 928 |
+
try:
|
| 929 |
+
account = future.result()
|
| 930 |
+
except Exception as exc:
|
| 931 |
+
errors.append({"token": anonymize_token(futures[future]), "error": str(exc)})
|
| 932 |
+
continue
|
| 933 |
+
if account is not None:
|
| 934 |
+
refreshed += 1
|
| 935 |
+
|
| 936 |
+
return {
|
| 937 |
+
"refreshed": refreshed,
|
| 938 |
+
"errors": errors,
|
| 939 |
+
"items": self.list_accounts(),
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
def build_export_items(self, access_tokens: list[str] | None = None) -> list[dict[str, str]]:
|
| 943 |
+
target_tokens = set(token for token in (access_tokens or []) if token)
|
| 944 |
+
with self._lock:
|
| 945 |
+
accounts = [
|
| 946 |
+
dict(item)
|
| 947 |
+
for item in self._accounts.values()
|
| 948 |
+
if not target_tokens or str(item.get("access_token") or "") in target_tokens
|
| 949 |
+
]
|
| 950 |
+
|
| 951 |
+
items: list[dict[str, str]] = []
|
| 952 |
+
for account in accounts:
|
| 953 |
+
access_token = str(account.get("access_token") or "").strip()
|
| 954 |
+
refresh_token = str(account.get("refresh_token") or "").strip()
|
| 955 |
+
id_token = str(account.get("id_token") or "").strip()
|
| 956 |
+
if not access_token or not refresh_token or not id_token:
|
| 957 |
+
continue
|
| 958 |
+
|
| 959 |
+
access_payload = self._decode_jwt_payload(access_token)
|
| 960 |
+
id_payload = self._decode_jwt_payload(id_token)
|
| 961 |
+
auth_claim = access_payload.get("https://api.openai.com/auth")
|
| 962 |
+
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
|
| 963 |
+
profile_claim = access_payload.get("https://api.openai.com/profile")
|
| 964 |
+
profile_claim = profile_claim if isinstance(profile_claim, dict) else {}
|
| 965 |
+
|
| 966 |
+
email = (
|
| 967 |
+
str(account.get("email") or "").strip()
|
| 968 |
+
or str(profile_claim.get("email") or "").strip()
|
| 969 |
+
or str(id_payload.get("email") or "").strip()
|
| 970 |
+
)
|
| 971 |
+
account_id = (
|
| 972 |
+
str(account.get("account_id") or "").strip()
|
| 973 |
+
or str(auth_claim.get("chatgpt_account_id") or "").strip()
|
| 974 |
+
or str(account.get("user_id") or "").strip()
|
| 975 |
+
)
|
| 976 |
+
item = {
|
| 977 |
+
"type": str(account.get("export_type") or "codex"),
|
| 978 |
+
"email": email,
|
| 979 |
+
"account_id": account_id,
|
| 980 |
+
"access_token": access_token,
|
| 981 |
+
"refresh_token": refresh_token,
|
| 982 |
+
"id_token": id_token,
|
| 983 |
+
"expired": self._timestamp_to_iso(access_payload.get("exp")),
|
| 984 |
+
"last_refresh": self._timestamp_to_iso(access_payload.get("iat")),
|
| 985 |
+
}
|
| 986 |
+
password = str(account.get("password") or "").strip()
|
| 987 |
+
if password:
|
| 988 |
+
item["password"] = password
|
| 989 |
+
items.append(item)
|
| 990 |
+
return items
|
| 991 |
+
|
| 992 |
+
def get_stats(self) -> dict:
|
| 993 |
+
with self._lock:
|
| 994 |
+
items = list(self._accounts.values())
|
| 995 |
+
total = len(items)
|
| 996 |
+
active = sum(1 for a in items if a.get("status") == "正常")
|
| 997 |
+
limited = sum(1 for a in items if a.get("status") == "限流")
|
| 998 |
+
abnormal = sum(1 for a in items if a.get("status") == "异常")
|
| 999 |
+
disabled = sum(1 for a in items if a.get("status") == "禁用")
|
| 1000 |
+
total_quota = sum(max(0, int(a.get("quota") or 0)) for a in items if a.get("status") == "正常")
|
| 1001 |
+
unlimited = sum(1 for a in items if a.get("status") == "正常" and bool(a.get("image_quota_unknown")))
|
| 1002 |
+
total_success = sum(int(a.get("success") or 0) for a in items)
|
| 1003 |
+
total_fail = sum(int(a.get("fail") or 0) for a in items)
|
| 1004 |
+
by_type = {}
|
| 1005 |
+
for a in items:
|
| 1006 |
+
t = a.get("type", "unknown")
|
| 1007 |
+
by_type[t] = by_type.get(t, 0) + 1
|
| 1008 |
+
return {
|
| 1009 |
+
"total": total,
|
| 1010 |
+
"cumulative_total": self._cumulative_total,
|
| 1011 |
+
"active": active,
|
| 1012 |
+
"limited": limited,
|
| 1013 |
+
"abnormal": abnormal,
|
| 1014 |
+
"disabled": disabled,
|
| 1015 |
+
"total_quota": total_quota,
|
| 1016 |
+
"unlimited_quota_count": unlimited,
|
| 1017 |
+
"total_success": total_success,
|
| 1018 |
+
"total_fail": total_fail,
|
| 1019 |
+
"by_type": by_type,
|
| 1020 |
+
}
|
| 1021 |
+
|
| 1022 |
+
def account_health(self) -> dict:
|
| 1023 |
+
stats = self.get_stats()
|
| 1024 |
+
return {
|
| 1025 |
+
"healthy": stats["active"] > 0 or stats["unlimited_quota_count"] > 0,
|
| 1026 |
+
"status": "ok" if stats["active"] > 0 else "degraded",
|
| 1027 |
+
**stats,
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
account_service = AccountService(config.get_storage_backend())
|
services/auth_service.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import secrets
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from threading import Lock
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from services.config import config
|
| 12 |
+
from services.storage.base import StorageBackend
|
| 13 |
+
|
| 14 |
+
AuthRole = Literal["admin", "user"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _now_iso() -> str:
|
| 18 |
+
return datetime.now(timezone.utc).isoformat()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _hash_key(value: str) -> str:
|
| 22 |
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AuthService:
|
| 26 |
+
def __init__(self, storage: StorageBackend):
|
| 27 |
+
self.storage = storage
|
| 28 |
+
self._lock = Lock()
|
| 29 |
+
self._items = self._load()
|
| 30 |
+
self._last_used_flush_at: dict[str, datetime] = {}
|
| 31 |
+
|
| 32 |
+
@staticmethod
|
| 33 |
+
def _clean(value: object) -> str:
|
| 34 |
+
return str(value or "").strip()
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
def _default_name(role: object) -> str:
|
| 38 |
+
return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
|
| 39 |
+
|
| 40 |
+
def _normalize_item(self, raw: object) -> dict[str, object] | None:
|
| 41 |
+
if not isinstance(raw, dict):
|
| 42 |
+
return None
|
| 43 |
+
role = self._clean(raw.get("role")).lower()
|
| 44 |
+
if role not in {"admin", "user"}:
|
| 45 |
+
return None
|
| 46 |
+
key_hash = self._clean(raw.get("key_hash"))
|
| 47 |
+
if not key_hash:
|
| 48 |
+
return None
|
| 49 |
+
item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
|
| 50 |
+
name = self._clean(raw.get("name")) or self._default_name(role)
|
| 51 |
+
created_at = self._clean(raw.get("created_at")) or _now_iso()
|
| 52 |
+
last_used_at = self._clean(raw.get("last_used_at")) or None
|
| 53 |
+
return {
|
| 54 |
+
"id": item_id,
|
| 55 |
+
"name": name,
|
| 56 |
+
"role": role,
|
| 57 |
+
"key_hash": key_hash,
|
| 58 |
+
"enabled": bool(raw.get("enabled", True)),
|
| 59 |
+
"created_at": created_at,
|
| 60 |
+
"last_used_at": last_used_at,
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
def _load(self) -> list[dict[str, object]]:
|
| 64 |
+
try:
|
| 65 |
+
items = self.storage.load_auth_keys()
|
| 66 |
+
except Exception:
|
| 67 |
+
return []
|
| 68 |
+
if not isinstance(items, list):
|
| 69 |
+
return []
|
| 70 |
+
return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
|
| 71 |
+
|
| 72 |
+
def _save(self) -> None:
|
| 73 |
+
self.storage.save_auth_keys(self._items)
|
| 74 |
+
|
| 75 |
+
def _reload_locked(self) -> None:
|
| 76 |
+
self._items = self._load()
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def _public_item(item: dict[str, object]) -> dict[str, object]:
|
| 80 |
+
return {
|
| 81 |
+
"id": item.get("id"),
|
| 82 |
+
"name": item.get("name"),
|
| 83 |
+
"role": item.get("role"),
|
| 84 |
+
"enabled": bool(item.get("enabled", True)),
|
| 85 |
+
"created_at": item.get("created_at"),
|
| 86 |
+
"last_used_at": item.get("last_used_at"),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
|
| 90 |
+
with self._lock:
|
| 91 |
+
self._reload_locked()
|
| 92 |
+
items = [item for item in self._items if role is None or item.get("role") == role]
|
| 93 |
+
return [self._public_item(item) for item in items]
|
| 94 |
+
|
| 95 |
+
def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
|
| 96 |
+
for item in self._items:
|
| 97 |
+
item_id = self._clean(item.get("id"))
|
| 98 |
+
if exclude_id and item_id == exclude_id:
|
| 99 |
+
continue
|
| 100 |
+
stored_hash = self._clean(item.get("key_hash"))
|
| 101 |
+
if stored_hash and hmac.compare_digest(stored_hash, key_hash):
|
| 102 |
+
return True
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
|
| 106 |
+
candidate = self._clean(raw_key)
|
| 107 |
+
if not candidate:
|
| 108 |
+
raise ValueError("请输入新的专用密钥")
|
| 109 |
+
admin_key = self._clean(config.auth_key)
|
| 110 |
+
if admin_key and hmac.compare_digest(candidate, admin_key):
|
| 111 |
+
raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
|
| 112 |
+
key_hash = _hash_key(candidate)
|
| 113 |
+
if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
|
| 114 |
+
raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
|
| 115 |
+
return key_hash
|
| 116 |
+
|
| 117 |
+
def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
|
| 118 |
+
candidate = self._clean(name)
|
| 119 |
+
if not candidate:
|
| 120 |
+
return False
|
| 121 |
+
for item in self._items:
|
| 122 |
+
item_id = self._clean(item.get("id"))
|
| 123 |
+
if exclude_id and item_id == exclude_id:
|
| 124 |
+
continue
|
| 125 |
+
if role is not None and item.get("role") != role:
|
| 126 |
+
continue
|
| 127 |
+
if self._clean(item.get("name")) == candidate:
|
| 128 |
+
return True
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
|
| 132 |
+
base_name = self._default_name(role)
|
| 133 |
+
if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
|
| 134 |
+
return base_name
|
| 135 |
+
suffix = 2
|
| 136 |
+
while True:
|
| 137 |
+
candidate = f"{base_name} {suffix}"
|
| 138 |
+
if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
|
| 139 |
+
return candidate
|
| 140 |
+
suffix += 1
|
| 141 |
+
|
| 142 |
+
def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
|
| 143 |
+
candidate = self._clean(name)
|
| 144 |
+
if not candidate:
|
| 145 |
+
return self._build_default_name_locked(role, exclude_id=exclude_id)
|
| 146 |
+
if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
|
| 147 |
+
raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
|
| 148 |
+
return candidate
|
| 149 |
+
|
| 150 |
+
def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
|
| 151 |
+
with self._lock:
|
| 152 |
+
self._reload_locked()
|
| 153 |
+
normalized_name = self._build_name_locked(name, role=role)
|
| 154 |
+
while True:
|
| 155 |
+
raw_key = f"sk-{secrets.token_urlsafe(24)}"
|
| 156 |
+
try:
|
| 157 |
+
key_hash = self._build_key_hash_locked(raw_key)
|
| 158 |
+
break
|
| 159 |
+
except ValueError:
|
| 160 |
+
continue
|
| 161 |
+
item = {
|
| 162 |
+
"id": uuid.uuid4().hex[:12],
|
| 163 |
+
"name": normalized_name,
|
| 164 |
+
"role": role,
|
| 165 |
+
"key_hash": key_hash,
|
| 166 |
+
"enabled": True,
|
| 167 |
+
"created_at": _now_iso(),
|
| 168 |
+
"last_used_at": None,
|
| 169 |
+
}
|
| 170 |
+
self._items.append(item)
|
| 171 |
+
self._save()
|
| 172 |
+
return self._public_item(item), raw_key
|
| 173 |
+
|
| 174 |
+
def update_key(
|
| 175 |
+
self,
|
| 176 |
+
key_id: str,
|
| 177 |
+
updates: dict[str, object],
|
| 178 |
+
*,
|
| 179 |
+
role: AuthRole | None = None,
|
| 180 |
+
) -> dict[str, object] | None:
|
| 181 |
+
normalized_id = self._clean(key_id)
|
| 182 |
+
if not normalized_id:
|
| 183 |
+
return None
|
| 184 |
+
with self._lock:
|
| 185 |
+
self._reload_locked()
|
| 186 |
+
for index, item in enumerate(self._items):
|
| 187 |
+
if item.get("id") != normalized_id:
|
| 188 |
+
continue
|
| 189 |
+
if role is not None and item.get("role") != role:
|
| 190 |
+
return None
|
| 191 |
+
next_item = dict(item)
|
| 192 |
+
next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
|
| 193 |
+
if "name" in updates and updates.get("name") is not None:
|
| 194 |
+
next_item["name"] = self._build_name_locked(
|
| 195 |
+
str(updates.get("name") or ""),
|
| 196 |
+
role=next_role,
|
| 197 |
+
exclude_id=normalized_id,
|
| 198 |
+
)
|
| 199 |
+
if "enabled" in updates and updates.get("enabled") is not None:
|
| 200 |
+
next_item["enabled"] = bool(updates.get("enabled"))
|
| 201 |
+
if "key" in updates and updates.get("key") is not None:
|
| 202 |
+
next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
|
| 203 |
+
self._items[index] = next_item
|
| 204 |
+
self._save()
|
| 205 |
+
return self._public_item(next_item)
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool:
|
| 209 |
+
normalized_id = self._clean(key_id)
|
| 210 |
+
if not normalized_id:
|
| 211 |
+
return False
|
| 212 |
+
with self._lock:
|
| 213 |
+
self._reload_locked()
|
| 214 |
+
before = len(self._items)
|
| 215 |
+
self._items = [
|
| 216 |
+
item
|
| 217 |
+
for item in self._items
|
| 218 |
+
if not (item.get("id") == normalized_id and (role is None or item.get("role") == role))
|
| 219 |
+
]
|
| 220 |
+
if len(self._items) == before:
|
| 221 |
+
return False
|
| 222 |
+
self._save()
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
def authenticate(self, raw_key: str) -> dict[str, object] | None:
|
| 226 |
+
candidate = self._clean(raw_key)
|
| 227 |
+
if not candidate:
|
| 228 |
+
return None
|
| 229 |
+
candidate_hash = _hash_key(candidate)
|
| 230 |
+
with self._lock:
|
| 231 |
+
for index, item in enumerate(self._items):
|
| 232 |
+
if not bool(item.get("enabled", True)):
|
| 233 |
+
continue
|
| 234 |
+
stored_hash = self._clean(item.get("key_hash"))
|
| 235 |
+
if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash):
|
| 236 |
+
continue
|
| 237 |
+
next_item = dict(item)
|
| 238 |
+
now = datetime.now(timezone.utc)
|
| 239 |
+
next_item["last_used_at"] = now.isoformat()
|
| 240 |
+
self._items[index] = next_item
|
| 241 |
+
item_id = self._clean(next_item.get("id"))
|
| 242 |
+
last_flush_at = self._last_used_flush_at.get(item_id)
|
| 243 |
+
if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60:
|
| 244 |
+
try:
|
| 245 |
+
self._save()
|
| 246 |
+
self._last_used_flush_at[item_id] = now
|
| 247 |
+
except Exception:
|
| 248 |
+
pass
|
| 249 |
+
return self._public_item(next_item)
|
| 250 |
+
return None
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
auth_service = AuthService(config.get_storage_backend())
|
services/backup_service.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import hmac
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import random
|
| 9 |
+
import subprocess
|
| 10 |
+
import tarfile
|
| 11 |
+
import threading
|
| 12 |
+
from datetime import UTC, datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from urllib.parse import quote, urlencode
|
| 15 |
+
|
| 16 |
+
from curl_cffi import requests
|
| 17 |
+
|
| 18 |
+
from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
|
| 19 |
+
from services.image_storage_service import IMAGE_INDEX_FILE
|
| 20 |
+
from services.image_tags_service import TAGS_FILE
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _utc_now() -> datetime:
|
| 24 |
+
return datetime.now(UTC)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _iso_now() -> str:
|
| 28 |
+
return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _clean(value: object) -> str:
|
| 32 |
+
return str(value or "").strip()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _sha256_hex(value: bytes) -> str:
|
| 36 |
+
return hashlib.sha256(value).hexdigest()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _is_backup_object(key: object) -> bool:
|
| 40 |
+
name = _clean(key).rsplit("/", 1)[-1]
|
| 41 |
+
return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc"))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _hmac_sha256(key: bytes, message: str) -> bytes:
|
| 45 |
+
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _openssl_encrypt(data: bytes, passphrase: str) -> bytes:
|
| 49 |
+
env = dict(os.environ)
|
| 50 |
+
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
|
| 51 |
+
try:
|
| 52 |
+
result = subprocess.run(
|
| 53 |
+
[
|
| 54 |
+
"openssl",
|
| 55 |
+
"enc",
|
| 56 |
+
"-aes-256-cbc",
|
| 57 |
+
"-pbkdf2",
|
| 58 |
+
"-salt",
|
| 59 |
+
"-md",
|
| 60 |
+
"sha256",
|
| 61 |
+
"-pass",
|
| 62 |
+
"env:CHATGPT2API_BACKUP_PASSPHRASE",
|
| 63 |
+
],
|
| 64 |
+
input=data,
|
| 65 |
+
stdout=subprocess.PIPE,
|
| 66 |
+
stderr=subprocess.PIPE,
|
| 67 |
+
check=True,
|
| 68 |
+
env=env,
|
| 69 |
+
)
|
| 70 |
+
except FileNotFoundError as exc:
|
| 71 |
+
raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc
|
| 72 |
+
except subprocess.CalledProcessError as exc:
|
| 73 |
+
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
|
| 74 |
+
raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc
|
| 75 |
+
return result.stdout
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _openssl_decrypt(data: bytes, passphrase: str) -> bytes:
|
| 79 |
+
env = dict(os.environ)
|
| 80 |
+
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
|
| 81 |
+
try:
|
| 82 |
+
result = subprocess.run(
|
| 83 |
+
[
|
| 84 |
+
"openssl",
|
| 85 |
+
"enc",
|
| 86 |
+
"-d",
|
| 87 |
+
"-aes-256-cbc",
|
| 88 |
+
"-pbkdf2",
|
| 89 |
+
"-md",
|
| 90 |
+
"sha256",
|
| 91 |
+
"-pass",
|
| 92 |
+
"env:CHATGPT2API_BACKUP_PASSPHRASE",
|
| 93 |
+
],
|
| 94 |
+
input=data,
|
| 95 |
+
stdout=subprocess.PIPE,
|
| 96 |
+
stderr=subprocess.PIPE,
|
| 97 |
+
check=True,
|
| 98 |
+
env=env,
|
| 99 |
+
)
|
| 100 |
+
except FileNotFoundError as exc:
|
| 101 |
+
raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc
|
| 102 |
+
except subprocess.CalledProcessError as exc:
|
| 103 |
+
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
|
| 104 |
+
raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc
|
| 105 |
+
return result.stdout
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _guess_content_type(name: str) -> str:
|
| 109 |
+
if name.endswith(".json"):
|
| 110 |
+
return "application/json"
|
| 111 |
+
if name.endswith(".jsonl"):
|
| 112 |
+
return "application/x-ndjson"
|
| 113 |
+
if name.endswith(".tar.gz"):
|
| 114 |
+
return "application/gzip"
|
| 115 |
+
if name.endswith(".gz"):
|
| 116 |
+
return "application/gzip"
|
| 117 |
+
return "application/octet-stream"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _json_bytes(value: object) -> bytes:
|
| 121 |
+
return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _count_items(value: object) -> int:
|
| 125 |
+
if isinstance(value, list):
|
| 126 |
+
return len(value)
|
| 127 |
+
if isinstance(value, dict):
|
| 128 |
+
return len(value)
|
| 129 |
+
return 0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class BackupError(RuntimeError):
|
| 133 |
+
pass
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class CloudflareR2Client:
|
| 137 |
+
def __init__(self, settings: dict[str, object]) -> None:
|
| 138 |
+
self.account_id = _clean(settings.get("account_id"))
|
| 139 |
+
self.access_key_id = _clean(settings.get("access_key_id"))
|
| 140 |
+
self.secret_access_key = _clean(settings.get("secret_access_key"))
|
| 141 |
+
self.bucket = _clean(settings.get("bucket"))
|
| 142 |
+
self.prefix = _clean(settings.get("prefix")) or "backups"
|
| 143 |
+
self.session = requests.Session(impersonate="chrome", verify=True)
|
| 144 |
+
|
| 145 |
+
def validate(self) -> None:
|
| 146 |
+
missing = []
|
| 147 |
+
if not self.account_id:
|
| 148 |
+
missing.append("Account ID")
|
| 149 |
+
if not self.access_key_id:
|
| 150 |
+
missing.append("Access Key ID")
|
| 151 |
+
if not self.secret_access_key:
|
| 152 |
+
missing.append("Secret Access Key")
|
| 153 |
+
if not self.bucket:
|
| 154 |
+
missing.append("Bucket")
|
| 155 |
+
if missing:
|
| 156 |
+
raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}")
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def endpoint(self) -> str:
|
| 160 |
+
return f"https://{self.account_id}.r2.cloudflarestorage.com"
|
| 161 |
+
|
| 162 |
+
def _aws_v4_headers(
|
| 163 |
+
self,
|
| 164 |
+
method: str,
|
| 165 |
+
path: str,
|
| 166 |
+
*,
|
| 167 |
+
query: dict[str, str] | None = None,
|
| 168 |
+
body: bytes = b"",
|
| 169 |
+
extra_headers: dict[str, str] | None = None,
|
| 170 |
+
) -> tuple[str, dict[str, str]]:
|
| 171 |
+
now = _utc_now()
|
| 172 |
+
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
| 173 |
+
date_stamp = now.strftime("%Y%m%d")
|
| 174 |
+
encoded_query = urlencode(sorted((query or {}).items()))
|
| 175 |
+
payload_hash = _sha256_hex(body)
|
| 176 |
+
host = f"{self.account_id}.r2.cloudflarestorage.com"
|
| 177 |
+
headers = {
|
| 178 |
+
"host": host,
|
| 179 |
+
"x-amz-content-sha256": payload_hash,
|
| 180 |
+
"x-amz-date": amz_date,
|
| 181 |
+
}
|
| 182 |
+
if extra_headers:
|
| 183 |
+
for key, value in extra_headers.items():
|
| 184 |
+
headers[key.lower()] = value.strip()
|
| 185 |
+
sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items())
|
| 186 |
+
canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items)
|
| 187 |
+
signed_headers = ";".join(key for key, _ in sorted_items)
|
| 188 |
+
canonical_request = "\n".join([
|
| 189 |
+
method.upper(),
|
| 190 |
+
path,
|
| 191 |
+
encoded_query,
|
| 192 |
+
canonical_headers,
|
| 193 |
+
signed_headers,
|
| 194 |
+
payload_hash,
|
| 195 |
+
])
|
| 196 |
+
credential_scope = f"{date_stamp}/auto/s3/aws4_request"
|
| 197 |
+
string_to_sign = "\n".join([
|
| 198 |
+
"AWS4-HMAC-SHA256",
|
| 199 |
+
amz_date,
|
| 200 |
+
credential_scope,
|
| 201 |
+
_sha256_hex(canonical_request.encode("utf-8")),
|
| 202 |
+
])
|
| 203 |
+
k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp)
|
| 204 |
+
k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest()
|
| 205 |
+
k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
|
| 206 |
+
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
|
| 207 |
+
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
| 208 |
+
authorization = (
|
| 209 |
+
"AWS4-HMAC-SHA256 "
|
| 210 |
+
f"Credential={self.access_key_id}/{credential_scope}, "
|
| 211 |
+
f"SignedHeaders={signed_headers}, "
|
| 212 |
+
f"Signature={signature}"
|
| 213 |
+
)
|
| 214 |
+
request_headers = {key: value for key, value in headers.items()}
|
| 215 |
+
request_headers["authorization"] = authorization
|
| 216 |
+
return encoded_query, request_headers
|
| 217 |
+
|
| 218 |
+
def _request(
|
| 219 |
+
self,
|
| 220 |
+
method: str,
|
| 221 |
+
key: str = "",
|
| 222 |
+
*,
|
| 223 |
+
query: dict[str, str] | None = None,
|
| 224 |
+
body: bytes = b"",
|
| 225 |
+
extra_headers: dict[str, str] | None = None,
|
| 226 |
+
timeout: float = 60.0,
|
| 227 |
+
):
|
| 228 |
+
object_path = f"/{self.bucket}"
|
| 229 |
+
if key:
|
| 230 |
+
object_path += f"/{quote(key.lstrip('/'), safe='/')}"
|
| 231 |
+
encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers)
|
| 232 |
+
url = f"{self.endpoint}{object_path}"
|
| 233 |
+
if encoded_query:
|
| 234 |
+
url += f"?{encoded_query}"
|
| 235 |
+
response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout)
|
| 236 |
+
return response
|
| 237 |
+
|
| 238 |
+
def test_connection(self) -> dict[str, object]:
|
| 239 |
+
self.validate()
|
| 240 |
+
response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0)
|
| 241 |
+
if response.status_code >= 400:
|
| 242 |
+
raise BackupError(f"连接 R2 失败:HTTP {response.status_code}")
|
| 243 |
+
return {"ok": True, "status": int(response.status_code)}
|
| 244 |
+
|
| 245 |
+
def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]:
|
| 246 |
+
headers = {"content-type": content_type}
|
| 247 |
+
if metadata:
|
| 248 |
+
for item_key, item_value in metadata.items():
|
| 249 |
+
headers[f"x-amz-meta-{item_key}"] = str(item_value)
|
| 250 |
+
response = self._request("PUT", key, body=payload, extra_headers=headers)
|
| 251 |
+
if response.status_code >= 400:
|
| 252 |
+
raise BackupError(f"上传备份失败:HTTP {response.status_code}")
|
| 253 |
+
return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')}
|
| 254 |
+
|
| 255 |
+
def delete_object(self, key: str) -> None:
|
| 256 |
+
response = self._request("DELETE", key, timeout=30.0)
|
| 257 |
+
if response.status_code >= 400 and response.status_code != 404:
|
| 258 |
+
raise BackupError(f"删除备份失败:HTTP {response.status_code}")
|
| 259 |
+
|
| 260 |
+
def download_bytes(self, key: str) -> bytes:
|
| 261 |
+
response = self._request("GET", key, timeout=60.0)
|
| 262 |
+
if response.status_code >= 400:
|
| 263 |
+
raise BackupError(f"读取备份失败:HTTP {response.status_code}")
|
| 264 |
+
return bytes(response.content or b"")
|
| 265 |
+
|
| 266 |
+
def list_objects(self) -> list[dict[str, object]]:
|
| 267 |
+
items: list[dict[str, object]] = []
|
| 268 |
+
continuation = ""
|
| 269 |
+
while True:
|
| 270 |
+
query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"}
|
| 271 |
+
if continuation:
|
| 272 |
+
query["continuation-token"] = continuation
|
| 273 |
+
response = self._request("GET", query=query, timeout=30.0)
|
| 274 |
+
if response.status_code >= 400:
|
| 275 |
+
raise BackupError(f"获取备份列表失败:HTTP {response.status_code}")
|
| 276 |
+
text = response.text
|
| 277 |
+
for block in text.split("<Contents>")[1:]:
|
| 278 |
+
key = _clean(block.split("<Key>", 1)[1].split("</Key>", 1)[0]) if "<Key>" in block else ""
|
| 279 |
+
if not key:
|
| 280 |
+
continue
|
| 281 |
+
size_text = _clean(block.split("<Size>", 1)[1].split("</Size>", 1)[0]) if "<Size>" in block else "0"
|
| 282 |
+
updated = _clean(block.split("<LastModified>", 1)[1].split("</LastModified>", 1)[0]) if "<LastModified>" in block else ""
|
| 283 |
+
items.append({
|
| 284 |
+
"key": key,
|
| 285 |
+
"size": int(size_text or 0),
|
| 286 |
+
"updated_at": updated,
|
| 287 |
+
})
|
| 288 |
+
truncated = "<IsTruncated>true</IsTruncated>" in text
|
| 289 |
+
if not truncated:
|
| 290 |
+
break
|
| 291 |
+
if "<NextContinuationToken>" not in text:
|
| 292 |
+
break
|
| 293 |
+
continuation = _clean(text.split("<NextContinuationToken>", 1)[1].split("</NextContinuationToken>", 1)[0])
|
| 294 |
+
if not continuation:
|
| 295 |
+
break
|
| 296 |
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 297 |
+
return items
|
| 298 |
+
|
| 299 |
+
def close(self) -> None:
|
| 300 |
+
self.session.close()
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
class BackupService:
|
| 304 |
+
def __init__(self) -> None:
|
| 305 |
+
self._lock = threading.RLock()
|
| 306 |
+
self._stop_event = threading.Event()
|
| 307 |
+
self._thread: threading.Thread | None = None
|
| 308 |
+
self._running = False
|
| 309 |
+
|
| 310 |
+
def start(self) -> None:
|
| 311 |
+
with self._lock:
|
| 312 |
+
if self._thread and self._thread.is_alive():
|
| 313 |
+
return
|
| 314 |
+
self._stop_event.clear()
|
| 315 |
+
self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler")
|
| 316 |
+
self._thread.start()
|
| 317 |
+
|
| 318 |
+
def stop(self) -> None:
|
| 319 |
+
with self._lock:
|
| 320 |
+
self._stop_event.set()
|
| 321 |
+
thread = self._thread
|
| 322 |
+
self._thread = None
|
| 323 |
+
if thread and thread.is_alive():
|
| 324 |
+
thread.join(timeout=2)
|
| 325 |
+
|
| 326 |
+
def _run(self) -> None:
|
| 327 |
+
while not self._stop_event.is_set():
|
| 328 |
+
try:
|
| 329 |
+
self.run_scheduled_backup_if_needed()
|
| 330 |
+
except Exception:
|
| 331 |
+
pass
|
| 332 |
+
self._stop_event.wait(30)
|
| 333 |
+
|
| 334 |
+
def run_scheduled_backup_if_needed(self) -> None:
|
| 335 |
+
settings = config.get_backup_settings()
|
| 336 |
+
if not settings.get("enabled"):
|
| 337 |
+
return
|
| 338 |
+
state = self.get_status()
|
| 339 |
+
if state.get("running"):
|
| 340 |
+
return
|
| 341 |
+
interval_minutes = int(settings.get("interval_minutes") or 360)
|
| 342 |
+
last_finished_raw = _clean(state.get("last_finished_at"))
|
| 343 |
+
if last_finished_raw:
|
| 344 |
+
try:
|
| 345 |
+
last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00"))
|
| 346 |
+
elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds()
|
| 347 |
+
if elapsed < interval_minutes * 60:
|
| 348 |
+
return
|
| 349 |
+
except Exception:
|
| 350 |
+
pass
|
| 351 |
+
self.run_backup(trigger="schedule")
|
| 352 |
+
|
| 353 |
+
def get_status(self) -> dict[str, object]:
|
| 354 |
+
return {
|
| 355 |
+
**load_backup_state(),
|
| 356 |
+
"running": self._running,
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
def is_configured(self) -> bool:
|
| 360 |
+
settings = config.get_backup_settings()
|
| 361 |
+
return all([
|
| 362 |
+
_clean(settings.get("account_id")),
|
| 363 |
+
_clean(settings.get("access_key_id")),
|
| 364 |
+
_clean(settings.get("secret_access_key")),
|
| 365 |
+
_clean(settings.get("bucket")),
|
| 366 |
+
])
|
| 367 |
+
|
| 368 |
+
def get_settings(self) -> dict[str, object]:
|
| 369 |
+
settings = dict(config.get_backup_settings())
|
| 370 |
+
settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else ""
|
| 371 |
+
settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else ""
|
| 372 |
+
return settings
|
| 373 |
+
|
| 374 |
+
def update_settings(self, payload: dict[str, object]) -> dict[str, object]:
|
| 375 |
+
current = config.get_backup_settings()
|
| 376 |
+
merged = dict(current)
|
| 377 |
+
merged.update(dict(payload or {}))
|
| 378 |
+
if "include" in payload and isinstance(payload.get("include"), dict):
|
| 379 |
+
include = dict(current.get("include") or {})
|
| 380 |
+
include.update(payload.get("include") or {})
|
| 381 |
+
merged["include"] = include
|
| 382 |
+
if payload.get("secret_access_key") == "********":
|
| 383 |
+
merged["secret_access_key"] = current.get("secret_access_key")
|
| 384 |
+
if payload.get("passphrase") == "********":
|
| 385 |
+
merged["passphrase"] = current.get("passphrase")
|
| 386 |
+
updated = config.update({"backup": merged})
|
| 387 |
+
return dict(updated.get("backup") or {})
|
| 388 |
+
|
| 389 |
+
def test_connection(self) -> dict[str, object]:
|
| 390 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 391 |
+
try:
|
| 392 |
+
return client.test_connection()
|
| 393 |
+
finally:
|
| 394 |
+
client.close()
|
| 395 |
+
|
| 396 |
+
def list_backups(self) -> list[dict[str, object]]:
|
| 397 |
+
if not self.is_configured():
|
| 398 |
+
return []
|
| 399 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 400 |
+
try:
|
| 401 |
+
items = client.list_objects()
|
| 402 |
+
finally:
|
| 403 |
+
client.close()
|
| 404 |
+
parsed: list[dict[str, object]] = []
|
| 405 |
+
for item in items:
|
| 406 |
+
key = _clean(item.get("key"))
|
| 407 |
+
name = key.rsplit("/", 1)[-1]
|
| 408 |
+
encrypted = name.endswith(".enc")
|
| 409 |
+
parsed.append({
|
| 410 |
+
"key": key,
|
| 411 |
+
"name": name,
|
| 412 |
+
"size": int(item.get("size") or 0),
|
| 413 |
+
"updated_at": item.get("updated_at"),
|
| 414 |
+
"encrypted": encrypted,
|
| 415 |
+
})
|
| 416 |
+
return parsed
|
| 417 |
+
|
| 418 |
+
def delete_backup(self, key: str) -> None:
|
| 419 |
+
candidate = _clean(key)
|
| 420 |
+
if not candidate:
|
| 421 |
+
raise BackupError("备份对象 key 不能为空")
|
| 422 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 423 |
+
try:
|
| 424 |
+
client.delete_object(candidate)
|
| 425 |
+
finally:
|
| 426 |
+
client.close()
|
| 427 |
+
|
| 428 |
+
def download_backup(self, key: str) -> dict[str, object]:
|
| 429 |
+
candidate = _clean(key)
|
| 430 |
+
if not candidate:
|
| 431 |
+
raise BackupError("备份对象 key 不能为空")
|
| 432 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 433 |
+
try:
|
| 434 |
+
payload = client.download_bytes(candidate)
|
| 435 |
+
finally:
|
| 436 |
+
client.close()
|
| 437 |
+
name = candidate.rsplit("/", 1)[-1] or "backup.bin"
|
| 438 |
+
if candidate.endswith(".enc"):
|
| 439 |
+
passphrase = _clean(config.get_backup_settings().get("passphrase"))
|
| 440 |
+
if not passphrase:
|
| 441 |
+
raise BackupError("当前未配置加密口令,无法下载并解密已加密备份")
|
| 442 |
+
payload = _openssl_decrypt(payload, passphrase)
|
| 443 |
+
if name.endswith(".enc"):
|
| 444 |
+
name = name[:-4] or "backup.tar.gz"
|
| 445 |
+
return {
|
| 446 |
+
"key": candidate,
|
| 447 |
+
"name": name,
|
| 448 |
+
"content_type": _guess_content_type(name),
|
| 449 |
+
"payload": payload,
|
| 450 |
+
"size": len(payload),
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
def get_backup_detail(self, key: str) -> dict[str, object]:
|
| 454 |
+
candidate = _clean(key)
|
| 455 |
+
if not candidate:
|
| 456 |
+
raise BackupError("备份对象 key 不能为空")
|
| 457 |
+
client = CloudflareR2Client(config.get_backup_settings())
|
| 458 |
+
try:
|
| 459 |
+
payload = client.download_bytes(candidate)
|
| 460 |
+
finally:
|
| 461 |
+
client.close()
|
| 462 |
+
detail = self._decode_backup_payload(candidate, payload)
|
| 463 |
+
detail["key"] = candidate
|
| 464 |
+
detail["name"] = candidate.rsplit("/", 1)[-1]
|
| 465 |
+
detail["encrypted"] = candidate.endswith(".enc")
|
| 466 |
+
return detail
|
| 467 |
+
|
| 468 |
+
def run_backup(self, *, trigger: str = "manual") -> dict[str, object]:
|
| 469 |
+
with self._lock:
|
| 470 |
+
current = self.get_status()
|
| 471 |
+
if self._running:
|
| 472 |
+
raise BackupError("当前已有备份任务正在执行")
|
| 473 |
+
started_at = _iso_now()
|
| 474 |
+
self._running = True
|
| 475 |
+
save_backup_state({
|
| 476 |
+
"last_started_at": started_at,
|
| 477 |
+
"last_finished_at": current.get("last_finished_at"),
|
| 478 |
+
"last_status": "idle",
|
| 479 |
+
"last_error": None,
|
| 480 |
+
"last_object_key": current.get("last_object_key"),
|
| 481 |
+
})
|
| 482 |
+
try:
|
| 483 |
+
result = self._run_backup_once(trigger=trigger)
|
| 484 |
+
save_backup_state({
|
| 485 |
+
"last_started_at": started_at,
|
| 486 |
+
"last_finished_at": _iso_now(),
|
| 487 |
+
"last_status": "success",
|
| 488 |
+
"last_error": None,
|
| 489 |
+
"last_object_key": result["key"],
|
| 490 |
+
})
|
| 491 |
+
return result
|
| 492 |
+
except Exception as exc:
|
| 493 |
+
save_backup_state({
|
| 494 |
+
"last_started_at": started_at,
|
| 495 |
+
"last_finished_at": _iso_now(),
|
| 496 |
+
"last_status": "error",
|
| 497 |
+
"last_error": str(exc) or exc.__class__.__name__,
|
| 498 |
+
"last_object_key": current.get("last_object_key"),
|
| 499 |
+
})
|
| 500 |
+
raise
|
| 501 |
+
finally:
|
| 502 |
+
self._running = False
|
| 503 |
+
|
| 504 |
+
def _run_backup_once(self, *, trigger: str) -> dict[str, object]:
|
| 505 |
+
settings = config.get_backup_settings()
|
| 506 |
+
client = CloudflareR2Client(settings)
|
| 507 |
+
client.validate()
|
| 508 |
+
payload_raw = self._build_backup_archive(settings, trigger=trigger)
|
| 509 |
+
encrypted = bool(settings.get("encrypt"))
|
| 510 |
+
if encrypted:
|
| 511 |
+
passphrase = _clean(settings.get("passphrase"))
|
| 512 |
+
if not passphrase:
|
| 513 |
+
raise BackupError("已启用备份加密,但未设置加密口令")
|
| 514 |
+
payload = _openssl_encrypt(payload_raw, passphrase)
|
| 515 |
+
suffix = ".tar.gz.enc"
|
| 516 |
+
else:
|
| 517 |
+
payload = payload_raw
|
| 518 |
+
suffix = ".tar.gz"
|
| 519 |
+
timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
|
| 520 |
+
random_tag = f"{random.randint(0, 0xFFFF):04x}"
|
| 521 |
+
object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}"
|
| 522 |
+
metadata = {
|
| 523 |
+
"created-at": _iso_now(),
|
| 524 |
+
"encrypted": "true" if encrypted else "false",
|
| 525 |
+
"trigger": trigger,
|
| 526 |
+
}
|
| 527 |
+
try:
|
| 528 |
+
result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata)
|
| 529 |
+
self._apply_rotation(client, int(settings.get("rotation_keep") or 0))
|
| 530 |
+
return {
|
| 531 |
+
"key": result["key"],
|
| 532 |
+
"size": len(payload),
|
| 533 |
+
"encrypted": encrypted,
|
| 534 |
+
}
|
| 535 |
+
finally:
|
| 536 |
+
client.close()
|
| 537 |
+
|
| 538 |
+
def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]:
|
| 539 |
+
decoded = payload
|
| 540 |
+
if key.endswith(".enc"):
|
| 541 |
+
passphrase = _clean(config.get_backup_settings().get("passphrase"))
|
| 542 |
+
if not passphrase:
|
| 543 |
+
raise BackupError("当前未配置加密口令,无法查看已加密备份")
|
| 544 |
+
decoded = _openssl_decrypt(decoded, passphrase)
|
| 545 |
+
return self._decode_archive_detail(decoded)
|
| 546 |
+
|
| 547 |
+
def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None:
|
| 548 |
+
if keep <= 0:
|
| 549 |
+
return
|
| 550 |
+
items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))]
|
| 551 |
+
if len(items) <= keep:
|
| 552 |
+
return
|
| 553 |
+
for item in items[keep:]:
|
| 554 |
+
key = _clean(item.get("key"))
|
| 555 |
+
if key:
|
| 556 |
+
client.delete_object(key)
|
| 557 |
+
|
| 558 |
+
def _decode_archive_detail(self, payload: bytes) -> dict[str, object]:
|
| 559 |
+
files: list[dict[str, object]] = []
|
| 560 |
+
snapshots: list[dict[str, object]] = []
|
| 561 |
+
metadata: dict[str, object] = {}
|
| 562 |
+
try:
|
| 563 |
+
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
|
| 564 |
+
members = [member for member in archive.getmembers() if member.isfile()]
|
| 565 |
+
for member in members:
|
| 566 |
+
extracted = archive.extractfile(member)
|
| 567 |
+
if extracted is None:
|
| 568 |
+
continue
|
| 569 |
+
raw = extracted.read()
|
| 570 |
+
name = member.name
|
| 571 |
+
if name == "backup-metadata.json":
|
| 572 |
+
try:
|
| 573 |
+
parsed = json.loads(raw.decode("utf-8"))
|
| 574 |
+
if isinstance(parsed, dict):
|
| 575 |
+
metadata = parsed
|
| 576 |
+
except Exception:
|
| 577 |
+
metadata = {}
|
| 578 |
+
continue
|
| 579 |
+
if name.startswith("snapshots/") and name.endswith(".json"):
|
| 580 |
+
count = 0
|
| 581 |
+
try:
|
| 582 |
+
parsed_snapshot = json.loads(raw.decode("utf-8"))
|
| 583 |
+
count = _count_items(parsed_snapshot)
|
| 584 |
+
except Exception:
|
| 585 |
+
count = 0
|
| 586 |
+
snapshots.append({
|
| 587 |
+
"name": name.removeprefix("snapshots/").removesuffix(".json"),
|
| 588 |
+
"count": count,
|
| 589 |
+
})
|
| 590 |
+
continue
|
| 591 |
+
files.append({
|
| 592 |
+
"name": name,
|
| 593 |
+
"exists": True,
|
| 594 |
+
"content_type": _guess_content_type(name),
|
| 595 |
+
"size": len(raw),
|
| 596 |
+
"sha256": _sha256_hex(raw),
|
| 597 |
+
})
|
| 598 |
+
except tarfile.TarError as exc:
|
| 599 |
+
raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc
|
| 600 |
+
files.sort(key=lambda item: str(item.get("name") or ""))
|
| 601 |
+
snapshots.sort(key=lambda item: str(item.get("name") or ""))
|
| 602 |
+
return {
|
| 603 |
+
"created_at": metadata.get("created_at"),
|
| 604 |
+
"trigger": metadata.get("trigger"),
|
| 605 |
+
"app_version": metadata.get("app_version"),
|
| 606 |
+
"storage_backend": metadata.get("storage_backend"),
|
| 607 |
+
"files": files,
|
| 608 |
+
"snapshots": snapshots,
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes:
|
| 612 |
+
include = settings.get("include") if isinstance(settings.get("include"), dict) else {}
|
| 613 |
+
metadata = {
|
| 614 |
+
"version": 2,
|
| 615 |
+
"created_at": _iso_now(),
|
| 616 |
+
"trigger": trigger,
|
| 617 |
+
"app_version": config.app_version,
|
| 618 |
+
"storage_backend": config.get_storage_backend().get_backend_info(),
|
| 619 |
+
}
|
| 620 |
+
buffer = io.BytesIO()
|
| 621 |
+
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
|
| 622 |
+
self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata))
|
| 623 |
+
if include.get("config"):
|
| 624 |
+
self._add_file_to_archive(archive, CONFIG_FILE, "config.json")
|
| 625 |
+
if include.get("register"):
|
| 626 |
+
self._add_file_to_archive(archive, DATA_DIR / "register.json", "data/register.json")
|
| 627 |
+
if include.get("cpa"):
|
| 628 |
+
self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json")
|
| 629 |
+
if include.get("sub2api"):
|
| 630 |
+
self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json")
|
| 631 |
+
if include.get("logs"):
|
| 632 |
+
self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl")
|
| 633 |
+
if include.get("image_tasks"):
|
| 634 |
+
self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json")
|
| 635 |
+
self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json")
|
| 636 |
+
if include.get("accounts_snapshot"):
|
| 637 |
+
self._add_bytes_to_archive(
|
| 638 |
+
archive,
|
| 639 |
+
"snapshots/accounts.json",
|
| 640 |
+
_json_bytes(config.get_storage_backend().load_accounts()),
|
| 641 |
+
)
|
| 642 |
+
if include.get("auth_keys_snapshot"):
|
| 643 |
+
self._add_bytes_to_archive(
|
| 644 |
+
archive,
|
| 645 |
+
"snapshots/auth_keys.json",
|
| 646 |
+
_json_bytes(config.get_storage_backend().load_auth_keys()),
|
| 647 |
+
)
|
| 648 |
+
if include.get("images"):
|
| 649 |
+
self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json")
|
| 650 |
+
self._add_directory_to_archive(archive, config.images_dir, "data/images")
|
| 651 |
+
return buffer.getvalue()
|
| 652 |
+
|
| 653 |
+
def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None:
|
| 654 |
+
info = tarfile.TarInfo(name=name)
|
| 655 |
+
info.size = len(payload)
|
| 656 |
+
info.mtime = int(_utc_now().timestamp())
|
| 657 |
+
archive.addfile(info, io.BytesIO(payload))
|
| 658 |
+
|
| 659 |
+
def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None:
|
| 660 |
+
if not source.exists() or not source.is_file():
|
| 661 |
+
return
|
| 662 |
+
archive.add(source, arcname=arcname)
|
| 663 |
+
|
| 664 |
+
def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None:
|
| 665 |
+
if not source_dir.exists() or not source_dir.is_dir():
|
| 666 |
+
return
|
| 667 |
+
for path in sorted(source_dir.rglob("*")):
|
| 668 |
+
if path.is_file():
|
| 669 |
+
relative = path.relative_to(source_dir).as_posix()
|
| 670 |
+
archive.add(path, arcname=f"{arcname_root}/{relative}")
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
backup_service = BackupService()
|
services/config.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import time
|
| 9 |
+
|
| 10 |
+
from services.storage.base import StorageBackend
|
| 11 |
+
|
| 12 |
+
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 13 |
+
DATA_DIR = BASE_DIR / "data"
|
| 14 |
+
CONFIG_FILE = BASE_DIR / "config.json"
|
| 15 |
+
VERSION_FILE = BASE_DIR / "VERSION"
|
| 16 |
+
BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
|
| 17 |
+
|
| 18 |
+
DEFAULT_BACKUP_INCLUDE = {
|
| 19 |
+
"config": True,
|
| 20 |
+
"register": True,
|
| 21 |
+
"cpa": True,
|
| 22 |
+
"sub2api": True,
|
| 23 |
+
"logs": True,
|
| 24 |
+
"image_tasks": True,
|
| 25 |
+
"accounts_snapshot": True,
|
| 26 |
+
"auth_keys_snapshot": True,
|
| 27 |
+
"images": False,
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
DEFAULT_IMAGE_STORAGE = {
|
| 31 |
+
"enabled": False,
|
| 32 |
+
"mode": "local",
|
| 33 |
+
"webdav_url": "",
|
| 34 |
+
"webdav_username": "",
|
| 35 |
+
"webdav_password": "",
|
| 36 |
+
"webdav_root_path": "chatgpt2api/images",
|
| 37 |
+
"public_base_url": "",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
DEFAULT_CHAT_COMPLETION_CACHE = {
|
| 41 |
+
"enabled": True,
|
| 42 |
+
"ttl_seconds": 60,
|
| 43 |
+
"max_entries": 256,
|
| 44 |
+
"dedupe_inflight": True,
|
| 45 |
+
"stream_cache": True,
|
| 46 |
+
"normalize_messages": True,
|
| 47 |
+
"drop_adjacent_duplicates": True,
|
| 48 |
+
"drop_assistant_history": False,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _normalize_bool(value: object, default: bool = False) -> bool:
|
| 53 |
+
if isinstance(value, str):
|
| 54 |
+
lowered = value.strip().lower()
|
| 55 |
+
if lowered in {"1", "true", "yes", "on"}:
|
| 56 |
+
return True
|
| 57 |
+
if lowered in {"0", "false", "no", "off"}:
|
| 58 |
+
return False
|
| 59 |
+
if value is None:
|
| 60 |
+
return default
|
| 61 |
+
return bool(value)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
|
| 65 |
+
try:
|
| 66 |
+
normalized = int(value)
|
| 67 |
+
except (TypeError, ValueError):
|
| 68 |
+
normalized = default
|
| 69 |
+
return max(minimum, normalized)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _normalize_backup_include(value: object) -> dict[str, bool]:
|
| 73 |
+
source = value if isinstance(value, dict) else {}
|
| 74 |
+
normalized = dict(DEFAULT_BACKUP_INCLUDE)
|
| 75 |
+
for key in normalized:
|
| 76 |
+
normalized[key] = _normalize_bool(source.get(key), normalized[key])
|
| 77 |
+
return normalized
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _normalize_backup_settings(value: object) -> dict[str, object]:
|
| 81 |
+
source = value if isinstance(value, dict) else {}
|
| 82 |
+
return {
|
| 83 |
+
"enabled": _normalize_bool(source.get("enabled"), False),
|
| 84 |
+
"provider": "cloudflare_r2",
|
| 85 |
+
"account_id": str(source.get("account_id") or "").strip(),
|
| 86 |
+
"access_key_id": str(source.get("access_key_id") or "").strip(),
|
| 87 |
+
"secret_access_key": str(source.get("secret_access_key") or "").strip(),
|
| 88 |
+
"bucket": str(source.get("bucket") or "").strip(),
|
| 89 |
+
"prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
|
| 90 |
+
"interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
|
| 91 |
+
"rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
|
| 92 |
+
"encrypt": _normalize_bool(source.get("encrypt"), False),
|
| 93 |
+
"passphrase": str(source.get("passphrase") or "").strip(),
|
| 94 |
+
"include": _normalize_backup_include(source.get("include")),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _normalize_backup_state(value: object) -> dict[str, object]:
|
| 99 |
+
source = value if isinstance(value, dict) else {}
|
| 100 |
+
return {
|
| 101 |
+
"last_started_at": str(source.get("last_started_at") or "").strip() or None,
|
| 102 |
+
"last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
|
| 103 |
+
"last_status": str(source.get("last_status") or "idle").strip() or "idle",
|
| 104 |
+
"last_error": str(source.get("last_error") or "").strip() or None,
|
| 105 |
+
"last_object_key": str(source.get("last_object_key") or "").strip() or None,
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _normalize_image_storage_settings(value: object) -> dict[str, object]:
|
| 110 |
+
source = value if isinstance(value, dict) else {}
|
| 111 |
+
mode = str(source.get("mode") or "local").strip().lower()
|
| 112 |
+
if mode not in {"local", "webdav", "both"}:
|
| 113 |
+
mode = "local"
|
| 114 |
+
enabled = _normalize_bool(source.get("enabled"), False)
|
| 115 |
+
if not enabled:
|
| 116 |
+
mode = "local"
|
| 117 |
+
root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
|
| 118 |
+
return {
|
| 119 |
+
"enabled": enabled,
|
| 120 |
+
"mode": mode,
|
| 121 |
+
"webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
|
| 122 |
+
"webdav_username": str(source.get("webdav_username") or "").strip(),
|
| 123 |
+
"webdav_password": str(source.get("webdav_password") or "").strip(),
|
| 124 |
+
"webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
|
| 125 |
+
"public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
|
| 130 |
+
source = value if isinstance(value, dict) else {}
|
| 131 |
+
return {
|
| 132 |
+
"enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
|
| 133 |
+
"ttl_seconds": _normalize_positive_int(
|
| 134 |
+
source.get("ttl_seconds"),
|
| 135 |
+
int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
|
| 136 |
+
0,
|
| 137 |
+
),
|
| 138 |
+
"max_entries": _normalize_positive_int(
|
| 139 |
+
source.get("max_entries"),
|
| 140 |
+
int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
|
| 141 |
+
1,
|
| 142 |
+
),
|
| 143 |
+
"dedupe_inflight": _normalize_bool(
|
| 144 |
+
source.get("dedupe_inflight"),
|
| 145 |
+
bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
|
| 146 |
+
),
|
| 147 |
+
"stream_cache": _normalize_bool(
|
| 148 |
+
source.get("stream_cache"),
|
| 149 |
+
bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
|
| 150 |
+
),
|
| 151 |
+
"normalize_messages": _normalize_bool(
|
| 152 |
+
source.get("normalize_messages"),
|
| 153 |
+
bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
|
| 154 |
+
),
|
| 155 |
+
"drop_adjacent_duplicates": _normalize_bool(
|
| 156 |
+
source.get("drop_adjacent_duplicates"),
|
| 157 |
+
bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
|
| 158 |
+
),
|
| 159 |
+
"drop_assistant_history": _normalize_bool(
|
| 160 |
+
source.get("drop_assistant_history"),
|
| 161 |
+
bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
|
| 162 |
+
),
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _validate_image_storage_settings(settings: dict[str, object]) -> None:
|
| 167 |
+
if not _normalize_bool(settings.get("enabled"), False):
|
| 168 |
+
return
|
| 169 |
+
if not str(settings.get("webdav_url") or "").strip():
|
| 170 |
+
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
|
| 171 |
+
if not str(settings.get("webdav_password") or "").strip():
|
| 172 |
+
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@dataclass(frozen=True)
|
| 176 |
+
class LoadedSettings:
|
| 177 |
+
auth_key: str
|
| 178 |
+
refresh_account_interval_minute: int
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _normalize_auth_key(value: object) -> str:
|
| 182 |
+
return str(value or "").strip()
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _is_invalid_auth_key(value: object) -> bool:
|
| 186 |
+
return _normalize_auth_key(value) == ""
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
|
| 190 |
+
if not path.exists():
|
| 191 |
+
return {}
|
| 192 |
+
if path.is_dir():
|
| 193 |
+
print(
|
| 194 |
+
f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
|
| 195 |
+
file=sys.stderr,
|
| 196 |
+
)
|
| 197 |
+
return {}
|
| 198 |
+
try:
|
| 199 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 200 |
+
except Exception:
|
| 201 |
+
return {}
|
| 202 |
+
return data if isinstance(data, dict) else {}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _load_settings() -> LoadedSettings:
|
| 206 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 207 |
+
raw_config = _read_json_object(CONFIG_FILE, name="config.json")
|
| 208 |
+
auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
|
| 209 |
+
if _is_invalid_auth_key(auth_key):
|
| 210 |
+
raise ValueError(
|
| 211 |
+
"❌ auth-key 未设置!\n"
|
| 212 |
+
"请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
|
| 217 |
+
except (TypeError, ValueError):
|
| 218 |
+
refresh_interval = 5
|
| 219 |
+
|
| 220 |
+
return LoadedSettings(
|
| 221 |
+
auth_key=auth_key,
|
| 222 |
+
refresh_account_interval_minute=refresh_interval,
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class ConfigStore:
|
| 227 |
+
def __init__(self, path: Path):
|
| 228 |
+
self.path = path
|
| 229 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 230 |
+
self.data = self._load()
|
| 231 |
+
self._storage_backend: StorageBackend | None = None
|
| 232 |
+
if _is_invalid_auth_key(self.auth_key):
|
| 233 |
+
raise ValueError(
|
| 234 |
+
"❌ auth-key 未设置!\n"
|
| 235 |
+
"请按以下任意一种方式解决:\n"
|
| 236 |
+
"1. 在 Render 的 Environment 变量中添加:\n"
|
| 237 |
+
" CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
|
| 238 |
+
"2. 或者在 config.json 中填写:\n"
|
| 239 |
+
' "auth-key": "your_real_auth_key"'
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
def _load(self) -> dict[str, object]:
|
| 243 |
+
return _read_json_object(self.path, name="config.json")
|
| 244 |
+
|
| 245 |
+
def _save(self) -> None:
|
| 246 |
+
self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 247 |
+
|
| 248 |
+
@property
|
| 249 |
+
def auth_key(self) -> str:
|
| 250 |
+
return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
|
| 251 |
+
|
| 252 |
+
@property
|
| 253 |
+
def accounts_file(self) -> Path:
|
| 254 |
+
return DATA_DIR / "accounts.json"
|
| 255 |
+
|
| 256 |
+
@property
|
| 257 |
+
def refresh_account_interval_minute(self) -> int:
|
| 258 |
+
try:
|
| 259 |
+
return int(self.data.get("refresh_account_interval_minute", 5))
|
| 260 |
+
except (TypeError, ValueError):
|
| 261 |
+
return 5
|
| 262 |
+
|
| 263 |
+
@property
|
| 264 |
+
def image_retention_days(self) -> int:
|
| 265 |
+
try:
|
| 266 |
+
return max(1, int(self.data.get("image_retention_days", 30)))
|
| 267 |
+
except (TypeError, ValueError):
|
| 268 |
+
return 30
|
| 269 |
+
|
| 270 |
+
@property
|
| 271 |
+
def image_poll_timeout_secs(self) -> int:
|
| 272 |
+
try:
|
| 273 |
+
return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
|
| 274 |
+
except (TypeError, ValueError):
|
| 275 |
+
return 120
|
| 276 |
+
|
| 277 |
+
@property
|
| 278 |
+
def image_poll_interval_secs(self) -> float:
|
| 279 |
+
try:
|
| 280 |
+
return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
|
| 281 |
+
except (TypeError, ValueError):
|
| 282 |
+
return 10.0
|
| 283 |
+
|
| 284 |
+
@property
|
| 285 |
+
def image_poll_initial_wait_secs(self) -> float:
|
| 286 |
+
"""Image generation upstream takes ~30s; polling immediately wastes requests
|
| 287 |
+
and trips a transient 429. Default 10s gives the conversation document time
|
| 288 |
+
to commit before the first poll."""
|
| 289 |
+
try:
|
| 290 |
+
return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
|
| 291 |
+
except (TypeError, ValueError):
|
| 292 |
+
return 10.0
|
| 293 |
+
|
| 294 |
+
@property
|
| 295 |
+
def image_account_concurrency(self) -> int:
|
| 296 |
+
try:
|
| 297 |
+
return max(1, int(self.data.get("image_account_concurrency", 3)))
|
| 298 |
+
except (TypeError, ValueError):
|
| 299 |
+
return 3
|
| 300 |
+
|
| 301 |
+
@property
|
| 302 |
+
def auto_remove_invalid_accounts(self) -> bool:
|
| 303 |
+
value = self.data.get("auto_remove_invalid_accounts", False)
|
| 304 |
+
if isinstance(value, str):
|
| 305 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 306 |
+
return bool(value)
|
| 307 |
+
|
| 308 |
+
@property
|
| 309 |
+
def auto_remove_rate_limited_accounts(self) -> bool:
|
| 310 |
+
value = self.data.get("auto_remove_rate_limited_accounts", False)
|
| 311 |
+
if isinstance(value, str):
|
| 312 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 313 |
+
return bool(value)
|
| 314 |
+
|
| 315 |
+
@property
|
| 316 |
+
def log_levels(self) -> list[str]:
|
| 317 |
+
levels = self.data.get("log_levels")
|
| 318 |
+
if not isinstance(levels, list):
|
| 319 |
+
return []
|
| 320 |
+
allowed = {"debug", "info", "warning", "error"}
|
| 321 |
+
return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
|
| 322 |
+
|
| 323 |
+
@property
|
| 324 |
+
def sensitive_words(self) -> list[str]:
|
| 325 |
+
words = self.data.get("sensitive_words")
|
| 326 |
+
return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
|
| 327 |
+
|
| 328 |
+
@property
|
| 329 |
+
def ai_review(self) -> dict[str, object]:
|
| 330 |
+
value = self.data.get("ai_review")
|
| 331 |
+
return value if isinstance(value, dict) else {}
|
| 332 |
+
|
| 333 |
+
@property
|
| 334 |
+
def global_system_prompt(self) -> str:
|
| 335 |
+
return str(self.data.get("global_system_prompt") or "").strip()
|
| 336 |
+
|
| 337 |
+
@property
|
| 338 |
+
def images_dir(self) -> Path:
|
| 339 |
+
path = DATA_DIR / "images"
|
| 340 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 341 |
+
return path
|
| 342 |
+
|
| 343 |
+
@property
|
| 344 |
+
def image_thumbnails_dir(self) -> Path:
|
| 345 |
+
path = DATA_DIR / "image_thumbnails"
|
| 346 |
+
path.mkdir(parents=True, exist_ok=True)
|
| 347 |
+
return path
|
| 348 |
+
|
| 349 |
+
def cleanup_old_images(self) -> int:
|
| 350 |
+
cutoff = time.time() - self.image_retention_days * 86400
|
| 351 |
+
removed = 0
|
| 352 |
+
for path in self.images_dir.rglob("*"):
|
| 353 |
+
if path.is_file() and path.stat().st_mtime < cutoff:
|
| 354 |
+
path.unlink()
|
| 355 |
+
removed += 1
|
| 356 |
+
for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
| 357 |
+
try:
|
| 358 |
+
path.rmdir()
|
| 359 |
+
except OSError:
|
| 360 |
+
pass
|
| 361 |
+
return removed
|
| 362 |
+
|
| 363 |
+
@property
|
| 364 |
+
def base_url(self) -> str:
|
| 365 |
+
return str(
|
| 366 |
+
os.getenv("CHATGPT2API_BASE_URL")
|
| 367 |
+
or self.data.get("base_url")
|
| 368 |
+
or ""
|
| 369 |
+
).strip().rstrip("/")
|
| 370 |
+
|
| 371 |
+
@property
|
| 372 |
+
def app_version(self) -> str:
|
| 373 |
+
try:
|
| 374 |
+
value = VERSION_FILE.read_text(encoding="utf-8").strip()
|
| 375 |
+
except FileNotFoundError:
|
| 376 |
+
return "0.0.0"
|
| 377 |
+
return value or "0.0.0"
|
| 378 |
+
|
| 379 |
+
def get(self) -> dict[str, object]:
|
| 380 |
+
data = dict(self.data)
|
| 381 |
+
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 382 |
+
data["image_retention_days"] = self.image_retention_days
|
| 383 |
+
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
|
| 384 |
+
data["image_poll_interval_secs"] = self.image_poll_interval_secs
|
| 385 |
+
data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
|
| 386 |
+
data["image_account_concurrency"] = self.image_account_concurrency
|
| 387 |
+
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 388 |
+
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
|
| 389 |
+
data["log_levels"] = self.log_levels
|
| 390 |
+
data["sensitive_words"] = self.sensitive_words
|
| 391 |
+
data["ai_review"] = self.ai_review
|
| 392 |
+
data["global_system_prompt"] = self.global_system_prompt
|
| 393 |
+
data["backup"] = self.get_backup_settings()
|
| 394 |
+
data["image_storage"] = self.get_image_storage_settings()
|
| 395 |
+
data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
|
| 396 |
+
data.pop("auth-key", None)
|
| 397 |
+
return data
|
| 398 |
+
|
| 399 |
+
def get_proxy_settings(self) -> str:
|
| 400 |
+
return str(self.data.get("proxy") or "").strip()
|
| 401 |
+
|
| 402 |
+
def update(self, data: dict[str, object]) -> dict[str, object]:
|
| 403 |
+
next_data = dict(self.data)
|
| 404 |
+
next_data.update(dict(data or {}))
|
| 405 |
+
if "backup" in next_data:
|
| 406 |
+
next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
|
| 407 |
+
if "image_storage" in next_data:
|
| 408 |
+
next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
|
| 409 |
+
_validate_image_storage_settings(next_data["image_storage"])
|
| 410 |
+
if "chat_completion_cache" in next_data:
|
| 411 |
+
next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
|
| 412 |
+
next_data.get("chat_completion_cache")
|
| 413 |
+
)
|
| 414 |
+
next_data.pop("backup_state", None)
|
| 415 |
+
self.data = next_data
|
| 416 |
+
self._save()
|
| 417 |
+
return self.get()
|
| 418 |
+
|
| 419 |
+
def get_backup_settings(self) -> dict[str, object]:
|
| 420 |
+
return _normalize_backup_settings(self.data.get("backup"))
|
| 421 |
+
|
| 422 |
+
def get_image_storage_settings(self) -> dict[str, object]:
|
| 423 |
+
return _normalize_image_storage_settings(self.data.get("image_storage"))
|
| 424 |
+
|
| 425 |
+
def get_chat_completion_cache_settings(self) -> dict[str, object]:
|
| 426 |
+
return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
|
| 427 |
+
|
| 428 |
+
def get_storage_backend(self) -> StorageBackend:
|
| 429 |
+
"""获取存储后端实例(单例)"""
|
| 430 |
+
if self._storage_backend is None:
|
| 431 |
+
from services.storage.factory import create_storage_backend
|
| 432 |
+
self._storage_backend = create_storage_backend(DATA_DIR)
|
| 433 |
+
return self._storage_backend
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def load_backup_state() -> dict[str, object]:
|
| 437 |
+
return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def save_backup_state(state: dict[str, object]) -> dict[str, object]:
|
| 441 |
+
normalized = _normalize_backup_state(state)
|
| 442 |
+
BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 443 |
+
return normalized
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
config = ConfigStore(CONFIG_FILE)
|
services/content_filter.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from curl_cffi import requests
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
|
| 8 |
+
from services.config import config
|
| 9 |
+
from services.proxy_service import proxy_settings
|
| 10 |
+
from utils.log import logger
|
| 11 |
+
|
| 12 |
+
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
|
| 13 |
+
|
| 14 |
+
# Strip base64 image data URIs before review: a text-only review model can't
|
| 15 |
+
# analyze image bytes, and a single inlined image easily blows past the token
|
| 16 |
+
# budget of the upstream review service.
|
| 17 |
+
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
|
| 18 |
+
|
| 19 |
+
# Cap aligned to the upstream review service's max context. If text still
|
| 20 |
+
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
|
| 21 |
+
# the system prompt and the most recent user message survive.
|
| 22 |
+
_MAX_REVIEW_TEXT_LEN = 100_000
|
| 23 |
+
_TRUNCATION_MARKER = "\n…[truncated]…\n"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _text(value: object) -> str:
|
| 27 |
+
if isinstance(value, str):
|
| 28 |
+
return value
|
| 29 |
+
if isinstance(value, list):
|
| 30 |
+
return "\n".join(_text(item) for item in value)
|
| 31 |
+
if isinstance(value, dict):
|
| 32 |
+
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
|
| 33 |
+
return ""
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def request_text(*values: object) -> str:
|
| 37 |
+
return "\n".join(part for value in values if (part := _text(value).strip()))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
|
| 41 |
+
"""Strip base64 data URIs and truncate to the review-service context limit.
|
| 42 |
+
|
| 43 |
+
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
|
| 44 |
+
and truncated_chars so callers can emit structured logs.
|
| 45 |
+
"""
|
| 46 |
+
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
|
| 47 |
+
truncated_chars = 0
|
| 48 |
+
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
|
| 49 |
+
# Reserve marker space so the result stays within the cap.
|
| 50 |
+
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
|
| 51 |
+
truncated_chars = len(sanitized) - 2 * half
|
| 52 |
+
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
|
| 53 |
+
stats = {
|
| 54 |
+
"base64_blocks_stripped": base64_blocks_stripped,
|
| 55 |
+
"truncated_chars": truncated_chars,
|
| 56 |
+
}
|
| 57 |
+
return sanitized, stats
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _extract_review_decision(data: object) -> str | None:
|
| 61 |
+
"""Defensively pull the decision text out of the review service response.
|
| 62 |
+
|
| 63 |
+
Returns None when the response shape doesn't match the OpenAI chat-completion
|
| 64 |
+
contract (e.g. {"error": ...} with no choices). The caller treats None as
|
| 65 |
+
"undecided" and applies the configured fail-open policy.
|
| 66 |
+
"""
|
| 67 |
+
if not isinstance(data, dict):
|
| 68 |
+
return None
|
| 69 |
+
choices = data.get("choices")
|
| 70 |
+
if not isinstance(choices, list) or not choices:
|
| 71 |
+
return None
|
| 72 |
+
first = choices[0]
|
| 73 |
+
if not isinstance(first, dict):
|
| 74 |
+
return None
|
| 75 |
+
message = first.get("message")
|
| 76 |
+
if not isinstance(message, dict):
|
| 77 |
+
return None
|
| 78 |
+
content = message.get("content")
|
| 79 |
+
if content is None:
|
| 80 |
+
return None
|
| 81 |
+
return str(content).strip().lower()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _is_allow_decision(decision: str) -> bool:
|
| 85 |
+
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _is_reject_decision(decision: str) -> bool:
|
| 89 |
+
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _resolve_fail_open(review: dict) -> bool:
|
| 93 |
+
"""Resolve fail_open from review config. Defaults to True."""
|
| 94 |
+
value = review.get("fail_open")
|
| 95 |
+
if value is None:
|
| 96 |
+
return True
|
| 97 |
+
if isinstance(value, bool):
|
| 98 |
+
return value
|
| 99 |
+
if isinstance(value, str):
|
| 100 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 101 |
+
return bool(value)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def check_request(text: str) -> None:
|
| 105 |
+
text = str(text or "")
|
| 106 |
+
if not text.strip():
|
| 107 |
+
return
|
| 108 |
+
# Local sensitive-word match runs on the raw text (cheap, no network).
|
| 109 |
+
for word in config.sensitive_words:
|
| 110 |
+
if word in text:
|
| 111 |
+
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
|
| 112 |
+
review = config.ai_review
|
| 113 |
+
if not review.get("enabled"):
|
| 114 |
+
return
|
| 115 |
+
base_url = str(review.get("base_url") or "").strip().rstrip("/")
|
| 116 |
+
api_key = str(review.get("api_key") or "").strip()
|
| 117 |
+
model = str(review.get("model") or "").strip()
|
| 118 |
+
if not base_url or not api_key or not model:
|
| 119 |
+
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
|
| 120 |
+
|
| 121 |
+
fail_open = _resolve_fail_open(review)
|
| 122 |
+
|
| 123 |
+
review_text, sanitize_stats = _sanitize_for_review(text)
|
| 124 |
+
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
|
| 125 |
+
logger.info({
|
| 126 |
+
"event": "ai_review_text_sanitized",
|
| 127 |
+
"original_text_len": len(text),
|
| 128 |
+
"review_text_len": len(review_text),
|
| 129 |
+
**sanitize_stats,
|
| 130 |
+
})
|
| 131 |
+
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
|
| 132 |
+
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
|
| 133 |
+
|
| 134 |
+
# fail_open=True (default): on upstream failure or ambiguous reply, let the
|
| 135 |
+
# request through. The review is a soft safety net; one missed review is
|
| 136 |
+
# preferable to a 5xx storm when the review service is flaky. Set
|
| 137 |
+
# config.ai_review.fail_open=false for strict-compliance deployments.
|
| 138 |
+
def _on_failure(event_payload: dict) -> None:
|
| 139 |
+
logger.warning(event_payload)
|
| 140 |
+
if not fail_open:
|
| 141 |
+
raise HTTPException(
|
| 142 |
+
status_code=503,
|
| 143 |
+
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
try:
|
| 147 |
+
response = requests.post(
|
| 148 |
+
f"{base_url}/v1/chat/completions",
|
| 149 |
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
| 150 |
+
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
|
| 151 |
+
timeout=60,
|
| 152 |
+
**proxy_settings.build_session_kwargs(),
|
| 153 |
+
)
|
| 154 |
+
except Exception as exc:
|
| 155 |
+
_on_failure({
|
| 156 |
+
"event": "ai_review_request_failed",
|
| 157 |
+
"error": str(exc),
|
| 158 |
+
"error_type": exc.__class__.__name__,
|
| 159 |
+
"review_text_len": len(review_text),
|
| 160 |
+
"original_text_len": len(text),
|
| 161 |
+
})
|
| 162 |
+
return
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
data = response.json()
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
_on_failure({
|
| 168 |
+
"event": "ai_review_response_not_json",
|
| 169 |
+
"status_code": response.status_code,
|
| 170 |
+
"body_preview": str(response.text or "")[:200],
|
| 171 |
+
"error": str(exc),
|
| 172 |
+
})
|
| 173 |
+
return
|
| 174 |
+
|
| 175 |
+
decision = _extract_review_decision(data)
|
| 176 |
+
if decision is None:
|
| 177 |
+
_on_failure({
|
| 178 |
+
"event": "ai_review_malformed_response",
|
| 179 |
+
"status_code": response.status_code,
|
| 180 |
+
"body_preview": str(data)[:300],
|
| 181 |
+
"review_text_len": len(review_text),
|
| 182 |
+
"original_text_len": len(text),
|
| 183 |
+
})
|
| 184 |
+
return
|
| 185 |
+
|
| 186 |
+
if _is_allow_decision(decision):
|
| 187 |
+
return
|
| 188 |
+
if _is_reject_decision(decision):
|
| 189 |
+
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
|
| 190 |
+
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
|
| 191 |
+
_on_failure({
|
| 192 |
+
"event": "ai_review_ambiguous_decision",
|
| 193 |
+
"decision": decision[:100],
|
| 194 |
+
"review_text_len": len(review_text),
|
| 195 |
+
})
|
| 196 |
+
return
|
services/cpa_service.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import threading
|
| 7 |
+
import uuid
|
| 8 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from threading import Lock
|
| 12 |
+
|
| 13 |
+
from curl_cffi.requests import Session
|
| 14 |
+
|
| 15 |
+
from services.account_service import account_service
|
| 16 |
+
from services.config import DATA_DIR
|
| 17 |
+
from services.proxy_service import proxy_settings
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _new_id() -> str:
|
| 24 |
+
return uuid.uuid4().hex[:12]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _now_iso() -> str:
|
| 28 |
+
return datetime.now(timezone.utc).isoformat()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
|
| 32 |
+
if not isinstance(raw, dict):
|
| 33 |
+
return None
|
| 34 |
+
status = str(raw.get("status") or "failed").strip() or "failed"
|
| 35 |
+
if fail_unfinished and status in {"pending", "running"}:
|
| 36 |
+
status = "failed"
|
| 37 |
+
return {
|
| 38 |
+
"job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
|
| 39 |
+
"status": status,
|
| 40 |
+
"created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 41 |
+
"updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
| 42 |
+
"total": int(raw.get("total") or 0),
|
| 43 |
+
"completed": int(raw.get("completed") or 0),
|
| 44 |
+
"added": int(raw.get("added") or 0),
|
| 45 |
+
"skipped": int(raw.get("skipped") or 0),
|
| 46 |
+
"refreshed": int(raw.get("refreshed") or 0),
|
| 47 |
+
"failed": int(raw.get("failed") or 0),
|
| 48 |
+
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _normalize_pool(raw: dict) -> dict:
|
| 53 |
+
return {
|
| 54 |
+
"id": str(raw.get("id") or _new_id()).strip(),
|
| 55 |
+
"name": str(raw.get("name") or "").strip(),
|
| 56 |
+
"base_url": str(raw.get("base_url") or "").strip(),
|
| 57 |
+
"secret_key": str(raw.get("secret_key") or "").strip(),
|
| 58 |
+
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _management_headers(secret_key: str) -> dict[str, str]:
|
| 63 |
+
return {
|
| 64 |
+
"Authorization": f"Bearer {secret_key}",
|
| 65 |
+
"Accept": "application/json",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class CPAConfig:
|
| 70 |
+
def __init__(self, store_file: Path):
|
| 71 |
+
self._store_file = store_file
|
| 72 |
+
self._lock = Lock()
|
| 73 |
+
self._pools: list[dict] = self._load()
|
| 74 |
+
|
| 75 |
+
def _load(self) -> list[dict]:
|
| 76 |
+
if not self._store_file.exists():
|
| 77 |
+
return []
|
| 78 |
+
try:
|
| 79 |
+
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
| 80 |
+
if isinstance(raw, dict) and "base_url" in raw:
|
| 81 |
+
pool = _normalize_pool(raw)
|
| 82 |
+
return [pool] if pool["base_url"] else []
|
| 83 |
+
if isinstance(raw, list):
|
| 84 |
+
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
|
| 85 |
+
except Exception:
|
| 86 |
+
pass
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
def _save(self) -> None:
|
| 90 |
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 92 |
+
|
| 93 |
+
def list_pools(self) -> list[dict]:
|
| 94 |
+
with self._lock:
|
| 95 |
+
return [dict(pool) for pool in self._pools]
|
| 96 |
+
|
| 97 |
+
def get_pool(self, pool_id: str) -> dict | None:
|
| 98 |
+
with self._lock:
|
| 99 |
+
for pool in self._pools:
|
| 100 |
+
if pool["id"] == pool_id:
|
| 101 |
+
return dict(pool)
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
|
| 105 |
+
pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
|
| 106 |
+
with self._lock:
|
| 107 |
+
self._pools.append(pool)
|
| 108 |
+
self._save()
|
| 109 |
+
return dict(pool)
|
| 110 |
+
|
| 111 |
+
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
|
| 112 |
+
with self._lock:
|
| 113 |
+
for index, pool in enumerate(self._pools):
|
| 114 |
+
if pool["id"] != pool_id:
|
| 115 |
+
continue
|
| 116 |
+
merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
|
| 117 |
+
self._pools[index] = _normalize_pool(merged)
|
| 118 |
+
self._save()
|
| 119 |
+
return dict(self._pools[index])
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
def delete_pool(self, pool_id: str) -> bool:
|
| 123 |
+
with self._lock:
|
| 124 |
+
before = len(self._pools)
|
| 125 |
+
self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
|
| 126 |
+
if len(self._pools) < before:
|
| 127 |
+
self._save()
|
| 128 |
+
return True
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
|
| 132 |
+
with self._lock:
|
| 133 |
+
for index, pool in enumerate(self._pools):
|
| 134 |
+
if pool["id"] != pool_id:
|
| 135 |
+
continue
|
| 136 |
+
next_pool = dict(pool)
|
| 137 |
+
next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
|
| 138 |
+
self._pools[index] = next_pool
|
| 139 |
+
self._save()
|
| 140 |
+
return dict(next_pool)
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
def get_import_job(self, pool_id: str) -> dict | None:
|
| 144 |
+
with self._lock:
|
| 145 |
+
for pool in self._pools:
|
| 146 |
+
if pool["id"] == pool_id:
|
| 147 |
+
job = pool.get("import_job")
|
| 148 |
+
return dict(job) if isinstance(job, dict) else None
|
| 149 |
+
return None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def list_remote_files(pool: dict) -> list[dict]:
|
| 153 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 154 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
| 155 |
+
if not base_url or not secret_key:
|
| 156 |
+
return []
|
| 157 |
+
|
| 158 |
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
|
| 159 |
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
| 160 |
+
try:
|
| 161 |
+
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
|
| 162 |
+
if not response.ok:
|
| 163 |
+
raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
|
| 164 |
+
payload = response.json()
|
| 165 |
+
finally:
|
| 166 |
+
session.close()
|
| 167 |
+
|
| 168 |
+
files = payload.get("files") if isinstance(payload, dict) else None
|
| 169 |
+
if not isinstance(files, list):
|
| 170 |
+
raise RuntimeError("remote list payload is invalid")
|
| 171 |
+
|
| 172 |
+
items: list[dict] = []
|
| 173 |
+
for item in files:
|
| 174 |
+
if not isinstance(item, dict):
|
| 175 |
+
continue
|
| 176 |
+
name = str(item.get("name") or "").strip()
|
| 177 |
+
email = str(item.get("email") or item.get("account") or "").strip()
|
| 178 |
+
if not name:
|
| 179 |
+
continue
|
| 180 |
+
items.append({"name": name, "email": email})
|
| 181 |
+
return items
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
|
| 185 |
+
base_url = str(pool.get("base_url") or "").strip()
|
| 186 |
+
secret_key = str(pool.get("secret_key") or "").strip()
|
| 187 |
+
file_name = str(file_name or "").strip()
|
| 188 |
+
if not base_url or not secret_key or not file_name:
|
| 189 |
+
return None, "invalid request"
|
| 190 |
+
|
| 191 |
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
|
| 192 |
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
| 193 |
+
try:
|
| 194 |
+
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
|
| 195 |
+
if not response.ok:
|
| 196 |
+
return None, f"HTTP {response.status_code}"
|
| 197 |
+
payload = response.json()
|
| 198 |
+
except Exception as exc:
|
| 199 |
+
return None, str(exc)
|
| 200 |
+
finally:
|
| 201 |
+
session.close()
|
| 202 |
+
|
| 203 |
+
if not isinstance(payload, dict):
|
| 204 |
+
return None, "invalid payload"
|
| 205 |
+
|
| 206 |
+
access_token = str(payload.get("access_token") or "").strip()
|
| 207 |
+
if not access_token:
|
| 208 |
+
return None, "missing access_token"
|
| 209 |
+
return access_token, None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class CPAImportService:
|
| 213 |
+
def __init__(self, cpa_config: CPAConfig):
|
| 214 |
+
self._config = cpa_config
|
| 215 |
+
|
| 216 |
+
def start_import(self, pool: dict, selected_files: list[str]) -> dict:
|
| 217 |
+
names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
|
| 218 |
+
if not names:
|
| 219 |
+
raise ValueError("selected files is required")
|
| 220 |
+
|
| 221 |
+
pool_id = str(pool.get("id") or "").strip()
|
| 222 |
+
job = {
|
| 223 |
+
"job_id": uuid.uuid4().hex,
|
| 224 |
+
"status": "pending",
|
| 225 |
+
"created_at": _now_iso(),
|
| 226 |
+
"updated_at": _now_iso(),
|
| 227 |
+
"total": len(names),
|
| 228 |
+
"completed": 0,
|
| 229 |
+
"added": 0,
|
| 230 |
+
"skipped": 0,
|
| 231 |
+
"refreshed": 0,
|
| 232 |
+
"failed": 0,
|
| 233 |
+
"errors": [],
|
| 234 |
+
}
|
| 235 |
+
saved_pool = self._config.set_import_job(pool_id, job)
|
| 236 |
+
if saved_pool is None:
|
| 237 |
+
raise ValueError("pool not found")
|
| 238 |
+
|
| 239 |
+
thread = threading.Thread(
|
| 240 |
+
target=self._run_import,
|
| 241 |
+
args=(pool_id, pool, names),
|
| 242 |
+
name=f"cpa-import-{pool_id}",
|
| 243 |
+
daemon=True,
|
| 244 |
+
)
|
| 245 |
+
thread.start()
|
| 246 |
+
return dict(saved_pool.get("import_job") or job)
|
| 247 |
+
|
| 248 |
+
def _update_job(self, pool_id: str, **updates) -> dict | None:
|
| 249 |
+
current = self._config.get_import_job(pool_id)
|
| 250 |
+
if current is None:
|
| 251 |
+
return None
|
| 252 |
+
next_job = {**current, **updates, "updated_at": _now_iso()}
|
| 253 |
+
pool = self._config.set_import_job(pool_id, next_job)
|
| 254 |
+
if pool is None:
|
| 255 |
+
return None
|
| 256 |
+
job = pool.get("import_job")
|
| 257 |
+
return dict(job) if isinstance(job, dict) else None
|
| 258 |
+
|
| 259 |
+
def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
|
| 260 |
+
current = self._config.get_import_job(pool_id)
|
| 261 |
+
if current is None:
|
| 262 |
+
return
|
| 263 |
+
errors = list(current.get("errors") or [])
|
| 264 |
+
errors.append({"name": file_name, "error": message})
|
| 265 |
+
self._update_job(pool_id, errors=errors, failed=len(errors))
|
| 266 |
+
|
| 267 |
+
def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
|
| 268 |
+
self._update_job(pool_id, status="running")
|
| 269 |
+
|
| 270 |
+
tokens: list[str] = []
|
| 271 |
+
max_workers = min(16, max(1, len(names)))
|
| 272 |
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 273 |
+
future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
|
| 274 |
+
for future in as_completed(future_map):
|
| 275 |
+
file_name = future_map[future]
|
| 276 |
+
try:
|
| 277 |
+
token, error = future.result()
|
| 278 |
+
except Exception as exc:
|
| 279 |
+
token, error = None, str(exc)
|
| 280 |
+
|
| 281 |
+
if token:
|
| 282 |
+
tokens.append(token)
|
| 283 |
+
else:
|
| 284 |
+
self._append_error(pool_id, file_name, error or "unknown error")
|
| 285 |
+
|
| 286 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 287 |
+
failed = len(current.get("errors") or [])
|
| 288 |
+
self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
|
| 289 |
+
|
| 290 |
+
if not tokens:
|
| 291 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 292 |
+
self._update_job(
|
| 293 |
+
pool_id,
|
| 294 |
+
status="failed",
|
| 295 |
+
completed=int(current.get("total") or 0),
|
| 296 |
+
failed=len(current.get("errors") or []),
|
| 297 |
+
)
|
| 298 |
+
return
|
| 299 |
+
|
| 300 |
+
add_result = account_service.add_accounts(tokens, source_type="codex")
|
| 301 |
+
refresh_result = account_service.refresh_accounts(tokens)
|
| 302 |
+
current = self._config.get_import_job(pool_id) or {}
|
| 303 |
+
self._update_job(
|
| 304 |
+
pool_id,
|
| 305 |
+
status="completed",
|
| 306 |
+
completed=len(names),
|
| 307 |
+
added=int(add_result.get("added") or 0),
|
| 308 |
+
skipped=int(add_result.get("skipped") or 0),
|
| 309 |
+
refreshed=int(refresh_result.get("refreshed") or 0),
|
| 310 |
+
failed=len(current.get("errors") or []),
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
cpa_config = CPAConfig(CPA_CONFIG_FILE)
|
| 315 |
+
cpa_import_service = CPAImportService(cpa_config)
|
services/image_service.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import shutil
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
import zipfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from fastapi import HTTPException
|
| 11 |
+
from fastapi.responses import FileResponse, Response
|
| 12 |
+
from PIL import Image, ImageOps
|
| 13 |
+
|
| 14 |
+
from services.config import config
|
| 15 |
+
from services.image_storage_service import image_storage_service
|
| 16 |
+
from services.image_tags_service import load_tags, remove_tags
|
| 17 |
+
from utils.log import logger
|
| 18 |
+
|
| 19 |
+
THUMBNAIL_SIZE = (320, 320)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _cleanup_empty_dirs(root: Path) -> None:
|
| 23 |
+
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
|
| 24 |
+
try:
|
| 25 |
+
path.rmdir()
|
| 26 |
+
except OSError:
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _safe_relative_path(path: str) -> str:
|
| 31 |
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
| 32 |
+
if not value:
|
| 33 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 34 |
+
parts = Path(value).parts
|
| 35 |
+
if any(part in {"", ".", ".."} for part in parts):
|
| 36 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 37 |
+
return Path(*parts).as_posix()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _safe_image_path(relative_path: str) -> Path:
|
| 41 |
+
rel = _safe_relative_path(relative_path)
|
| 42 |
+
root = config.images_dir.resolve()
|
| 43 |
+
path = (root / rel).resolve()
|
| 44 |
+
try:
|
| 45 |
+
path.relative_to(root)
|
| 46 |
+
except ValueError as exc:
|
| 47 |
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
| 48 |
+
if not path.is_file():
|
| 49 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 50 |
+
return path
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_image_response(relative_path: str) -> FileResponse | Response:
|
| 54 |
+
if image_storage_service.has_local(relative_path):
|
| 55 |
+
return FileResponse(_safe_image_path(relative_path))
|
| 56 |
+
return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _thumbnail_path(relative_path: str) -> Path:
|
| 60 |
+
rel = _safe_relative_path(relative_path)
|
| 61 |
+
return config.image_thumbnails_dir / f"{rel}.png"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def thumbnail_url(base_url: str, relative_path: str) -> str:
|
| 65 |
+
return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _image_dimensions(path: Path) -> tuple[int, int] | None:
|
| 69 |
+
try:
|
| 70 |
+
with Image.open(path) as image:
|
| 71 |
+
return image.size
|
| 72 |
+
except Exception:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def ensure_thumbnail(relative_path: str) -> Path:
|
| 77 |
+
target = _thumbnail_path(relative_path)
|
| 78 |
+
source_mtime = 0.0
|
| 79 |
+
source: Path | None = None
|
| 80 |
+
if image_storage_service.has_local(relative_path):
|
| 81 |
+
source = _safe_image_path(relative_path)
|
| 82 |
+
source_mtime = source.stat().st_mtime
|
| 83 |
+
if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
|
| 84 |
+
return target
|
| 85 |
+
|
| 86 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
try:
|
| 88 |
+
image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
|
| 89 |
+
with Image.open(image_source) as image:
|
| 90 |
+
image = ImageOps.exif_transpose(image)
|
| 91 |
+
if image.mode not in {"RGB", "RGBA"}:
|
| 92 |
+
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
| 93 |
+
image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
| 94 |
+
image.save(target, format="PNG", optimize=True)
|
| 95 |
+
except HTTPException:
|
| 96 |
+
raise
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
|
| 99 |
+
return target
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def get_thumbnail_response(relative_path: str) -> FileResponse:
|
| 103 |
+
return FileResponse(ensure_thumbnail(relative_path))
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_image_download_response(relative_path: str) -> FileResponse:
|
| 107 |
+
if image_storage_service.has_local(relative_path):
|
| 108 |
+
path = _safe_image_path(relative_path)
|
| 109 |
+
return FileResponse(path, filename=path.name)
|
| 110 |
+
rel = _safe_relative_path(relative_path)
|
| 111 |
+
return Response(
|
| 112 |
+
content=image_storage_service.get_bytes(rel),
|
| 113 |
+
media_type="image/png",
|
| 114 |
+
headers={"Content-Disposition": f'attachment; filename="{Path(rel).name}"'},
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def cleanup_image_thumbnails() -> int:
|
| 119 |
+
thumbnails_root = config.image_thumbnails_dir
|
| 120 |
+
removed = 0
|
| 121 |
+
for path in thumbnails_root.rglob("*"):
|
| 122 |
+
if not path.is_file():
|
| 123 |
+
continue
|
| 124 |
+
rel = path.relative_to(thumbnails_root).as_posix()
|
| 125 |
+
if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
|
| 126 |
+
path.unlink()
|
| 127 |
+
removed += 1
|
| 128 |
+
_cleanup_empty_dirs(thumbnails_root)
|
| 129 |
+
return removed
|
| 130 |
+
|
| 131 |
+
def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
|
| 132 |
+
config.cleanup_old_images()
|
| 133 |
+
cleanup_image_thumbnails()
|
| 134 |
+
all_tags = load_tags()
|
| 135 |
+
items = [
|
| 136 |
+
{
|
| 137 |
+
**item,
|
| 138 |
+
"url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
|
| 139 |
+
"thumbnail_url": thumbnail_url(base_url, str(item["path"])),
|
| 140 |
+
"tags": all_tags.get(str(item["path"]), []),
|
| 141 |
+
}
|
| 142 |
+
for item in image_storage_service.list_items(base_url, start_date, end_date)
|
| 143 |
+
]
|
| 144 |
+
groups: dict[str, list[dict[str, object]]] = {}
|
| 145 |
+
for item in items:
|
| 146 |
+
groups.setdefault(str(item["date"]), []).append(item)
|
| 147 |
+
return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
|
| 151 |
+
root = config.images_dir.resolve()
|
| 152 |
+
targets = [
|
| 153 |
+
str(item["path"])
|
| 154 |
+
for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
|
| 155 |
+
] if all_matching else (paths or [])
|
| 156 |
+
removed = 0
|
| 157 |
+
for item in targets:
|
| 158 |
+
path = (root / item).resolve()
|
| 159 |
+
try:
|
| 160 |
+
path.relative_to(root)
|
| 161 |
+
except ValueError:
|
| 162 |
+
continue
|
| 163 |
+
if image_storage_service.delete(item):
|
| 164 |
+
removed += 1
|
| 165 |
+
for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
|
| 166 |
+
if thumbnail.is_file():
|
| 167 |
+
thumbnail.unlink()
|
| 168 |
+
remove_tags(item)
|
| 169 |
+
_cleanup_empty_dirs(root)
|
| 170 |
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
| 171 |
+
return {"removed": removed}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
| 175 |
+
root = config.images_dir.resolve()
|
| 176 |
+
buf = io.BytesIO()
|
| 177 |
+
added = 0
|
| 178 |
+
used_names: set[str] = set()
|
| 179 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 180 |
+
for item in paths:
|
| 181 |
+
rel = _safe_relative_path(item)
|
| 182 |
+
path = (root / rel).resolve()
|
| 183 |
+
payload: bytes | None = None
|
| 184 |
+
try:
|
| 185 |
+
path.relative_to(root)
|
| 186 |
+
except ValueError:
|
| 187 |
+
continue
|
| 188 |
+
if path.is_file():
|
| 189 |
+
payload = path.read_bytes()
|
| 190 |
+
else:
|
| 191 |
+
try:
|
| 192 |
+
payload = image_storage_service.get_bytes(rel)
|
| 193 |
+
except Exception:
|
| 194 |
+
continue
|
| 195 |
+
name = path.name
|
| 196 |
+
if name in used_names:
|
| 197 |
+
stem = path.stem
|
| 198 |
+
suffix = path.suffix
|
| 199 |
+
counter = 2
|
| 200 |
+
while f"{stem}_{counter}{suffix}" in used_names:
|
| 201 |
+
counter += 1
|
| 202 |
+
name = f"{stem}_{counter}{suffix}"
|
| 203 |
+
used_names.add(name)
|
| 204 |
+
zf.writestr(name, payload)
|
| 205 |
+
added += 1
|
| 206 |
+
if added == 0:
|
| 207 |
+
raise HTTPException(status_code=404, detail="no images found")
|
| 208 |
+
buf.seek(0)
|
| 209 |
+
return buf
|
| 210 |
+
def storage_stats() -> dict:
|
| 211 |
+
import shutil
|
| 212 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 213 |
+
total_mb = usage.total // (1024 * 1024)
|
| 214 |
+
used_mb = usage.used // (1024 * 1024)
|
| 215 |
+
free_mb = usage.free // (1024 * 1024)
|
| 216 |
+
|
| 217 |
+
image_count = 0
|
| 218 |
+
image_size = 0
|
| 219 |
+
for p in config.images_dir.rglob("*"):
|
| 220 |
+
if p.is_file():
|
| 221 |
+
image_count += 1
|
| 222 |
+
image_size += p.stat().st_size
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
"disk_total_mb": total_mb,
|
| 226 |
+
"disk_used_mb": used_mb,
|
| 227 |
+
"disk_free_mb": free_mb,
|
| 228 |
+
"image_count": image_count,
|
| 229 |
+
"image_size_mb": image_size // (1024 * 1024),
|
| 230 |
+
"image_size_bytes": image_size,
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def compress_images(quality: int = 60) -> dict:
|
| 235 |
+
"""重新压缩所有图片,返回节省的空间"""
|
| 236 |
+
saved = 0
|
| 237 |
+
count = 0
|
| 238 |
+
for p in sorted(config.images_dir.rglob("*.png")):
|
| 239 |
+
if not p.is_file():
|
| 240 |
+
continue
|
| 241 |
+
try:
|
| 242 |
+
orig = p.stat().st_size
|
| 243 |
+
with Image.open(p) as img:
|
| 244 |
+
img = ImageOps.exif_transpose(img)
|
| 245 |
+
img.save(str(p) + ".tmp", format="PNG", optimize=True)
|
| 246 |
+
new_size = Path(str(p) + ".tmp").stat().st_size
|
| 247 |
+
if new_size < orig:
|
| 248 |
+
Path(str(p) + ".tmp").replace(p)
|
| 249 |
+
saved += orig - new_size
|
| 250 |
+
count += 1
|
| 251 |
+
else:
|
| 252 |
+
Path(str(p) + ".tmp").unlink()
|
| 253 |
+
except Exception:
|
| 254 |
+
pass
|
| 255 |
+
return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
|
| 259 |
+
"""删除最旧的图片直到剩余空间达到 target_free_mb"""
|
| 260 |
+
import shutil
|
| 261 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 262 |
+
current_free = usage.free // (1024 * 1024)
|
| 263 |
+
if current_free >= target_free_mb and not dry_run:
|
| 264 |
+
return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
|
| 265 |
+
|
| 266 |
+
files = sorted(
|
| 267 |
+
(p for p in config.images_dir.rglob("*.png") if p.is_file()),
|
| 268 |
+
key=lambda p: p.stat().st_mtime,
|
| 269 |
+
)
|
| 270 |
+
removed = 0
|
| 271 |
+
freed = 0
|
| 272 |
+
for p in files:
|
| 273 |
+
if current_free + freed // (1024 * 1024) >= target_free_mb:
|
| 274 |
+
break
|
| 275 |
+
size = p.stat().st_size
|
| 276 |
+
if not dry_run:
|
| 277 |
+
rel = p.relative_to(config.images_dir).as_posix()
|
| 278 |
+
for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
|
| 279 |
+
if tp.is_file():
|
| 280 |
+
tp.unlink()
|
| 281 |
+
remove_tags(rel)
|
| 282 |
+
p.unlink()
|
| 283 |
+
freed += size
|
| 284 |
+
removed += 1
|
| 285 |
+
|
| 286 |
+
if not dry_run:
|
| 287 |
+
_cleanup_empty_dirs(config.images_dir)
|
| 288 |
+
_cleanup_empty_dirs(config.image_thumbnails_dir)
|
| 289 |
+
|
| 290 |
+
return {
|
| 291 |
+
"removed": removed,
|
| 292 |
+
"freed_mb": freed // (1024 * 1024),
|
| 293 |
+
"target_free_mb": target_free_mb,
|
| 294 |
+
"current_free_mb": current_free + (freed // (1024 * 1024)),
|
| 295 |
+
"done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
|
| 296 |
+
"dry_run": dry_run,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def download_images_zip(paths: list[str]) -> io.BytesIO:
|
| 301 |
+
root = config.images_dir.resolve()
|
| 302 |
+
buf = io.BytesIO()
|
| 303 |
+
added = 0
|
| 304 |
+
used_names: set[str] = set()
|
| 305 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 306 |
+
for item in paths:
|
| 307 |
+
rel = _safe_relative_path(item)
|
| 308 |
+
path = (root / rel).resolve()
|
| 309 |
+
try:
|
| 310 |
+
path.relative_to(root)
|
| 311 |
+
except ValueError:
|
| 312 |
+
continue
|
| 313 |
+
if not path.is_file():
|
| 314 |
+
continue
|
| 315 |
+
name = path.name
|
| 316 |
+
if name in used_names:
|
| 317 |
+
stem = path.stem
|
| 318 |
+
suffix = path.suffix
|
| 319 |
+
counter = 2
|
| 320 |
+
while f"{stem}_{counter}{suffix}" in used_names:
|
| 321 |
+
counter += 1
|
| 322 |
+
name = f"{stem}_{counter}{suffix}"
|
| 323 |
+
used_names.add(name)
|
| 324 |
+
zf.write(path, name)
|
| 325 |
+
added += 1
|
| 326 |
+
if added == 0:
|
| 327 |
+
raise HTTPException(status_code=404, detail="no images found")
|
| 328 |
+
buf.seek(0)
|
| 329 |
+
return buf
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _auto_cleanup_worker(stop_event: threading.Event) -> None:
|
| 333 |
+
"""后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
|
| 334 |
+
import shutil
|
| 335 |
+
min_free_mb = getattr(config, "image_min_free_mb", None)
|
| 336 |
+
if min_free_mb is None:
|
| 337 |
+
min_free_mb = 500
|
| 338 |
+
|
| 339 |
+
while not stop_event.wait(1800): # 每30分钟
|
| 340 |
+
try:
|
| 341 |
+
config.cleanup_old_images()
|
| 342 |
+
cleanup_image_thumbnails()
|
| 343 |
+
usage = shutil.disk_usage(config.images_dir)
|
| 344 |
+
free_mb = usage.free // (1024 * 1024)
|
| 345 |
+
if free_mb < min_free_mb:
|
| 346 |
+
logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
|
| 347 |
+
result = delete_to_target(min_free_mb)
|
| 348 |
+
logger.info({"event": "image_auto_cleanup_done", **result})
|
| 349 |
+
except Exception:
|
| 350 |
+
pass
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
|
| 354 |
+
t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
|
| 355 |
+
t.start()
|
| 356 |
+
return t
|
services/image_storage_service.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import io
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from threading import Lock
|
| 11 |
+
from urllib.parse import quote, urlparse
|
| 12 |
+
|
| 13 |
+
from curl_cffi import requests
|
| 14 |
+
from fastapi import HTTPException
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
from services.config import DATA_DIR, config
|
| 18 |
+
|
| 19 |
+
IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
|
| 20 |
+
IMAGE_INDEX_LOCK = Lock()
|
| 21 |
+
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ImageStorageError(RuntimeError):
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class StoredImage:
|
| 30 |
+
rel: str
|
| 31 |
+
url: str
|
| 32 |
+
storage: str
|
| 33 |
+
size: int
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _clean(value: object) -> str:
|
| 37 |
+
return str(value or "").strip()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _now_iso() -> str:
|
| 41 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _safe_relative_path(path: str) -> str:
|
| 45 |
+
value = str(path or "").strip().replace("\\", "/").lstrip("/")
|
| 46 |
+
if not value:
|
| 47 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 48 |
+
parts = Path(value).parts
|
| 49 |
+
if any(part in {"", ".", ".."} for part in parts):
|
| 50 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 51 |
+
return Path(*parts).as_posix()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
|
| 55 |
+
try:
|
| 56 |
+
with Image.open(io.BytesIO(payload)) as image:
|
| 57 |
+
return image.size
|
| 58 |
+
except Exception:
|
| 59 |
+
return None
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _is_image_rel(path: str) -> bool:
|
| 63 |
+
try:
|
| 64 |
+
safe_rel = _safe_relative_path(path)
|
| 65 |
+
except HTTPException:
|
| 66 |
+
return False
|
| 67 |
+
return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _local_image_path(relative_path: str) -> Path:
|
| 71 |
+
rel = _safe_relative_path(relative_path)
|
| 72 |
+
root = config.images_dir.resolve()
|
| 73 |
+
path = (root / rel).resolve()
|
| 74 |
+
try:
|
| 75 |
+
path.relative_to(root)
|
| 76 |
+
except ValueError as exc:
|
| 77 |
+
raise HTTPException(status_code=404, detail="image not found") from exc
|
| 78 |
+
return path
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _read_json_object(path: Path) -> dict[str, object]:
|
| 82 |
+
if not path.exists():
|
| 83 |
+
return {}
|
| 84 |
+
try:
|
| 85 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 86 |
+
except Exception:
|
| 87 |
+
return {}
|
| 88 |
+
return data if isinstance(data, dict) else {}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _write_json_object(path: Path, data: dict[str, object]) -> None:
|
| 92 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 93 |
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
| 94 |
+
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 95 |
+
tmp_path.replace(path)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class WebDAVClient:
|
| 99 |
+
def __init__(self, settings: dict[str, object]):
|
| 100 |
+
self.url = _clean(settings.get("webdav_url")).rstrip("/")
|
| 101 |
+
self.username = _clean(settings.get("webdav_username"))
|
| 102 |
+
self.password = _clean(settings.get("webdav_password"))
|
| 103 |
+
self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
|
| 104 |
+
self.session = requests.Session()
|
| 105 |
+
|
| 106 |
+
def _auth_kwargs(self) -> dict[str, object]:
|
| 107 |
+
return {"auth": (self.username, self.password)} if self.username or self.password else {}
|
| 108 |
+
|
| 109 |
+
def _request(self, method: str, url: str, **kwargs):
|
| 110 |
+
response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
|
| 111 |
+
if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
|
| 112 |
+
raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
|
| 113 |
+
return response
|
| 114 |
+
|
| 115 |
+
def remote_url(self, rel: str = "") -> str:
|
| 116 |
+
parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
|
| 117 |
+
encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
|
| 118 |
+
return f"{self.url}/{encoded}" if encoded else self.url
|
| 119 |
+
|
| 120 |
+
def ensure_dirs(self, rel: str) -> None:
|
| 121 |
+
parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
|
| 122 |
+
current = self.url
|
| 123 |
+
for item in "/".join(parts).split("/"):
|
| 124 |
+
if not item:
|
| 125 |
+
continue
|
| 126 |
+
current = f"{current}/{quote(item, safe='')}"
|
| 127 |
+
response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
|
| 128 |
+
if response.status_code in {201, 405}:
|
| 129 |
+
continue
|
| 130 |
+
if response.status_code >= 400:
|
| 131 |
+
raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
|
| 132 |
+
|
| 133 |
+
def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
|
| 134 |
+
self.ensure_dirs(rel)
|
| 135 |
+
url = self.remote_url(rel)
|
| 136 |
+
self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
|
| 137 |
+
return url
|
| 138 |
+
|
| 139 |
+
def get(self, rel: str) -> bytes:
|
| 140 |
+
response = self._request("GET", self.remote_url(rel))
|
| 141 |
+
return bytes(response.content)
|
| 142 |
+
|
| 143 |
+
def delete(self, rel: str) -> bool:
|
| 144 |
+
response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
|
| 145 |
+
if response.status_code in {200, 202, 204, 404}:
|
| 146 |
+
return response.status_code != 404
|
| 147 |
+
raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
|
| 148 |
+
|
| 149 |
+
def test(self) -> dict[str, object]:
|
| 150 |
+
if not self.url:
|
| 151 |
+
return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
|
| 152 |
+
if urlparse(self.url).scheme not in {"http", "https"}:
|
| 153 |
+
return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
|
| 154 |
+
test_rel = ".chatgpt2api_webdav_test.txt"
|
| 155 |
+
try:
|
| 156 |
+
self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
|
| 157 |
+
self.delete(test_rel)
|
| 158 |
+
return {"ok": True, "status": 200, "error": None}
|
| 159 |
+
except ImageStorageError as exc:
|
| 160 |
+
return {"ok": False, "status": 0, "error": str(exc)}
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
|
| 163 |
+
finally:
|
| 164 |
+
self.session.close()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class ImageStorageService:
|
| 168 |
+
def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
|
| 169 |
+
self.index_file = index_file
|
| 170 |
+
self._index_lock = IMAGE_INDEX_LOCK
|
| 171 |
+
|
| 172 |
+
def settings(self) -> dict[str, object]:
|
| 173 |
+
return config.get_image_storage_settings()
|
| 174 |
+
|
| 175 |
+
def mode(self) -> str:
|
| 176 |
+
return _clean(self.settings().get("mode")) or "local"
|
| 177 |
+
|
| 178 |
+
def _load_index(self) -> dict[str, dict[str, object]]:
|
| 179 |
+
raw = _read_json_object(self.index_file)
|
| 180 |
+
items = raw.get("items")
|
| 181 |
+
if not isinstance(items, dict):
|
| 182 |
+
return {}
|
| 183 |
+
return {str(key): value for key, value in items.items() if isinstance(value, dict)}
|
| 184 |
+
|
| 185 |
+
def _load_clean_index(self) -> dict[str, dict[str, object]]:
|
| 186 |
+
items = self._load_index()
|
| 187 |
+
return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
|
| 188 |
+
|
| 189 |
+
def _save_index(self, items: dict[str, dict[str, object]]) -> None:
|
| 190 |
+
_write_json_object(self.index_file, {"items": items})
|
| 191 |
+
|
| 192 |
+
def _public_url(self, rel: str, base_url: str | None = None) -> str:
|
| 193 |
+
settings = self.settings()
|
| 194 |
+
public_base_url = _clean(settings.get("public_base_url"))
|
| 195 |
+
if public_base_url:
|
| 196 |
+
return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
|
| 197 |
+
return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
|
| 198 |
+
|
| 199 |
+
def make_relative_path(self, image_data: bytes) -> str:
|
| 200 |
+
file_hash = hashlib.md5(image_data).hexdigest()
|
| 201 |
+
filename = f"{int(time.time())}_{file_hash}.png"
|
| 202 |
+
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
| 203 |
+
return f"{relative_dir.as_posix()}/{filename}"
|
| 204 |
+
|
| 205 |
+
def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
|
| 206 |
+
config.cleanup_old_images()
|
| 207 |
+
rel = self.make_relative_path(image_data)
|
| 208 |
+
mode = self.mode()
|
| 209 |
+
if mode not in {"local", "webdav", "both"}:
|
| 210 |
+
mode = "local"
|
| 211 |
+
stored_local = False
|
| 212 |
+
stored_webdav = False
|
| 213 |
+
remote_url = ""
|
| 214 |
+
|
| 215 |
+
if mode in {"local", "both"}:
|
| 216 |
+
path = _local_image_path(rel)
|
| 217 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
path.write_bytes(image_data)
|
| 219 |
+
stored_local = True
|
| 220 |
+
|
| 221 |
+
if mode in {"webdav", "both"}:
|
| 222 |
+
remote_url = WebDAVClient(self.settings()).put(rel, image_data)
|
| 223 |
+
stored_webdav = True
|
| 224 |
+
|
| 225 |
+
dimensions = _image_dimensions(image_data)
|
| 226 |
+
item = {
|
| 227 |
+
"rel": rel,
|
| 228 |
+
"path": rel,
|
| 229 |
+
"name": Path(rel).name,
|
| 230 |
+
"date": "-".join(rel.split("/")[:3]),
|
| 231 |
+
"size": len(image_data),
|
| 232 |
+
"created_at": _now_iso(),
|
| 233 |
+
"storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
|
| 234 |
+
"local": stored_local,
|
| 235 |
+
"webdav": stored_webdav,
|
| 236 |
+
"remote_url": remote_url,
|
| 237 |
+
}
|
| 238 |
+
if dimensions:
|
| 239 |
+
item["width"], item["height"] = dimensions
|
| 240 |
+
with self._index_lock:
|
| 241 |
+
items = self._load_clean_index()
|
| 242 |
+
items[rel] = item
|
| 243 |
+
self._save_index(items)
|
| 244 |
+
return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
|
| 245 |
+
|
| 246 |
+
def get_bytes(self, rel: str) -> bytes:
|
| 247 |
+
safe_rel = _safe_relative_path(rel)
|
| 248 |
+
if not _is_image_rel(safe_rel):
|
| 249 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 250 |
+
path = _local_image_path(safe_rel)
|
| 251 |
+
if path.is_file():
|
| 252 |
+
return path.read_bytes()
|
| 253 |
+
item = self._load_clean_index().get(safe_rel, {})
|
| 254 |
+
if item.get("webdav"):
|
| 255 |
+
return WebDAVClient(self.settings()).get(safe_rel)
|
| 256 |
+
raise HTTPException(status_code=404, detail="image not found")
|
| 257 |
+
|
| 258 |
+
def exists(self, rel: str) -> bool:
|
| 259 |
+
safe_rel = _safe_relative_path(rel)
|
| 260 |
+
if not _is_image_rel(safe_rel):
|
| 261 |
+
return False
|
| 262 |
+
if _local_image_path(safe_rel).is_file():
|
| 263 |
+
return True
|
| 264 |
+
item = self._load_clean_index().get(safe_rel, {})
|
| 265 |
+
return bool(item.get("webdav"))
|
| 266 |
+
|
| 267 |
+
def has_local(self, rel: str) -> bool:
|
| 268 |
+
safe_rel = _safe_relative_path(rel)
|
| 269 |
+
return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
|
| 270 |
+
|
| 271 |
+
def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
|
| 272 |
+
with self._index_lock:
|
| 273 |
+
indexed = self._load_clean_index()
|
| 274 |
+
root = config.images_dir
|
| 275 |
+
changed = False
|
| 276 |
+
for path in root.rglob("*"):
|
| 277 |
+
if not path.is_file() or not _is_image_rel(path.name):
|
| 278 |
+
continue
|
| 279 |
+
rel = path.relative_to(root).as_posix()
|
| 280 |
+
if rel in indexed:
|
| 281 |
+
continue
|
| 282 |
+
dimensions = None
|
| 283 |
+
try:
|
| 284 |
+
dimensions = _image_dimensions(path.read_bytes())
|
| 285 |
+
except Exception:
|
| 286 |
+
dimensions = None
|
| 287 |
+
indexed[rel] = {
|
| 288 |
+
"rel": rel,
|
| 289 |
+
"path": rel,
|
| 290 |
+
"name": path.name,
|
| 291 |
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
| 292 |
+
"size": path.stat().st_size,
|
| 293 |
+
"created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
| 294 |
+
"storage": "local",
|
| 295 |
+
"local": True,
|
| 296 |
+
"webdav": False,
|
| 297 |
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
| 298 |
+
}
|
| 299 |
+
changed = True
|
| 300 |
+
|
| 301 |
+
items: list[dict[str, object]] = []
|
| 302 |
+
for rel, item in list(indexed.items()):
|
| 303 |
+
if not _is_image_rel(rel):
|
| 304 |
+
indexed.pop(rel, None)
|
| 305 |
+
changed = True
|
| 306 |
+
continue
|
| 307 |
+
local = _local_image_path(rel).is_file()
|
| 308 |
+
webdav = bool(item.get("webdav"))
|
| 309 |
+
if not local and not webdav:
|
| 310 |
+
indexed.pop(rel, None)
|
| 311 |
+
changed = True
|
| 312 |
+
continue
|
| 313 |
+
storage = "both" if local and webdav else ("webdav" if webdav else "local")
|
| 314 |
+
if item.get("local") != local or item.get("storage") != storage:
|
| 315 |
+
item = {
|
| 316 |
+
**item,
|
| 317 |
+
"local": local,
|
| 318 |
+
"storage": storage,
|
| 319 |
+
}
|
| 320 |
+
indexed[rel] = item
|
| 321 |
+
changed = True
|
| 322 |
+
day = str(item.get("date") or "")
|
| 323 |
+
if start_date and day < start_date:
|
| 324 |
+
continue
|
| 325 |
+
if end_date and day > end_date:
|
| 326 |
+
continue
|
| 327 |
+
items.append({
|
| 328 |
+
**item,
|
| 329 |
+
"rel": rel,
|
| 330 |
+
"path": rel,
|
| 331 |
+
"url": self._public_url(rel, base_url),
|
| 332 |
+
})
|
| 333 |
+
if changed:
|
| 334 |
+
self._save_index(indexed)
|
| 335 |
+
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
|
| 336 |
+
return items
|
| 337 |
+
|
| 338 |
+
def delete(self, rel: str) -> bool:
|
| 339 |
+
safe_rel = _safe_relative_path(rel)
|
| 340 |
+
removed = False
|
| 341 |
+
path = _local_image_path(safe_rel)
|
| 342 |
+
if path.is_file():
|
| 343 |
+
path.unlink()
|
| 344 |
+
removed = True
|
| 345 |
+
with self._index_lock:
|
| 346 |
+
items = self._load_clean_index()
|
| 347 |
+
item = items.get(safe_rel, {})
|
| 348 |
+
if item.get("webdav"):
|
| 349 |
+
try:
|
| 350 |
+
removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
|
| 351 |
+
except ImageStorageError:
|
| 352 |
+
if not removed:
|
| 353 |
+
raise
|
| 354 |
+
if safe_rel in items:
|
| 355 |
+
items.pop(safe_rel, None)
|
| 356 |
+
self._save_index(items)
|
| 357 |
+
return removed
|
| 358 |
+
|
| 359 |
+
def sync_all(self) -> dict[str, int]:
|
| 360 |
+
settings = self.settings()
|
| 361 |
+
if self.mode() not in {"webdav", "both"}:
|
| 362 |
+
raise ImageStorageError("WebDAV 图片存储未启用")
|
| 363 |
+
uploaded = 0
|
| 364 |
+
skipped = 0
|
| 365 |
+
failed = 0
|
| 366 |
+
with self._index_lock:
|
| 367 |
+
items = self._load_clean_index()
|
| 368 |
+
client = WebDAVClient(settings)
|
| 369 |
+
for path in sorted(config.images_dir.rglob("*")):
|
| 370 |
+
if not path.is_file() or not _is_image_rel(path.name):
|
| 371 |
+
continue
|
| 372 |
+
rel = path.relative_to(config.images_dir).as_posix()
|
| 373 |
+
item = items.get(rel, {})
|
| 374 |
+
if item.get("webdav"):
|
| 375 |
+
skipped += 1
|
| 376 |
+
continue
|
| 377 |
+
try:
|
| 378 |
+
payload = path.read_bytes()
|
| 379 |
+
remote_url = client.put(rel, payload)
|
| 380 |
+
dimensions = _image_dimensions(payload)
|
| 381 |
+
items[rel] = {
|
| 382 |
+
**item,
|
| 383 |
+
"rel": rel,
|
| 384 |
+
"path": rel,
|
| 385 |
+
"name": path.name,
|
| 386 |
+
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
|
| 387 |
+
"size": len(payload),
|
| 388 |
+
"created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
|
| 389 |
+
"storage": "both",
|
| 390 |
+
"local": True,
|
| 391 |
+
"webdav": True,
|
| 392 |
+
"remote_url": remote_url,
|
| 393 |
+
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
|
| 394 |
+
}
|
| 395 |
+
uploaded += 1
|
| 396 |
+
except Exception:
|
| 397 |
+
failed += 1
|
| 398 |
+
self._save_index(items)
|
| 399 |
+
return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
|
| 400 |
+
|
| 401 |
+
def test_webdav(self) -> dict[str, object]:
|
| 402 |
+
return WebDAVClient(self.settings()).test()
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
image_storage_service = ImageStorageService()
|
services/image_tags_service.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from services.config import DATA_DIR
|
| 7 |
+
|
| 8 |
+
TAGS_FILE = DATA_DIR / "image_tags.json"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _ensure_file() -> None:
|
| 12 |
+
TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
| 13 |
+
if not TAGS_FILE.exists():
|
| 14 |
+
TAGS_FILE.write_text("{}", encoding="utf-8")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_tags() -> dict[str, list[str]]:
|
| 18 |
+
_ensure_file()
|
| 19 |
+
try:
|
| 20 |
+
data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
|
| 21 |
+
except Exception:
|
| 22 |
+
return {}
|
| 23 |
+
return data if isinstance(data, dict) else {}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def save_tags(data: dict[str, list[str]]) -> None:
|
| 27 |
+
_ensure_file()
|
| 28 |
+
TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_tags(image_rel: str) -> list[str]:
|
| 32 |
+
return load_tags().get(image_rel, [])
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def set_tags(image_rel: str, tags: list[str]) -> list[str]:
|
| 36 |
+
data = load_tags()
|
| 37 |
+
cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
|
| 38 |
+
if cleaned:
|
| 39 |
+
data[image_rel] = cleaned
|
| 40 |
+
else:
|
| 41 |
+
data.pop(image_rel, None)
|
| 42 |
+
save_tags(data)
|
| 43 |
+
return cleaned
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def remove_tags(image_rel: str) -> None:
|
| 47 |
+
data = load_tags()
|
| 48 |
+
if data.pop(image_rel, None) is not None:
|
| 49 |
+
save_tags(data)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def delete_tag(tag: str) -> int:
|
| 53 |
+
"""从所有图片中删除指定标签,返回受影响的图片数。"""
|
| 54 |
+
data = load_tags()
|
| 55 |
+
count = 0
|
| 56 |
+
for rel in list(data):
|
| 57 |
+
if tag in data[rel]:
|
| 58 |
+
data[rel] = [t for t in data[rel] if t != tag]
|
| 59 |
+
if not data[rel]:
|
| 60 |
+
del data[rel]
|
| 61 |
+
count += 1
|
| 62 |
+
if count > 0:
|
| 63 |
+
save_tags(data)
|
| 64 |
+
return count
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def get_all_tags() -> list[str]:
|
| 68 |
+
data = load_tags()
|
| 69 |
+
seen: set[str] = set()
|
| 70 |
+
result: list[str] = []
|
| 71 |
+
for tags in data.values():
|
| 72 |
+
for t in tags:
|
| 73 |
+
if t not in seen:
|
| 74 |
+
seen.add(t)
|
| 75 |
+
result.append(t)
|
| 76 |
+
return result
|
services/image_task_service.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from services.config import DATA_DIR, config
|
| 12 |
+
from services.content_filter import request_text
|
| 13 |
+
from services.log_service import LOG_TYPE_CALL, log_service
|
| 14 |
+
from services.protocol import openai_v1_image_edit, openai_v1_image_generations
|
| 15 |
+
|
| 16 |
+
TASK_STATUS_QUEUED = "queued"
|
| 17 |
+
TASK_STATUS_RUNNING = "running"
|
| 18 |
+
TASK_STATUS_SUCCESS = "success"
|
| 19 |
+
TASK_STATUS_ERROR = "error"
|
| 20 |
+
TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
|
| 21 |
+
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _now_iso() -> str:
|
| 25 |
+
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _timestamp(value: object) -> float:
|
| 29 |
+
if not isinstance(value, str) or not value.strip():
|
| 30 |
+
return 0.0
|
| 31 |
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
|
| 32 |
+
try:
|
| 33 |
+
return datetime.strptime(value[:26], fmt).timestamp()
|
| 34 |
+
except ValueError:
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
| 38 |
+
except Exception:
|
| 39 |
+
return 0.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _clean(value: object, default: str = "") -> str:
|
| 43 |
+
return str(value or default).strip()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _owner_id(identity: dict[str, object]) -> str:
|
| 47 |
+
return _clean(identity.get("id")) or "anonymous"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _task_key(owner_id: str, task_id: str) -> str:
|
| 51 |
+
return f"{owner_id}:{task_id}"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _collect_image_urls(data: list[Any]) -> list[str]:
|
| 55 |
+
urls: list[str] = []
|
| 56 |
+
for item in data:
|
| 57 |
+
if isinstance(item, dict):
|
| 58 |
+
url = item.get("url")
|
| 59 |
+
if isinstance(url, str) and url:
|
| 60 |
+
urls.append(url)
|
| 61 |
+
return urls
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
| 65 |
+
item = {
|
| 66 |
+
"id": task.get("id"),
|
| 67 |
+
"status": task.get("status"),
|
| 68 |
+
"mode": task.get("mode"),
|
| 69 |
+
"model": task.get("model"),
|
| 70 |
+
"size": task.get("size"),
|
| 71 |
+
"quality": task.get("quality"),
|
| 72 |
+
"created_at": task.get("created_at"),
|
| 73 |
+
"updated_at": task.get("updated_at"),
|
| 74 |
+
}
|
| 75 |
+
if task.get("data") is not None:
|
| 76 |
+
item["data"] = task.get("data")
|
| 77 |
+
if task.get("usage") is not None:
|
| 78 |
+
item["usage"] = task.get("usage")
|
| 79 |
+
if task.get("error"):
|
| 80 |
+
item["error"] = task.get("error")
|
| 81 |
+
return item
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class ImageTaskService:
|
| 85 |
+
def __init__(
|
| 86 |
+
self,
|
| 87 |
+
path: Path,
|
| 88 |
+
*,
|
| 89 |
+
generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
|
| 90 |
+
edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
|
| 91 |
+
retention_days_getter: Callable[[], int] | None = None,
|
| 92 |
+
):
|
| 93 |
+
self.path = path
|
| 94 |
+
self.generation_handler = generation_handler
|
| 95 |
+
self.edit_handler = edit_handler
|
| 96 |
+
self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
|
| 97 |
+
self._lock = threading.RLock()
|
| 98 |
+
self._tasks: dict[str, dict[str, Any]] = {}
|
| 99 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 100 |
+
with self._lock:
|
| 101 |
+
self._tasks = self._load_locked()
|
| 102 |
+
changed = self._recover_unfinished_locked()
|
| 103 |
+
changed = self._cleanup_locked() or changed
|
| 104 |
+
if changed:
|
| 105 |
+
self._save_locked()
|
| 106 |
+
|
| 107 |
+
def submit_generation(
|
| 108 |
+
self,
|
| 109 |
+
identity: dict[str, object],
|
| 110 |
+
*,
|
| 111 |
+
client_task_id: str,
|
| 112 |
+
prompt: str,
|
| 113 |
+
model: str,
|
| 114 |
+
size: str | None,
|
| 115 |
+
quality: str = "auto",
|
| 116 |
+
base_url: str = "",
|
| 117 |
+
) -> dict[str, Any]:
|
| 118 |
+
payload = {
|
| 119 |
+
"prompt": prompt,
|
| 120 |
+
"model": model,
|
| 121 |
+
"n": 1,
|
| 122 |
+
"size": size,
|
| 123 |
+
"quality": quality,
|
| 124 |
+
"response_format": "url",
|
| 125 |
+
"base_url": base_url,
|
| 126 |
+
}
|
| 127 |
+
return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
|
| 128 |
+
|
| 129 |
+
def submit_edit(
|
| 130 |
+
self,
|
| 131 |
+
identity: dict[str, object],
|
| 132 |
+
*,
|
| 133 |
+
client_task_id: str,
|
| 134 |
+
prompt: str,
|
| 135 |
+
model: str,
|
| 136 |
+
size: str | None,
|
| 137 |
+
quality: str = "auto",
|
| 138 |
+
base_url: str = "",
|
| 139 |
+
images: list[tuple[bytes, str, str]] | None = None,
|
| 140 |
+
) -> dict[str, Any]:
|
| 141 |
+
payload = {
|
| 142 |
+
"prompt": prompt,
|
| 143 |
+
"images": images or [],
|
| 144 |
+
"model": model,
|
| 145 |
+
"n": 1,
|
| 146 |
+
"size": size,
|
| 147 |
+
"quality": quality,
|
| 148 |
+
"response_format": "url",
|
| 149 |
+
"base_url": base_url,
|
| 150 |
+
}
|
| 151 |
+
return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
|
| 152 |
+
|
| 153 |
+
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
|
| 154 |
+
owner = _owner_id(identity)
|
| 155 |
+
requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
|
| 156 |
+
with self._lock:
|
| 157 |
+
if self._cleanup_locked():
|
| 158 |
+
self._save_locked()
|
| 159 |
+
items = []
|
| 160 |
+
missing_ids = []
|
| 161 |
+
for task_id in requested_ids:
|
| 162 |
+
task = self._tasks.get(_task_key(owner, task_id))
|
| 163 |
+
if task is None:
|
| 164 |
+
missing_ids.append(task_id)
|
| 165 |
+
else:
|
| 166 |
+
items.append(_public_task(task))
|
| 167 |
+
if not requested_ids:
|
| 168 |
+
items = [
|
| 169 |
+
_public_task(task)
|
| 170 |
+
for task in self._tasks.values()
|
| 171 |
+
if task.get("owner_id") == owner
|
| 172 |
+
]
|
| 173 |
+
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 174 |
+
missing_ids = []
|
| 175 |
+
return {"items": items, "missing_ids": missing_ids}
|
| 176 |
+
|
| 177 |
+
def _submit(
|
| 178 |
+
self,
|
| 179 |
+
identity: dict[str, object],
|
| 180 |
+
*,
|
| 181 |
+
client_task_id: str,
|
| 182 |
+
mode: str,
|
| 183 |
+
payload: dict[str, Any],
|
| 184 |
+
) -> dict[str, Any]:
|
| 185 |
+
task_id = _clean(client_task_id)
|
| 186 |
+
if not task_id:
|
| 187 |
+
raise ValueError("client_task_id is required")
|
| 188 |
+
owner = _owner_id(identity)
|
| 189 |
+
key = _task_key(owner, task_id)
|
| 190 |
+
now = _now_iso()
|
| 191 |
+
should_start = False
|
| 192 |
+
with self._lock:
|
| 193 |
+
cleaned = self._cleanup_locked()
|
| 194 |
+
task = self._tasks.get(key)
|
| 195 |
+
if task is not None:
|
| 196 |
+
if cleaned:
|
| 197 |
+
self._save_locked()
|
| 198 |
+
return _public_task(task)
|
| 199 |
+
task = {
|
| 200 |
+
"id": task_id,
|
| 201 |
+
"owner_id": owner,
|
| 202 |
+
"status": TASK_STATUS_QUEUED,
|
| 203 |
+
"mode": mode,
|
| 204 |
+
"model": _clean(payload.get("model"), "gpt-image-2"),
|
| 205 |
+
"size": _clean(payload.get("size")),
|
| 206 |
+
"quality": _clean(payload.get("quality"), "auto"),
|
| 207 |
+
"created_at": now,
|
| 208 |
+
"updated_at": now,
|
| 209 |
+
}
|
| 210 |
+
self._tasks[key] = task
|
| 211 |
+
self._save_locked()
|
| 212 |
+
should_start = True
|
| 213 |
+
|
| 214 |
+
if should_start:
|
| 215 |
+
thread = threading.Thread(
|
| 216 |
+
target=self._run_task,
|
| 217 |
+
args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
|
| 218 |
+
name=f"image-task-{task_id[:16]}",
|
| 219 |
+
daemon=True,
|
| 220 |
+
)
|
| 221 |
+
thread.start()
|
| 222 |
+
return _public_task(task)
|
| 223 |
+
|
| 224 |
+
def _run_task(
|
| 225 |
+
self,
|
| 226 |
+
key: str,
|
| 227 |
+
mode: str,
|
| 228 |
+
payload: dict[str, Any],
|
| 229 |
+
identity: dict[str, object],
|
| 230 |
+
model: str,
|
| 231 |
+
) -> None:
|
| 232 |
+
started = time.time()
|
| 233 |
+
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
|
| 234 |
+
try:
|
| 235 |
+
handler = self.edit_handler if mode == "edit" else self.generation_handler
|
| 236 |
+
result = handler(payload)
|
| 237 |
+
if not isinstance(result, dict):
|
| 238 |
+
raise RuntimeError("image task returned streaming result unexpectedly")
|
| 239 |
+
data = result.get("data")
|
| 240 |
+
account_email = _clean(result.get("_account_email") or result.get("account_email"))
|
| 241 |
+
if not isinstance(data, list) or not data:
|
| 242 |
+
upstream = _clean(result.get("message"))
|
| 243 |
+
if upstream:
|
| 244 |
+
message = upstream
|
| 245 |
+
else:
|
| 246 |
+
message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
|
| 247 |
+
error = RuntimeError(message)
|
| 248 |
+
if account_email:
|
| 249 |
+
setattr(error, "account_email", account_email)
|
| 250 |
+
raise error
|
| 251 |
+
usage = result.get("usage")
|
| 252 |
+
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="")
|
| 253 |
+
self._log_call(
|
| 254 |
+
identity,
|
| 255 |
+
mode,
|
| 256 |
+
model,
|
| 257 |
+
started,
|
| 258 |
+
"调用完成",
|
| 259 |
+
request_preview=request_text(payload.get("prompt")),
|
| 260 |
+
urls=_collect_image_urls(data),
|
| 261 |
+
account_email=account_email,
|
| 262 |
+
)
|
| 263 |
+
except Exception as exc:
|
| 264 |
+
error_message = str(exc) or "image task failed"
|
| 265 |
+
account_email = _clean(getattr(exc, "account_email", ""))
|
| 266 |
+
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[])
|
| 267 |
+
self._log_call(
|
| 268 |
+
identity,
|
| 269 |
+
mode,
|
| 270 |
+
model,
|
| 271 |
+
started,
|
| 272 |
+
"调用失败",
|
| 273 |
+
request_preview=request_text(payload.get("prompt")),
|
| 274 |
+
status="failed",
|
| 275 |
+
error=error_message,
|
| 276 |
+
account_email=account_email,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
def _log_call(
|
| 280 |
+
self,
|
| 281 |
+
identity: dict[str, object],
|
| 282 |
+
mode: str,
|
| 283 |
+
model: str,
|
| 284 |
+
started: float,
|
| 285 |
+
suffix: str,
|
| 286 |
+
*,
|
| 287 |
+
request_preview: str = "",
|
| 288 |
+
status: str = "success",
|
| 289 |
+
error: str = "",
|
| 290 |
+
urls: list[str] | None = None,
|
| 291 |
+
account_email: str = "",
|
| 292 |
+
) -> None:
|
| 293 |
+
endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
|
| 294 |
+
summary_prefix = "图生图" if mode == "edit" else "文生图"
|
| 295 |
+
detail = {
|
| 296 |
+
"key_id": identity.get("id"),
|
| 297 |
+
"key_name": identity.get("name"),
|
| 298 |
+
"role": identity.get("role"),
|
| 299 |
+
"endpoint": endpoint,
|
| 300 |
+
"model": model,
|
| 301 |
+
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 302 |
+
"ended_at": _now_iso(),
|
| 303 |
+
"duration_ms": int((time.time() - started) * 1000),
|
| 304 |
+
"status": status,
|
| 305 |
+
}
|
| 306 |
+
if request_preview:
|
| 307 |
+
detail["request_text"] = request_preview
|
| 308 |
+
if error:
|
| 309 |
+
detail["error"] = error
|
| 310 |
+
if account_email:
|
| 311 |
+
detail["account_email"] = account_email
|
| 312 |
+
if urls:
|
| 313 |
+
detail["urls"] = list(dict.fromkeys(urls))
|
| 314 |
+
try:
|
| 315 |
+
log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
|
| 316 |
+
except Exception:
|
| 317 |
+
pass
|
| 318 |
+
|
| 319 |
+
def _update_task(self, key: str, **updates: Any) -> None:
|
| 320 |
+
with self._lock:
|
| 321 |
+
task = self._tasks.get(key)
|
| 322 |
+
if task is None:
|
| 323 |
+
return
|
| 324 |
+
task.update(updates)
|
| 325 |
+
task["updated_at"] = _now_iso()
|
| 326 |
+
self._save_locked()
|
| 327 |
+
|
| 328 |
+
def _load_locked(self) -> dict[str, dict[str, Any]]:
|
| 329 |
+
if not self.path.exists():
|
| 330 |
+
return {}
|
| 331 |
+
try:
|
| 332 |
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
| 333 |
+
except Exception:
|
| 334 |
+
return {}
|
| 335 |
+
raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
|
| 336 |
+
if not isinstance(raw_items, list):
|
| 337 |
+
return {}
|
| 338 |
+
tasks: dict[str, dict[str, Any]] = {}
|
| 339 |
+
for item in raw_items:
|
| 340 |
+
if not isinstance(item, dict):
|
| 341 |
+
continue
|
| 342 |
+
task_id = _clean(item.get("id"))
|
| 343 |
+
owner = _clean(item.get("owner_id"))
|
| 344 |
+
if not task_id or not owner:
|
| 345 |
+
continue
|
| 346 |
+
status = _clean(item.get("status"))
|
| 347 |
+
if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
|
| 348 |
+
status = TASK_STATUS_ERROR
|
| 349 |
+
task = {
|
| 350 |
+
"id": task_id,
|
| 351 |
+
"owner_id": owner,
|
| 352 |
+
"status": status,
|
| 353 |
+
"mode": "edit" if item.get("mode") == "edit" else "generate",
|
| 354 |
+
"model": _clean(item.get("model"), "gpt-image-2"),
|
| 355 |
+
"size": _clean(item.get("size")),
|
| 356 |
+
"quality": _clean(item.get("quality"), "auto"),
|
| 357 |
+
"created_at": _clean(item.get("created_at"), _now_iso()),
|
| 358 |
+
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
|
| 359 |
+
}
|
| 360 |
+
data = item.get("data")
|
| 361 |
+
if isinstance(data, list):
|
| 362 |
+
task["data"] = data
|
| 363 |
+
usage = item.get("usage")
|
| 364 |
+
if isinstance(usage, dict):
|
| 365 |
+
task["usage"] = usage
|
| 366 |
+
error = _clean(item.get("error"))
|
| 367 |
+
if error:
|
| 368 |
+
task["error"] = error
|
| 369 |
+
tasks[_task_key(owner, task_id)] = task
|
| 370 |
+
return tasks
|
| 371 |
+
|
| 372 |
+
def _save_locked(self) -> None:
|
| 373 |
+
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
|
| 374 |
+
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
| 375 |
+
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 376 |
+
tmp_path.replace(self.path)
|
| 377 |
+
|
| 378 |
+
def _recover_unfinished_locked(self) -> bool:
|
| 379 |
+
changed = False
|
| 380 |
+
for task in self._tasks.values():
|
| 381 |
+
if task.get("status") in UNFINISHED_STATUSES:
|
| 382 |
+
task["status"] = TASK_STATUS_ERROR
|
| 383 |
+
task["error"] = "服务已重启,未完成的图片任务已中断"
|
| 384 |
+
task["updated_at"] = _now_iso()
|
| 385 |
+
changed = True
|
| 386 |
+
return changed
|
| 387 |
+
|
| 388 |
+
def _cleanup_locked(self) -> bool:
|
| 389 |
+
try:
|
| 390 |
+
retention_days = max(1, int(self.retention_days_getter()))
|
| 391 |
+
except Exception:
|
| 392 |
+
retention_days = 30
|
| 393 |
+
cutoff = time.time() - retention_days * 86400
|
| 394 |
+
removed_keys = [
|
| 395 |
+
key
|
| 396 |
+
for key, task in self._tasks.items()
|
| 397 |
+
if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
|
| 398 |
+
]
|
| 399 |
+
for key in removed_keys:
|
| 400 |
+
self._tasks.pop(key, None)
|
| 401 |
+
return bool(removed_keys)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
|
services/log_service.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import itertools
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
from uuid import uuid4
|
| 12 |
+
|
| 13 |
+
from fastapi import HTTPException
|
| 14 |
+
from fastapi.concurrency import run_in_threadpool
|
| 15 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 16 |
+
|
| 17 |
+
from services.config import DATA_DIR
|
| 18 |
+
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
| 19 |
+
from utils.helper import anthropic_sse_stream, sse_json_stream
|
| 20 |
+
|
| 21 |
+
LOG_TYPE_CALL = "call"
|
| 22 |
+
LOG_TYPE_ACCOUNT = "account"
|
| 23 |
+
INTERNAL_RESPONSE_KEYS = {"_account_email"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class LogService:
|
| 27 |
+
def __init__(self, path: Path):
|
| 28 |
+
self.path = path
|
| 29 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def _legacy_id(raw_line: str, line_number: int) -> str:
|
| 33 |
+
payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
|
| 34 |
+
return hashlib.sha1(payload).hexdigest()[:24]
|
| 35 |
+
|
| 36 |
+
def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
|
| 37 |
+
try:
|
| 38 |
+
item = json.loads(raw_line)
|
| 39 |
+
except Exception:
|
| 40 |
+
return None
|
| 41 |
+
if not isinstance(item, dict):
|
| 42 |
+
return None
|
| 43 |
+
parsed = dict(item)
|
| 44 |
+
parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
|
| 45 |
+
return parsed
|
| 46 |
+
|
| 47 |
+
@staticmethod
|
| 48 |
+
def _serialize_item(item: dict[str, Any]) -> str:
|
| 49 |
+
return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
|
| 53 |
+
t = str(item.get("time") or "")
|
| 54 |
+
day = t[:10]
|
| 55 |
+
if type and item.get("type") != type:
|
| 56 |
+
return False
|
| 57 |
+
if start_date and day < start_date:
|
| 58 |
+
return False
|
| 59 |
+
if end_date and day > end_date:
|
| 60 |
+
return False
|
| 61 |
+
return True
|
| 62 |
+
|
| 63 |
+
def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
|
| 64 |
+
item = {
|
| 65 |
+
"id": uuid4().hex,
|
| 66 |
+
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 67 |
+
"type": type,
|
| 68 |
+
"summary": summary,
|
| 69 |
+
"detail": detail or data,
|
| 70 |
+
}
|
| 71 |
+
with self.path.open("a", encoding="utf-8") as file:
|
| 72 |
+
file.write(self._serialize_item(item) + "\n")
|
| 73 |
+
|
| 74 |
+
def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
| 75 |
+
if not self.path.exists():
|
| 76 |
+
return []
|
| 77 |
+
items: list[dict[str, Any]] = []
|
| 78 |
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
| 79 |
+
for line_number in range(len(lines) - 1, -1, -1):
|
| 80 |
+
item = self._parse_line(lines[line_number], line_number)
|
| 81 |
+
if item is None:
|
| 82 |
+
continue
|
| 83 |
+
if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
|
| 84 |
+
continue
|
| 85 |
+
items.append(item)
|
| 86 |
+
if len(items) >= limit:
|
| 87 |
+
break
|
| 88 |
+
return items
|
| 89 |
+
|
| 90 |
+
def delete(self, ids: list[str]) -> dict[str, int]:
|
| 91 |
+
target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
|
| 92 |
+
if not self.path.exists() or not target_ids:
|
| 93 |
+
return {"removed": 0}
|
| 94 |
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
| 95 |
+
kept_lines: list[str] = []
|
| 96 |
+
removed = 0
|
| 97 |
+
for line_number, raw_line in enumerate(lines):
|
| 98 |
+
item = self._parse_line(raw_line, line_number)
|
| 99 |
+
if item is None:
|
| 100 |
+
kept_lines.append(raw_line)
|
| 101 |
+
continue
|
| 102 |
+
if str(item.get("id") or "") in target_ids:
|
| 103 |
+
removed += 1
|
| 104 |
+
continue
|
| 105 |
+
kept_lines.append(self._serialize_item(item))
|
| 106 |
+
content = "\n".join(kept_lines)
|
| 107 |
+
if content:
|
| 108 |
+
content += "\n"
|
| 109 |
+
self.path.write_text(content, encoding="utf-8")
|
| 110 |
+
return {"removed": removed}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
log_service = LogService(DATA_DIR / "logs.jsonl")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _collect_urls(value: object) -> list[str]:
|
| 117 |
+
urls: list[str] = []
|
| 118 |
+
if isinstance(value, dict):
|
| 119 |
+
for key, item in value.items():
|
| 120 |
+
if key == "url" and isinstance(item, str):
|
| 121 |
+
urls.append(item)
|
| 122 |
+
elif key == "urls" and isinstance(item, list):
|
| 123 |
+
urls.extend(str(url) for url in item if isinstance(url, str))
|
| 124 |
+
else:
|
| 125 |
+
urls.extend(_collect_urls(item))
|
| 126 |
+
elif isinstance(value, list):
|
| 127 |
+
for item in value:
|
| 128 |
+
urls.extend(_collect_urls(item))
|
| 129 |
+
return urls
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _collect_account_emails(value: object) -> list[str]:
|
| 133 |
+
emails: list[str] = []
|
| 134 |
+
if isinstance(value, dict):
|
| 135 |
+
for key, item in value.items():
|
| 136 |
+
if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
|
| 137 |
+
emails.append(item.strip())
|
| 138 |
+
else:
|
| 139 |
+
emails.extend(_collect_account_emails(item))
|
| 140 |
+
elif isinstance(value, list):
|
| 141 |
+
for item in value:
|
| 142 |
+
emails.extend(_collect_account_emails(item))
|
| 143 |
+
return emails
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _strip_internal_response_fields(value: object) -> object:
|
| 147 |
+
if isinstance(value, dict):
|
| 148 |
+
return {
|
| 149 |
+
key: _strip_internal_response_fields(item)
|
| 150 |
+
for key, item in value.items()
|
| 151 |
+
if key not in INTERNAL_RESPONSE_KEYS
|
| 152 |
+
}
|
| 153 |
+
if isinstance(value, list):
|
| 154 |
+
return [_strip_internal_response_fields(item) for item in value]
|
| 155 |
+
return value
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _request_excerpt(text: object, limit: int = 1000) -> str:
|
| 159 |
+
value = str(text or "").strip()
|
| 160 |
+
if not value:
|
| 161 |
+
return ""
|
| 162 |
+
normalized = " ".join(value.split())
|
| 163 |
+
if len(normalized) <= limit:
|
| 164 |
+
return normalized
|
| 165 |
+
return normalized[: limit - 1].rstrip() + "…"
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _image_error_response(exc: Exception) -> JSONResponse:
|
| 169 |
+
from services.protocol.conversation import public_image_error_message
|
| 170 |
+
|
| 171 |
+
message = public_image_error_message(str(exc))
|
| 172 |
+
if "no available image quota" in message.lower():
|
| 173 |
+
return openai_error_response(
|
| 174 |
+
{
|
| 175 |
+
"error": {
|
| 176 |
+
"message": "no available image quota",
|
| 177 |
+
"type": "insufficient_quota",
|
| 178 |
+
"param": None,
|
| 179 |
+
"code": "insufficient_quota",
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
429,
|
| 183 |
+
)
|
| 184 |
+
if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
|
| 185 |
+
return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
|
| 186 |
+
return openai_error_response(message, 502)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
|
| 190 |
+
message = str(exc)
|
| 191 |
+
if sse == "anthropic":
|
| 192 |
+
return anthropic_error_response(message, status_code)
|
| 193 |
+
return openai_error_response(message, status_code)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def _next_item(items):
|
| 197 |
+
try:
|
| 198 |
+
return True, next(items)
|
| 199 |
+
except StopIteration:
|
| 200 |
+
return False, None
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
@dataclass
|
| 204 |
+
class LoggedCall:
|
| 205 |
+
identity: dict[str, object]
|
| 206 |
+
endpoint: str
|
| 207 |
+
model: str
|
| 208 |
+
summary: str
|
| 209 |
+
started: float = field(default_factory=time.time)
|
| 210 |
+
request_text: str = ""
|
| 211 |
+
|
| 212 |
+
async def run(self, handler, *args, sse: str = "openai"):
|
| 213 |
+
from services.protocol.conversation import ImageGenerationError
|
| 214 |
+
|
| 215 |
+
try:
|
| 216 |
+
result = await run_in_threadpool(handler, *args)
|
| 217 |
+
except ImageGenerationError as exc:
|
| 218 |
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
| 219 |
+
return _image_error_response(exc)
|
| 220 |
+
except HTTPException as exc:
|
| 221 |
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
| 222 |
+
raise
|
| 223 |
+
except Exception as exc:
|
| 224 |
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
| 225 |
+
if self.endpoint.startswith("/v1/images"):
|
| 226 |
+
return _image_error_response(exc)
|
| 227 |
+
return _protocol_error_response(exc, 502, sse)
|
| 228 |
+
|
| 229 |
+
if isinstance(result, dict):
|
| 230 |
+
self.log("调用完成", result)
|
| 231 |
+
response = dict(result)
|
| 232 |
+
response.pop("_account_email", None)
|
| 233 |
+
return response
|
| 234 |
+
|
| 235 |
+
sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
|
| 236 |
+
try:
|
| 237 |
+
has_first, first = await run_in_threadpool(_next_item, result)
|
| 238 |
+
except ImageGenerationError as exc:
|
| 239 |
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
| 240 |
+
return _image_error_response(exc)
|
| 241 |
+
except HTTPException as exc:
|
| 242 |
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
| 243 |
+
raise
|
| 244 |
+
except Exception as exc:
|
| 245 |
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
| 246 |
+
if self.endpoint.startswith("/v1/images"):
|
| 247 |
+
return _image_error_response(exc)
|
| 248 |
+
return _protocol_error_response(exc, 502, sse)
|
| 249 |
+
if not has_first:
|
| 250 |
+
self.log("流式调用结束")
|
| 251 |
+
return StreamingResponse(sender(()), media_type="text/event-stream")
|
| 252 |
+
return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
|
| 253 |
+
|
| 254 |
+
def stream(self, items):
|
| 255 |
+
urls: list[str] = []
|
| 256 |
+
account_emails: list[str] = []
|
| 257 |
+
failed = False
|
| 258 |
+
try:
|
| 259 |
+
for item in items:
|
| 260 |
+
urls.extend(_collect_urls(item))
|
| 261 |
+
account_emails.extend(_collect_account_emails(item))
|
| 262 |
+
yield _strip_internal_response_fields(item)
|
| 263 |
+
except Exception as exc:
|
| 264 |
+
failed = True
|
| 265 |
+
self.log(
|
| 266 |
+
"流式调用失败",
|
| 267 |
+
status="failed",
|
| 268 |
+
error=str(exc),
|
| 269 |
+
urls=urls,
|
| 270 |
+
account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
|
| 271 |
+
)
|
| 272 |
+
if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
|
| 273 |
+
from services.protocol.conversation import ImageGenerationError, public_image_error_message
|
| 274 |
+
|
| 275 |
+
raise ImageGenerationError(public_image_error_message(str(exc))) from exc
|
| 276 |
+
raise
|
| 277 |
+
finally:
|
| 278 |
+
if not failed:
|
| 279 |
+
self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "")
|
| 280 |
+
|
| 281 |
+
def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
|
| 282 |
+
urls: list[str] | None = None, account_email: str = "") -> None:
|
| 283 |
+
detail = {
|
| 284 |
+
"key_id": self.identity.get("id"),
|
| 285 |
+
"key_name": self.identity.get("name"),
|
| 286 |
+
"role": self.identity.get("role"),
|
| 287 |
+
"endpoint": self.endpoint,
|
| 288 |
+
"model": self.model,
|
| 289 |
+
"started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 290 |
+
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 291 |
+
"duration_ms": int((time.time() - self.started) * 1000),
|
| 292 |
+
"status": status,
|
| 293 |
+
}
|
| 294 |
+
request_excerpt = _request_excerpt(self.request_text)
|
| 295 |
+
if request_excerpt:
|
| 296 |
+
detail["request_text"] = request_excerpt
|
| 297 |
+
if error:
|
| 298 |
+
detail["error"] = error
|
| 299 |
+
email = str(account_email or "").strip()
|
| 300 |
+
if not email:
|
| 301 |
+
emails = _collect_account_emails(result)
|
| 302 |
+
email = emails[0] if emails else ""
|
| 303 |
+
if email:
|
| 304 |
+
detail["account_email"] = email
|
| 305 |
+
collected_urls = [*(urls or []), *_collect_urls(result)]
|
| 306 |
+
if collected_urls:
|
| 307 |
+
detail["urls"] = list(dict.fromkeys(collected_urls))
|
| 308 |
+
log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
|
services/oauth_login_service.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""手动 OAuth 桥服务
|
| 2 |
+
|
| 3 |
+
让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程:
|
| 4 |
+
1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。
|
| 5 |
+
2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的
|
| 6 |
+
callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。
|
| 7 |
+
3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token
|
| 8 |
+
得到 {access_token, refresh_token, id_token}。
|
| 9 |
+
|
| 10 |
+
得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个
|
| 11 |
+
(app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import base64
|
| 16 |
+
import hashlib
|
| 17 |
+
import secrets
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
import uuid
|
| 21 |
+
from typing import Any
|
| 22 |
+
from urllib.parse import parse_qs, urlencode, urlparse
|
| 23 |
+
|
| 24 |
+
from curl_cffi import requests
|
| 25 |
+
|
| 26 |
+
from services.proxy_service import proxy_settings
|
| 27 |
+
from services.register.openai_register import (
|
| 28 |
+
auth_base,
|
| 29 |
+
common_headers,
|
| 30 |
+
platform_auth0_client,
|
| 31 |
+
platform_base,
|
| 32 |
+
platform_oauth_audience,
|
| 33 |
+
platform_oauth_client_id,
|
| 34 |
+
platform_oauth_redirect_uri,
|
| 35 |
+
sec_ch_ua,
|
| 36 |
+
user_agent,
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class OAuthLoginError(Exception):
|
| 41 |
+
"""OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class OAuthLoginService:
|
| 45 |
+
"""维护 PKCE 临时会话,并完成 code → token 的兑换。"""
|
| 46 |
+
|
| 47 |
+
_SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
|
| 48 |
+
_MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
|
| 49 |
+
|
| 50 |
+
def __init__(self) -> None:
|
| 51 |
+
self._lock = threading.Lock()
|
| 52 |
+
self._sessions: dict[str, dict[str, Any]] = {}
|
| 53 |
+
|
| 54 |
+
@staticmethod
|
| 55 |
+
def _generate_pkce() -> tuple[str, str]:
|
| 56 |
+
"""生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
|
| 57 |
+
verifier = base64.urlsafe_b64encode(secrets.token_bytes(64)).rstrip(b"=").decode("ascii")
|
| 58 |
+
challenge = base64.urlsafe_b64encode(
|
| 59 |
+
hashlib.sha256(verifier.encode("ascii")).digest()
|
| 60 |
+
).rstrip(b"=").decode("ascii")
|
| 61 |
+
return verifier, challenge
|
| 62 |
+
|
| 63 |
+
def _purge_expired_locked(self) -> None:
|
| 64 |
+
"""清理过期或溢出容量的会话,必须在持锁状态下调用。"""
|
| 65 |
+
now = time.time()
|
| 66 |
+
expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
|
| 67 |
+
for sid in expired:
|
| 68 |
+
self._sessions.pop(sid, None)
|
| 69 |
+
if len(self._sessions) > self._MAX_SESSIONS:
|
| 70 |
+
ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
|
| 71 |
+
for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
|
| 72 |
+
self._sessions.pop(sid, None)
|
| 73 |
+
|
| 74 |
+
def start(self, email_hint: str = "") -> dict[str, str]:
|
| 75 |
+
"""登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
|
| 76 |
+
|
| 77 |
+
state 形如 "<session_id>.<nonce>",让 callback URL 自带 session_id,
|
| 78 |
+
finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
|
| 79 |
+
"""
|
| 80 |
+
verifier, challenge = self._generate_pkce()
|
| 81 |
+
nonce = secrets.token_urlsafe(32)
|
| 82 |
+
device_id = str(uuid.uuid4())
|
| 83 |
+
session_id = uuid.uuid4().hex
|
| 84 |
+
state = f"{session_id}.{secrets.token_urlsafe(16)}"
|
| 85 |
+
|
| 86 |
+
params = {
|
| 87 |
+
"issuer": auth_base,
|
| 88 |
+
"client_id": platform_oauth_client_id,
|
| 89 |
+
"audience": platform_oauth_audience,
|
| 90 |
+
"redirect_uri": platform_oauth_redirect_uri,
|
| 91 |
+
"device_id": device_id,
|
| 92 |
+
"screen_hint": "login_or_signup",
|
| 93 |
+
"max_age": "0",
|
| 94 |
+
"scope": "openid profile email offline_access",
|
| 95 |
+
"response_type": "code",
|
| 96 |
+
"response_mode": "query",
|
| 97 |
+
"state": state,
|
| 98 |
+
"nonce": nonce,
|
| 99 |
+
"code_challenge": challenge,
|
| 100 |
+
"code_challenge_method": "S256",
|
| 101 |
+
"auth0Client": platform_auth0_client,
|
| 102 |
+
}
|
| 103 |
+
email_hint = str(email_hint or "").strip()
|
| 104 |
+
if email_hint:
|
| 105 |
+
params["login_hint"] = email_hint
|
| 106 |
+
|
| 107 |
+
authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
|
| 108 |
+
|
| 109 |
+
with self._lock:
|
| 110 |
+
self._purge_expired_locked()
|
| 111 |
+
self._sessions[session_id] = {
|
| 112 |
+
"code_verifier": verifier,
|
| 113 |
+
"state": state,
|
| 114 |
+
"created_at": time.time(),
|
| 115 |
+
"redirect_uri": platform_oauth_redirect_uri,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
return {
|
| 119 |
+
"session_id": session_id,
|
| 120 |
+
"authorize_url": authorize_url,
|
| 121 |
+
"expires_in": str(self._SESSION_TTL_SECONDS),
|
| 122 |
+
"redirect_uri_prefix": platform_oauth_redirect_uri,
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
@staticmethod
|
| 126 |
+
def _extract_code_from_callback(value: str) -> tuple[str, str]:
|
| 127 |
+
"""从 callback URL 或 raw code 中提取 (code, state)。
|
| 128 |
+
|
| 129 |
+
既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
|
| 130 |
+
也允许只粘 code 本身。
|
| 131 |
+
"""
|
| 132 |
+
raw = str(value or "").strip()
|
| 133 |
+
if not raw:
|
| 134 |
+
return "", ""
|
| 135 |
+
if raw.startswith("http://") or raw.startswith("https://"):
|
| 136 |
+
try:
|
| 137 |
+
parsed = parse_qs(urlparse(raw).query)
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
|
| 140 |
+
code = str((parsed.get("code") or [""])[0]).strip()
|
| 141 |
+
state = str((parsed.get("state") or [""])[0]).strip()
|
| 142 |
+
if not code:
|
| 143 |
+
err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
|
| 144 |
+
raise OAuthLoginError(err or "callback URL 中没有 code 参数")
|
| 145 |
+
return code, state
|
| 146 |
+
# 用户可能直接粘了 code 字符串
|
| 147 |
+
return raw, ""
|
| 148 |
+
|
| 149 |
+
def finish(self, session_id: str, callback: str) -> dict[str, str]:
|
| 150 |
+
"""用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
|
| 151 |
+
|
| 152 |
+
- 优先用 callback URL 自带 state 里的 session_id(更可靠),
|
| 153 |
+
找不到才用前端传来的 session_id;
|
| 154 |
+
- 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
|
| 155 |
+
只有成功兑换才 pop,便于用户用同一 verifier 重试。
|
| 156 |
+
"""
|
| 157 |
+
body_sid = str(session_id or "").strip()
|
| 158 |
+
code, state = self._extract_code_from_callback(callback)
|
| 159 |
+
if not code:
|
| 160 |
+
raise OAuthLoginError("缺少 code 或 callback URL")
|
| 161 |
+
|
| 162 |
+
# state 里嵌的 session_id 优先级最高
|
| 163 |
+
state_sid = state.split(".", 1)[0] if state else ""
|
| 164 |
+
candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
|
| 165 |
+
if not candidate_sids:
|
| 166 |
+
raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
|
| 167 |
+
|
| 168 |
+
with self._lock:
|
| 169 |
+
self._purge_expired_locked()
|
| 170 |
+
session = None
|
| 171 |
+
picked_sid = ""
|
| 172 |
+
for sid in candidate_sids:
|
| 173 |
+
cur = self._sessions.get(sid)
|
| 174 |
+
if cur is not None:
|
| 175 |
+
session = cur
|
| 176 |
+
picked_sid = sid
|
| 177 |
+
break
|
| 178 |
+
if session is None:
|
| 179 |
+
raise OAuthLoginError(
|
| 180 |
+
"OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
if state and session.get("state") and state != session["state"]:
|
| 184 |
+
raise OAuthLoginError(
|
| 185 |
+
"state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
tokens = self._exchange_code(
|
| 189 |
+
code,
|
| 190 |
+
session["code_verifier"],
|
| 191 |
+
session.get("redirect_uri") or platform_oauth_redirect_uri,
|
| 192 |
+
)
|
| 193 |
+
# 仅在成功兑换之后才消耗 session
|
| 194 |
+
with self._lock:
|
| 195 |
+
self._sessions.pop(picked_sid, None)
|
| 196 |
+
return tokens
|
| 197 |
+
|
| 198 |
+
@staticmethod
|
| 199 |
+
def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
|
| 200 |
+
"""调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
|
| 201 |
+
kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
|
| 202 |
+
session = requests.Session(**kwargs)
|
| 203 |
+
try:
|
| 204 |
+
response = session.post(
|
| 205 |
+
f"{auth_base}/api/accounts/oauth/token",
|
| 206 |
+
headers={
|
| 207 |
+
**common_headers,
|
| 208 |
+
"referer": f"{platform_base}/",
|
| 209 |
+
"origin": platform_base,
|
| 210 |
+
"auth0-client": platform_auth0_client,
|
| 211 |
+
"sec-ch-ua": sec_ch_ua,
|
| 212 |
+
"user-agent": user_agent,
|
| 213 |
+
},
|
| 214 |
+
json={
|
| 215 |
+
"client_id": platform_oauth_client_id,
|
| 216 |
+
"code_verifier": code_verifier,
|
| 217 |
+
"grant_type": "authorization_code",
|
| 218 |
+
"code": code,
|
| 219 |
+
"redirect_uri": redirect_uri,
|
| 220 |
+
},
|
| 221 |
+
timeout=60,
|
| 222 |
+
)
|
| 223 |
+
except Exception as exc:
|
| 224 |
+
raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
|
| 225 |
+
finally:
|
| 226 |
+
session.close()
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
data = response.json() if response.text else {}
|
| 230 |
+
except Exception:
|
| 231 |
+
data = {}
|
| 232 |
+
|
| 233 |
+
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
|
| 234 |
+
detail = ""
|
| 235 |
+
if isinstance(data, dict):
|
| 236 |
+
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
|
| 237 |
+
if not detail:
|
| 238 |
+
try:
|
| 239 |
+
detail = str(response.text or "")[:300]
|
| 240 |
+
except Exception:
|
| 241 |
+
detail = ""
|
| 242 |
+
# 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
|
| 243 |
+
print(
|
| 244 |
+
f"[oauth-login] /api/accounts/oauth/token rejected: "
|
| 245 |
+
f"status={response.status_code} detail={detail!r} "
|
| 246 |
+
f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
|
| 247 |
+
flush=True,
|
| 248 |
+
)
|
| 249 |
+
raise OAuthLoginError(
|
| 250 |
+
f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
access_token = str(data.get("access_token") or "").strip()
|
| 254 |
+
refresh_token = str(data.get("refresh_token") or "").strip()
|
| 255 |
+
id_token = str(data.get("id_token") or "").strip()
|
| 256 |
+
|
| 257 |
+
if not access_token:
|
| 258 |
+
raise OAuthLoginError("OpenAI 返回的 access_token 为空")
|
| 259 |
+
if not refresh_token:
|
| 260 |
+
# scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
|
| 261 |
+
raise OAuthLoginError(
|
| 262 |
+
"OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
return {
|
| 266 |
+
"access_token": access_token,
|
| 267 |
+
"refresh_token": refresh_token,
|
| 268 |
+
"id_token": id_token,
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
oauth_login_service = OAuthLoginService()
|
services/openai_backend_api.py
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import random
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
import urllib.error
|
| 8 |
+
import urllib.request
|
| 9 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Dict, Iterator, Optional
|
| 14 |
+
|
| 15 |
+
from curl_cffi import requests
|
| 16 |
+
from PIL import Image
|
| 17 |
+
|
| 18 |
+
from services.account_service import account_service
|
| 19 |
+
from services.config import config
|
| 20 |
+
from services.proxy_service import proxy_settings
|
| 21 |
+
from utils.helper import UpstreamHTTPError, ensure_ok, iter_sse_payloads, new_uuid, split_image_model
|
| 22 |
+
from utils.log import logger
|
| 23 |
+
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 24 |
+
from utils.turnstile import solve_turnstile_token
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class InvalidAccessTokenError(RuntimeError):
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ImagePollTimeoutError(RuntimeError):
|
| 32 |
+
pass
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class ChatRequirements:
|
| 37 |
+
"""保存一次对话请求所需的 sentinel token。"""
|
| 38 |
+
token: str
|
| 39 |
+
proof_token: str = ""
|
| 40 |
+
turnstile_token: str = ""
|
| 41 |
+
so_token: str = ""
|
| 42 |
+
raw_finalize: Optional[Dict[str, Any]] = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
|
| 46 |
+
DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
|
| 47 |
+
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
| 48 |
+
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
| 49 |
+
CODEX_RESPONSES_MODEL = "gpt-5.5"
|
| 50 |
+
CODEX_RESPONSES_INSTRUCTIONS = (
|
| 51 |
+
"Use the image_generation tool to create exactly one image for the user's request. "
|
| 52 |
+
"Return the generated image result."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class OpenAIBackendAPI:
|
| 57 |
+
"""ChatGPT Web 后端封装。
|
| 58 |
+
|
| 59 |
+
说明:
|
| 60 |
+
- 传入 `access_token` 时,聊天和模型列表都会走已登录链路
|
| 61 |
+
例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
|
| 62 |
+
- 不传 `access_token` 时,会走未登录链路
|
| 63 |
+
例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
|
| 64 |
+
- `stream_conversation()` 是底层统一流式入口
|
| 65 |
+
- 协议兼容转换放在 `services.protocol`
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
def __init__(self, access_token: str = "") -> None:
|
| 69 |
+
"""初始化后端客户端。
|
| 70 |
+
|
| 71 |
+
参数:
|
| 72 |
+
- `access_token`:可选。传入后表示使用已登录链路;不传则使用未登录链路。
|
| 73 |
+
"""
|
| 74 |
+
self.base_url = "https://chatgpt.com"
|
| 75 |
+
self.client_version = DEFAULT_CLIENT_VERSION
|
| 76 |
+
self.client_build_number = DEFAULT_CLIENT_BUILD_NUMBER
|
| 77 |
+
self.access_token = access_token
|
| 78 |
+
self.account = account_service.get_account(self.access_token) if self.access_token else {}
|
| 79 |
+
self.account = self.account if isinstance(self.account, dict) else {}
|
| 80 |
+
self.fp = self._build_fp()
|
| 81 |
+
self.user_agent = self.fp["user-agent"]
|
| 82 |
+
self.device_id = self.fp["oai-device-id"]
|
| 83 |
+
self.session_id = self.fp["oai-session-id"]
|
| 84 |
+
self.pow_script_sources: list[str] = []
|
| 85 |
+
self.pow_data_build = ""
|
| 86 |
+
self.session = requests.Session(**proxy_settings.build_session_kwargs(
|
| 87 |
+
account=self.account,
|
| 88 |
+
impersonate=self.fp["impersonate"],
|
| 89 |
+
verify=True,
|
| 90 |
+
))
|
| 91 |
+
self.session.headers.update({
|
| 92 |
+
"User-Agent": self.user_agent,
|
| 93 |
+
"Origin": self.base_url,
|
| 94 |
+
"Referer": self.base_url + "/",
|
| 95 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-US;q=0.7",
|
| 96 |
+
"Cache-Control": "no-cache",
|
| 97 |
+
"Pragma": "no-cache",
|
| 98 |
+
"Priority": "u=1, i",
|
| 99 |
+
"Sec-Ch-Ua": self.fp["sec-ch-ua"],
|
| 100 |
+
"Sec-Ch-Ua-Arch": '"x86"',
|
| 101 |
+
"Sec-Ch-Ua-Bitness": '"64"',
|
| 102 |
+
"Sec-Ch-Ua-Full-Version": '"143.0.3650.96"',
|
| 103 |
+
"Sec-Ch-Ua-Full-Version-List": '"Microsoft Edge";v="143.0.3650.96", "Chromium";v="143.0.7499.147", "Not A(Brand";v="24.0.0.0"',
|
| 104 |
+
"Sec-Ch-Ua-Mobile": self.fp["sec-ch-ua-mobile"],
|
| 105 |
+
"Sec-Ch-Ua-Model": '""',
|
| 106 |
+
"Sec-Ch-Ua-Platform": self.fp["sec-ch-ua-platform"],
|
| 107 |
+
"Sec-Ch-Ua-Platform-Version": '"19.0.0"',
|
| 108 |
+
"Sec-Fetch-Dest": "empty",
|
| 109 |
+
"Sec-Fetch-Mode": "cors",
|
| 110 |
+
"Sec-Fetch-Site": "same-origin",
|
| 111 |
+
"OAI-Device-Id": self.device_id,
|
| 112 |
+
"OAI-Session-Id": self.session_id,
|
| 113 |
+
"OAI-Language": "zh-CN",
|
| 114 |
+
"OAI-Client-Version": self.client_version,
|
| 115 |
+
"OAI-Client-Build-Number": self.client_build_number,
|
| 116 |
+
})
|
| 117 |
+
if self.access_token:
|
| 118 |
+
self.session.headers["Authorization"] = f"Bearer {self.access_token}"
|
| 119 |
+
|
| 120 |
+
def _build_fp(self) -> Dict[str, str]:
|
| 121 |
+
account = self.account
|
| 122 |
+
raw_fp = account.get("fp")
|
| 123 |
+
fp = {str(k).lower(): str(v) for k, v in raw_fp.items()} if isinstance(raw_fp, dict) else {}
|
| 124 |
+
for key in (
|
| 125 |
+
"user-agent",
|
| 126 |
+
"impersonate",
|
| 127 |
+
"oai-device-id",
|
| 128 |
+
"oai-session-id",
|
| 129 |
+
"sec-ch-ua",
|
| 130 |
+
"sec-ch-ua-mobile",
|
| 131 |
+
"sec-ch-ua-platform",
|
| 132 |
+
):
|
| 133 |
+
value = str(account.get(key) or "").strip()
|
| 134 |
+
if value:
|
| 135 |
+
fp[key] = value
|
| 136 |
+
fp.setdefault(
|
| 137 |
+
"user-agent",
|
| 138 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
| 139 |
+
"(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0",
|
| 140 |
+
)
|
| 141 |
+
fp.setdefault("impersonate", "edge101")
|
| 142 |
+
fp.setdefault("oai-device-id", new_uuid())
|
| 143 |
+
fp.setdefault("oai-session-id", new_uuid())
|
| 144 |
+
fp.setdefault("sec-ch-ua", '"Microsoft Edge";v="143", "Chromium";v="143", "Not A(Brand";v="24"')
|
| 145 |
+
fp.setdefault("sec-ch-ua-mobile", "?0")
|
| 146 |
+
fp.setdefault("sec-ch-ua-platform", '"Windows"')
|
| 147 |
+
return fp
|
| 148 |
+
|
| 149 |
+
def _headers(self, path: str, extra: Optional[Dict[str, str]] = None) -> Dict[str, str]:
|
| 150 |
+
"""构造请求头,并补上 web 端要求的 target path/route。"""
|
| 151 |
+
headers = dict(self.session.headers)
|
| 152 |
+
headers["X-OpenAI-Target-Path"] = path
|
| 153 |
+
headers["X-OpenAI-Target-Route"] = path
|
| 154 |
+
if extra:
|
| 155 |
+
headers.update(extra)
|
| 156 |
+
return headers
|
| 157 |
+
|
| 158 |
+
@staticmethod
|
| 159 |
+
def _extract_quota_and_restore_at(limits_progress: list[Any]) -> tuple[int, str | None, bool]:
|
| 160 |
+
for item in limits_progress:
|
| 161 |
+
if isinstance(item, dict) and item.get("feature_name") == "image_gen":
|
| 162 |
+
return int(item.get("remaining") or 0), str(item.get("reset_after") or "") or None, False
|
| 163 |
+
return 0, None, True
|
| 164 |
+
|
| 165 |
+
def _get_me(self) -> Dict[str, Any]:
|
| 166 |
+
path = "/backend-api/me"
|
| 167 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path), timeout=20)
|
| 168 |
+
if response.status_code != 200:
|
| 169 |
+
if response.status_code == 401:
|
| 170 |
+
raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
|
| 171 |
+
raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
|
| 172 |
+
return response.json()
|
| 173 |
+
|
| 174 |
+
def _get_conversation_init(self) -> Dict[str, Any]:
|
| 175 |
+
path = "/backend-api/conversation/init"
|
| 176 |
+
response = self.session.post(
|
| 177 |
+
self.base_url + path,
|
| 178 |
+
headers=self._headers(path, {"Content-Type": "application/json"}),
|
| 179 |
+
json={
|
| 180 |
+
"gizmo_id": None,
|
| 181 |
+
"requested_default_model": None,
|
| 182 |
+
"conversation_id": None,
|
| 183 |
+
"timezone_offset_min": -480,
|
| 184 |
+
},
|
| 185 |
+
timeout=20,
|
| 186 |
+
)
|
| 187 |
+
if response.status_code != 200:
|
| 188 |
+
if response.status_code == 401:
|
| 189 |
+
raise InvalidAccessTokenError(f"{path} failed: HTTP {response.status_code}")
|
| 190 |
+
raise RuntimeError(f"{path} failed: HTTP {response.status_code}")
|
| 191 |
+
return response.json()
|
| 192 |
+
|
| 193 |
+
def _get_default_account(self) -> Dict[str, Any]:
|
| 194 |
+
route = "/backend-api/accounts/check/v4-2023-04-27"
|
| 195 |
+
response = self.session.get(self.base_url + route + "?timezone_offset_min=-480", headers=self._headers(route),
|
| 196 |
+
timeout=20)
|
| 197 |
+
if response.status_code != 200:
|
| 198 |
+
if response.status_code == 401:
|
| 199 |
+
raise InvalidAccessTokenError(f"{route} failed: HTTP {response.status_code}")
|
| 200 |
+
raise RuntimeError(f"/backend-api/accounts/check failed: HTTP {response.status_code}")
|
| 201 |
+
payload = response.json()
|
| 202 |
+
logger.debug({"event": "backend_user_info_account_payload", "account_payload": payload})
|
| 203 |
+
return ((payload.get("accounts") or {}).get("default") or {}).get("account") or {}
|
| 204 |
+
|
| 205 |
+
def get_user_info(self) -> Dict[str, Any]:
|
| 206 |
+
"""获取当前 token 的账号信息。"""
|
| 207 |
+
if not self.access_token:
|
| 208 |
+
raise RuntimeError("access_token is required")
|
| 209 |
+
logger.debug({"event": "backend_user_info_start"})
|
| 210 |
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
| 211 |
+
me_future = executor.submit(self._get_me)
|
| 212 |
+
init_future = executor.submit(self._get_conversation_init)
|
| 213 |
+
account_future = executor.submit(self._get_default_account)
|
| 214 |
+
me_payload, init_payload, default_account = me_future.result(), init_future.result(), account_future.result()
|
| 215 |
+
|
| 216 |
+
plan_type = str(default_account.get("plan_type") or "free")
|
| 217 |
+
|
| 218 |
+
limits_progress = init_payload.get("limits_progress")
|
| 219 |
+
limits_progress = limits_progress if isinstance(limits_progress, list) else []
|
| 220 |
+
quota, restore_at, image_quota_unknown = self._extract_quota_and_restore_at(limits_progress)
|
| 221 |
+
result = {
|
| 222 |
+
"email": me_payload.get("email"),
|
| 223 |
+
"user_id": me_payload.get("id"),
|
| 224 |
+
"type": plan_type,
|
| 225 |
+
"quota": quota,
|
| 226 |
+
"image_quota_unknown": image_quota_unknown,
|
| 227 |
+
"limits_progress": limits_progress,
|
| 228 |
+
"default_model_slug": init_payload.get("default_model_slug"),
|
| 229 |
+
"restore_at": restore_at,
|
| 230 |
+
"status": "正常" if image_quota_unknown and plan_type.lower() != "free" else ("限流" if quota == 0 else "正常"),
|
| 231 |
+
}
|
| 232 |
+
logger.debug({
|
| 233 |
+
"event": "backend_user_info_result",
|
| 234 |
+
"email": result.get("email"),
|
| 235 |
+
"user_id": result.get("user_id"),
|
| 236 |
+
"type": result.get("type"),
|
| 237 |
+
"quota": result.get("quota"),
|
| 238 |
+
"image_quota_unknown": result.get("image_quota_unknown"),
|
| 239 |
+
"default_model_slug": result.get("default_model_slug"),
|
| 240 |
+
"restore_at": result.get("restore_at"),
|
| 241 |
+
"status": result.get("status"),
|
| 242 |
+
})
|
| 243 |
+
return result
|
| 244 |
+
|
| 245 |
+
def _bootstrap_headers(self) -> Dict[str, str]:
|
| 246 |
+
"""构造首页预热请求头。"""
|
| 247 |
+
return {
|
| 248 |
+
"User-Agent": self.user_agent,
|
| 249 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
| 250 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
| 251 |
+
"Sec-Ch-Ua": self.session.headers["Sec-Ch-Ua"],
|
| 252 |
+
"Sec-Ch-Ua-Mobile": self.session.headers["Sec-Ch-Ua-Mobile"],
|
| 253 |
+
"Sec-Ch-Ua-Platform": self.session.headers["Sec-Ch-Ua-Platform"],
|
| 254 |
+
"Sec-Fetch-Dest": "document",
|
| 255 |
+
"Sec-Fetch-Mode": "navigate",
|
| 256 |
+
"Sec-Fetch-Site": "none",
|
| 257 |
+
"Sec-Fetch-User": "?1",
|
| 258 |
+
"Upgrade-Insecure-Requests": "1",
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
|
| 262 |
+
"""把 sentinel 响应整理成后续对话需要的 token 集合。"""
|
| 263 |
+
if (data.get("arkose") or {}).get("required"):
|
| 264 |
+
raise RuntimeError("chat requirements requires arkose token, which is not implemented")
|
| 265 |
+
|
| 266 |
+
proof_token = ""
|
| 267 |
+
proof_info = data.get("proofofwork") or {}
|
| 268 |
+
if proof_info.get("required"):
|
| 269 |
+
proof_token = build_proof_token(
|
| 270 |
+
proof_info.get("seed", ""),
|
| 271 |
+
proof_info.get("difficulty", ""),
|
| 272 |
+
self.user_agent,
|
| 273 |
+
script_sources=self.pow_script_sources,
|
| 274 |
+
data_build=self.pow_data_build,
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
turnstile_token = ""
|
| 278 |
+
turnstile_info = data.get("turnstile") or {}
|
| 279 |
+
if turnstile_info.get("required") and turnstile_info.get("dx"):
|
| 280 |
+
turnstile_token = solve_turnstile_token(turnstile_info["dx"], source_p) or ""
|
| 281 |
+
|
| 282 |
+
return ChatRequirements(
|
| 283 |
+
token=data.get("token", ""),
|
| 284 |
+
proof_token=proof_token,
|
| 285 |
+
turnstile_token=turnstile_token,
|
| 286 |
+
so_token=data.get("so_token", ""),
|
| 287 |
+
raw_finalize=data,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
def _conversation_headers(self, path: str, requirements: ChatRequirements) -> Dict[str, str]:
|
| 291 |
+
"""根据当前 requirements 构造对话 SSE 请求头。"""
|
| 292 |
+
headers = {
|
| 293 |
+
"Accept": "text/event-stream",
|
| 294 |
+
"Content-Type": "application/json",
|
| 295 |
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
| 296 |
+
}
|
| 297 |
+
if requirements.proof_token:
|
| 298 |
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
| 299 |
+
if requirements.turnstile_token:
|
| 300 |
+
headers["OpenAI-Sentinel-Turnstile-Token"] = requirements.turnstile_token
|
| 301 |
+
if requirements.so_token:
|
| 302 |
+
headers["OpenAI-Sentinel-SO-Token"] = requirements.so_token
|
| 303 |
+
return self._headers(path, headers)
|
| 304 |
+
|
| 305 |
+
def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) -> list[Dict[str, Any]]:
|
| 306 |
+
"""把标准 chat messages 转成 web conversation 所需的 messages。"""
|
| 307 |
+
conversation_messages = []
|
| 308 |
+
for item in messages:
|
| 309 |
+
role = item.get("role", "user")
|
| 310 |
+
content = item.get("content", "")
|
| 311 |
+
if isinstance(content, str):
|
| 312 |
+
conversation_messages.append({
|
| 313 |
+
"id": new_uuid(),
|
| 314 |
+
"author": {"role": role},
|
| 315 |
+
"content": {"content_type": "text", "parts": [content]},
|
| 316 |
+
})
|
| 317 |
+
continue
|
| 318 |
+
if not isinstance(content, list):
|
| 319 |
+
raise RuntimeError("only string or list message content is supported")
|
| 320 |
+
text_parts: list[str] = []
|
| 321 |
+
image_inputs: list[tuple[bytes, str]] = []
|
| 322 |
+
for part in content:
|
| 323 |
+
if not isinstance(part, dict):
|
| 324 |
+
continue
|
| 325 |
+
part_type = str(part.get("type") or "")
|
| 326 |
+
if part_type == "text":
|
| 327 |
+
text_parts.append(str(part.get("text") or ""))
|
| 328 |
+
elif part_type == "image":
|
| 329 |
+
data = part.get("data")
|
| 330 |
+
mime = str(part.get("mime") or "image/png")
|
| 331 |
+
if isinstance(data, (bytes, bytearray)):
|
| 332 |
+
image_inputs.append((bytes(data), mime))
|
| 333 |
+
if not image_inputs:
|
| 334 |
+
conversation_messages.append({
|
| 335 |
+
"id": new_uuid(),
|
| 336 |
+
"author": {"role": role},
|
| 337 |
+
"content": {"content_type": "text", "parts": ["".join(text_parts)]},
|
| 338 |
+
})
|
| 339 |
+
continue
|
| 340 |
+
if not self.access_token:
|
| 341 |
+
raise RuntimeError("authenticated upstream account required for image input")
|
| 342 |
+
uploaded: list[Dict[str, Any]] = []
|
| 343 |
+
for idx, (data, mime) in enumerate(image_inputs, start=1):
|
| 344 |
+
ext_part = mime.split("/", 1)[1].split("+")[0] if "/" in mime else "png"
|
| 345 |
+
extension = "jpg" if ext_part == "jpeg" else (ext_part or "png")
|
| 346 |
+
b64 = base64.b64encode(data).decode("ascii")
|
| 347 |
+
uploaded.append(self._upload_image(f"data:{mime};base64,{b64}", f"image_{idx}.{extension}"))
|
| 348 |
+
parts: list[Any] = []
|
| 349 |
+
for ref in uploaded:
|
| 350 |
+
parts.append({
|
| 351 |
+
"content_type": "image_asset_pointer",
|
| 352 |
+
"asset_pointer": f"file-service://{ref['file_id']}",
|
| 353 |
+
"width": ref["width"],
|
| 354 |
+
"height": ref["height"],
|
| 355 |
+
"size_bytes": ref["file_size"],
|
| 356 |
+
})
|
| 357 |
+
text = "".join(text_parts)
|
| 358 |
+
if text:
|
| 359 |
+
parts.append(text)
|
| 360 |
+
conversation_messages.append({
|
| 361 |
+
"id": new_uuid(),
|
| 362 |
+
"author": {"role": role},
|
| 363 |
+
"content": {"content_type": "multimodal_text", "parts": parts},
|
| 364 |
+
"metadata": {
|
| 365 |
+
"attachments": [{
|
| 366 |
+
"id": ref["file_id"],
|
| 367 |
+
"mimeType": ref["mime_type"],
|
| 368 |
+
"name": ref["file_name"],
|
| 369 |
+
"size": ref["file_size"],
|
| 370 |
+
"width": ref["width"],
|
| 371 |
+
"height": ref["height"],
|
| 372 |
+
} for ref in uploaded],
|
| 373 |
+
},
|
| 374 |
+
})
|
| 375 |
+
return conversation_messages
|
| 376 |
+
|
| 377 |
+
def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]:
|
| 378 |
+
"""把标准 messages 构造成 web 对话请求体。"""
|
| 379 |
+
return {
|
| 380 |
+
"action": "next",
|
| 381 |
+
"messages": self._api_messages_to_conversation_messages(messages),
|
| 382 |
+
"model": model,
|
| 383 |
+
"parent_message_id": new_uuid(),
|
| 384 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 385 |
+
"conversation_origin": None,
|
| 386 |
+
"force_paragen": False,
|
| 387 |
+
"force_paragen_model_slug": "",
|
| 388 |
+
"force_rate_limit": False,
|
| 389 |
+
"force_use_sse": True,
|
| 390 |
+
"history_and_training_disabled": True,
|
| 391 |
+
"reset_rate_limits": False,
|
| 392 |
+
"suggestions": [],
|
| 393 |
+
"supported_encodings": [],
|
| 394 |
+
"system_hints": [],
|
| 395 |
+
"timezone": timezone,
|
| 396 |
+
"timezone_offset_min": -480,
|
| 397 |
+
"variant_purpose": "comparison_implicit",
|
| 398 |
+
"websocket_request_id": new_uuid(),
|
| 399 |
+
"client_contextual_info": {
|
| 400 |
+
"is_dark_mode": False,
|
| 401 |
+
"time_since_loaded": 120,
|
| 402 |
+
"page_height": 900,
|
| 403 |
+
"page_width": 1400,
|
| 404 |
+
"pixel_ratio": 2,
|
| 405 |
+
"screen_height": 1440,
|
| 406 |
+
"screen_width": 2560,
|
| 407 |
+
},
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
def _image_model_slug(self, model: str) -> str:
|
| 411 |
+
"""把标准图片模型名映射到底层 model slug。"""
|
| 412 |
+
_, base_model = split_image_model(model)
|
| 413 |
+
if not base_model:
|
| 414 |
+
return "auto"
|
| 415 |
+
if base_model == "gpt-image-2":
|
| 416 |
+
return "gpt-5-3"
|
| 417 |
+
if base_model == CODEX_IMAGE_MODEL:
|
| 418 |
+
return base_model
|
| 419 |
+
return "auto"
|
| 420 |
+
|
| 421 |
+
def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
|
| 422 |
+
Dict[str, str]:
|
| 423 |
+
"""构造图片链路请求头。"""
|
| 424 |
+
headers = {
|
| 425 |
+
"Content-Type": "application/json",
|
| 426 |
+
"Accept": accept,
|
| 427 |
+
"OpenAI-Sentinel-Chat-Requirements-Token": requirements.token,
|
| 428 |
+
}
|
| 429 |
+
if requirements.proof_token:
|
| 430 |
+
headers["OpenAI-Sentinel-Proof-Token"] = requirements.proof_token
|
| 431 |
+
if conduit_token:
|
| 432 |
+
headers["X-Conduit-Token"] = conduit_token
|
| 433 |
+
if accept == "text/event-stream":
|
| 434 |
+
headers["X-Oai-Turn-Trace-Id"] = new_uuid()
|
| 435 |
+
return self._headers(path, headers)
|
| 436 |
+
|
| 437 |
+
def _codex_responses_headers(self) -> Dict[str, str]:
|
| 438 |
+
return {
|
| 439 |
+
"Authorization": f"Bearer {self.access_token}",
|
| 440 |
+
"Content-Type": "application/json",
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
def _ensure_codex_source_account(self) -> None:
|
| 444 |
+
account = account_service.get_account(self.access_token)
|
| 445 |
+
source_type = str((account or {}).get("source_type") or "web").strip().lower()
|
| 446 |
+
if source_type != "codex":
|
| 447 |
+
raise RuntimeError("codex responses endpoint requires a codex source account")
|
| 448 |
+
|
| 449 |
+
@staticmethod
|
| 450 |
+
def _codex_image_input(prompt: str, images: list[str]) -> list[Dict[str, Any]]:
|
| 451 |
+
content: list[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
| 452 |
+
for image in images:
|
| 453 |
+
payload = image if image.startswith("data:image/") else f"data:image/png;base64,{image}"
|
| 454 |
+
content.append({"type": "input_image", "image_url": payload})
|
| 455 |
+
return [{"role": "user", "content": content}]
|
| 456 |
+
|
| 457 |
+
@staticmethod
|
| 458 |
+
def _codex_body_preview(body: Any, limit: int = 4000) -> str:
|
| 459 |
+
if isinstance(body, (dict, list)):
|
| 460 |
+
try:
|
| 461 |
+
text = json.dumps(body, ensure_ascii=False)
|
| 462 |
+
except Exception:
|
| 463 |
+
text = repr(body)
|
| 464 |
+
else:
|
| 465 |
+
text = str(body or "")
|
| 466 |
+
return text if len(text) <= limit else text[:limit] + "...[truncated]"
|
| 467 |
+
|
| 468 |
+
@staticmethod
|
| 469 |
+
def _codex_event_image_result_lengths(value: Any) -> list[int]:
|
| 470 |
+
if isinstance(value, dict):
|
| 471 |
+
lengths: list[int] = []
|
| 472 |
+
if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
|
| 473 |
+
lengths.append(len(value["result"]))
|
| 474 |
+
for item in value.values():
|
| 475 |
+
lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
|
| 476 |
+
return lengths
|
| 477 |
+
if isinstance(value, list):
|
| 478 |
+
lengths: list[int] = []
|
| 479 |
+
for item in value:
|
| 480 |
+
lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(item))
|
| 481 |
+
return lengths
|
| 482 |
+
return []
|
| 483 |
+
|
| 484 |
+
@staticmethod
|
| 485 |
+
def _codex_event_summary(event: Dict[str, Any]) -> Dict[str, Any]:
|
| 486 |
+
summary: Dict[str, Any] = {
|
| 487 |
+
"type": str(event.get("type") or ""),
|
| 488 |
+
"keys": list(event.keys())[:30],
|
| 489 |
+
}
|
| 490 |
+
for key in ("id", "status", "sequence_number", "response_id", "item_id", "output_index", "content_index"):
|
| 491 |
+
value = event.get(key)
|
| 492 |
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
| 493 |
+
summary[key] = value
|
| 494 |
+
for key in ("response", "item", "output"):
|
| 495 |
+
value = event.get(key)
|
| 496 |
+
if isinstance(value, dict):
|
| 497 |
+
summary[f"{key}_type"] = value.get("type")
|
| 498 |
+
summary[f"{key}_status"] = value.get("status")
|
| 499 |
+
summary[f"{key}_keys"] = list(value.keys())[:30]
|
| 500 |
+
elif isinstance(value, list):
|
| 501 |
+
summary[f"{key}_len"] = len(value)
|
| 502 |
+
summary[f"{key}_types"] = [
|
| 503 |
+
item.get("type") for item in value[:10] if isinstance(item, dict)
|
| 504 |
+
]
|
| 505 |
+
error = event.get("error")
|
| 506 |
+
if isinstance(error, dict):
|
| 507 |
+
summary["error"] = {
|
| 508 |
+
key: error.get(key)
|
| 509 |
+
for key in ("type", "code", "message")
|
| 510 |
+
if error.get(key) is not None
|
| 511 |
+
}
|
| 512 |
+
delta = event.get("delta")
|
| 513 |
+
if isinstance(delta, str):
|
| 514 |
+
summary["delta_len"] = len(delta)
|
| 515 |
+
summary["delta_preview"] = delta[:200]
|
| 516 |
+
result_lengths = OpenAIBackendAPI._codex_event_image_result_lengths(event)
|
| 517 |
+
if result_lengths:
|
| 518 |
+
summary["image_result_lengths"] = result_lengths[:10]
|
| 519 |
+
return summary
|
| 520 |
+
|
| 521 |
+
def _log_codex_response_failure(
|
| 522 |
+
self,
|
| 523 |
+
path: str,
|
| 524 |
+
status_code: int,
|
| 525 |
+
headers: Any,
|
| 526 |
+
payload: Dict[str, Any],
|
| 527 |
+
body: Any,
|
| 528 |
+
) -> None:
|
| 529 |
+
request_headers = self._codex_responses_headers()
|
| 530 |
+
safe_request_headers = {
|
| 531 |
+
key: value for key, value in request_headers.items() if key.lower() != "authorization"
|
| 532 |
+
}
|
| 533 |
+
response_headers = dict(headers.items()) if hasattr(headers, "items") else dict(headers or {})
|
| 534 |
+
tool = ((payload.get("tools") or [{}])[0]) if isinstance(payload.get("tools"), list) else {}
|
| 535 |
+
logger.warning({
|
| 536 |
+
"event": "codex_responses_http_error",
|
| 537 |
+
"path": path,
|
| 538 |
+
"status_code": status_code,
|
| 539 |
+
"request": {
|
| 540 |
+
"model": payload.get("model"),
|
| 541 |
+
"tool_model": tool.get("model"),
|
| 542 |
+
"tool_action": tool.get("action"),
|
| 543 |
+
"size": tool.get("size"),
|
| 544 |
+
"quality": tool.get("quality"),
|
| 545 |
+
"image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
|
| 546 |
+
"prompt_preview": self._codex_body_preview(
|
| 547 |
+
(((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
|
| 548 |
+
500,
|
| 549 |
+
),
|
| 550 |
+
"headers": safe_request_headers,
|
| 551 |
+
},
|
| 552 |
+
"response": {
|
| 553 |
+
"headers": response_headers,
|
| 554 |
+
"body_preview": self._codex_body_preview(body),
|
| 555 |
+
},
|
| 556 |
+
})
|
| 557 |
+
|
| 558 |
+
@staticmethod
|
| 559 |
+
def _iter_codex_response_events(raw: Any) -> Iterator[Dict[str, Any]]:
|
| 560 |
+
content_type = str(raw.headers.get("content-type") or "").lower()
|
| 561 |
+
text = raw.read().decode("utf-8", "replace")
|
| 562 |
+
status_code = getattr(raw, "status", None)
|
| 563 |
+
parse_errors: list[str] = []
|
| 564 |
+
events: list[Dict[str, Any]] = []
|
| 565 |
+
if "application/json" in content_type:
|
| 566 |
+
try:
|
| 567 |
+
data = json.loads(text)
|
| 568 |
+
if isinstance(data, dict):
|
| 569 |
+
events.append(data)
|
| 570 |
+
except Exception as exc:
|
| 571 |
+
parse_errors.append(str(exc))
|
| 572 |
+
else:
|
| 573 |
+
lines: list[str] = []
|
| 574 |
+
for line in text.splitlines() + [""]:
|
| 575 |
+
if not line:
|
| 576 |
+
if lines:
|
| 577 |
+
payload_text = "\n".join(lines).strip()
|
| 578 |
+
if payload_text and payload_text != "[DONE]":
|
| 579 |
+
try:
|
| 580 |
+
data = json.loads(payload_text)
|
| 581 |
+
except Exception as exc:
|
| 582 |
+
parse_errors.append(str(exc))
|
| 583 |
+
data = None
|
| 584 |
+
if isinstance(data, dict):
|
| 585 |
+
events.append(data)
|
| 586 |
+
lines = []
|
| 587 |
+
elif line.startswith("data:"):
|
| 588 |
+
lines.append(line[5:].lstrip())
|
| 589 |
+
|
| 590 |
+
event_types: Dict[str, int] = {}
|
| 591 |
+
image_result_lengths: list[int] = []
|
| 592 |
+
for event in events:
|
| 593 |
+
event_type = str(event.get("type") or "<missing>")
|
| 594 |
+
event_types[event_type] = event_types.get(event_type, 0) + 1
|
| 595 |
+
image_result_lengths.extend(OpenAIBackendAPI._codex_event_image_result_lengths(event))
|
| 596 |
+
logger.info({
|
| 597 |
+
"event": "codex_responses_response_debug",
|
| 598 |
+
"status_code": status_code,
|
| 599 |
+
"content_type": content_type,
|
| 600 |
+
"response_text_len": len(text),
|
| 601 |
+
"event_count": len(events),
|
| 602 |
+
"event_types": event_types,
|
| 603 |
+
"image_result_lengths": image_result_lengths[:10],
|
| 604 |
+
"parse_error_count": len(parse_errors),
|
| 605 |
+
"parse_errors": parse_errors[:5],
|
| 606 |
+
"event_summaries": [OpenAIBackendAPI._codex_event_summary(event) for event in events[:30]],
|
| 607 |
+
"event_previews": [
|
| 608 |
+
OpenAIBackendAPI._codex_body_preview(event, 1500)
|
| 609 |
+
for event in events[:10]
|
| 610 |
+
] if not image_result_lengths else [],
|
| 611 |
+
"body_preview": text[:1000] if not events else "",
|
| 612 |
+
})
|
| 613 |
+
for event in events:
|
| 614 |
+
yield event
|
| 615 |
+
|
| 616 |
+
def iter_codex_image_response_events(
|
| 617 |
+
self,
|
| 618 |
+
prompt: str,
|
| 619 |
+
images: list[str] | None = None,
|
| 620 |
+
size: str | None = None,
|
| 621 |
+
quality: str = "auto",
|
| 622 |
+
) -> Iterator[Dict[str, Any]]:
|
| 623 |
+
if not self.access_token:
|
| 624 |
+
raise RuntimeError("access_token is required for codex image endpoints")
|
| 625 |
+
self._ensure_codex_source_account()
|
| 626 |
+
path = "/backend-api/codex/responses"
|
| 627 |
+
payload = {
|
| 628 |
+
"model": CODEX_RESPONSES_MODEL,
|
| 629 |
+
"instructions": CODEX_RESPONSES_INSTRUCTIONS,
|
| 630 |
+
"store": False,
|
| 631 |
+
"input": self._codex_image_input(prompt, images or []),
|
| 632 |
+
"tools": [{
|
| 633 |
+
"type": "image_generation",
|
| 634 |
+
"model": "gpt-image-2",
|
| 635 |
+
"action": "edit" if images else "generate",
|
| 636 |
+
"size": str(size or "1024x1024"),
|
| 637 |
+
"quality": str(quality or "auto"),
|
| 638 |
+
"output_format": "png",
|
| 639 |
+
}],
|
| 640 |
+
"tool_choice": {"type": "image_generation"},
|
| 641 |
+
"stream": True,
|
| 642 |
+
}
|
| 643 |
+
request = urllib.request.Request(
|
| 644 |
+
self.base_url + path,
|
| 645 |
+
json.dumps(payload).encode(),
|
| 646 |
+
self._codex_responses_headers(),
|
| 647 |
+
method="POST",
|
| 648 |
+
)
|
| 649 |
+
account = account_service.get_account(self.access_token) or {}
|
| 650 |
+
token_payload = account_service._decode_jwt_payload(self.access_token)
|
| 651 |
+
auth_claim = token_payload.get("https://api.openai.com/auth")
|
| 652 |
+
auth_claim = auth_claim if isinstance(auth_claim, dict) else {}
|
| 653 |
+
tool = payload["tools"][0]
|
| 654 |
+
logger.info({
|
| 655 |
+
"event": "codex_responses_request_debug",
|
| 656 |
+
"url": self.base_url + path,
|
| 657 |
+
"transport": "urllib.request",
|
| 658 |
+
"timeout_secs": 1200,
|
| 659 |
+
"account_email": str(account.get("email") or "").strip(),
|
| 660 |
+
"source_type": str(account.get("source_type") or "").strip(),
|
| 661 |
+
"account_type": str(account.get("type") or "").strip(),
|
| 662 |
+
"token_claims": {
|
| 663 |
+
"jti": token_payload.get("jti"),
|
| 664 |
+
"iat": token_payload.get("iat"),
|
| 665 |
+
"exp": token_payload.get("exp"),
|
| 666 |
+
"client_id": token_payload.get("client_id"),
|
| 667 |
+
"chatgpt_account_id": auth_claim.get("chatgpt_account_id"),
|
| 668 |
+
"chatgpt_plan_type": auth_claim.get("chatgpt_plan_type"),
|
| 669 |
+
"localhost": auth_claim.get("localhost"),
|
| 670 |
+
},
|
| 671 |
+
"request": {
|
| 672 |
+
"model": payload.get("model"),
|
| 673 |
+
"tool_model": tool.get("model"),
|
| 674 |
+
"tool_action": tool.get("action"),
|
| 675 |
+
"size": tool.get("size"),
|
| 676 |
+
"quality": tool.get("quality"),
|
| 677 |
+
"output_format": tool.get("output_format"),
|
| 678 |
+
"stream": payload.get("stream"),
|
| 679 |
+
"image_input_count": max(len((payload.get("input") or [{}])[0].get("content") or []) - 1, 0),
|
| 680 |
+
"prompt_preview": self._codex_body_preview(
|
| 681 |
+
(((payload.get("input") or [{}])[0].get("content") or [{}])[0].get("text") or ""),
|
| 682 |
+
500,
|
| 683 |
+
),
|
| 684 |
+
},
|
| 685 |
+
"headers": {
|
| 686 |
+
key: value for key, value in self._codex_responses_headers().items()
|
| 687 |
+
if key.lower() != "authorization"
|
| 688 |
+
},
|
| 689 |
+
})
|
| 690 |
+
try:
|
| 691 |
+
with urllib.request.urlopen(request, timeout=1200) as raw:
|
| 692 |
+
yield from self._iter_codex_response_events(raw)
|
| 693 |
+
except urllib.error.HTTPError as error:
|
| 694 |
+
body_text = error.read().decode("utf-8", "replace")
|
| 695 |
+
body: Any = body_text
|
| 696 |
+
try:
|
| 697 |
+
body = json.loads(body_text)
|
| 698 |
+
except Exception:
|
| 699 |
+
pass
|
| 700 |
+
self._log_codex_response_failure(path, error.code, error.headers, payload, body)
|
| 701 |
+
retry_after_header = error.headers.get("Retry-After") if error.headers else None
|
| 702 |
+
retry_after = int(retry_after_header) if str(retry_after_header or "").isdigit() else None
|
| 703 |
+
raise UpstreamHTTPError(path, error.code, body, retry_after=retry_after) from error
|
| 704 |
+
|
| 705 |
+
def _prepare_image_conversation(self, prompt: str, requirements: ChatRequirements, model: str) -> str:
|
| 706 |
+
"""为图片生成准备 conduit token。"""
|
| 707 |
+
path = "/backend-api/f/conversation/prepare"
|
| 708 |
+
payload = {
|
| 709 |
+
"action": "next",
|
| 710 |
+
"fork_from_shared_post": False,
|
| 711 |
+
"parent_message_id": new_uuid(),
|
| 712 |
+
"model": self._image_model_slug(model),
|
| 713 |
+
"client_prepare_state": "success",
|
| 714 |
+
"timezone_offset_min": -480,
|
| 715 |
+
"timezone": "Asia/Shanghai",
|
| 716 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 717 |
+
"system_hints": ["picture_v2"],
|
| 718 |
+
"partial_query": {
|
| 719 |
+
"id": new_uuid(),
|
| 720 |
+
"author": {"role": "user"},
|
| 721 |
+
"content": {"content_type": "text", "parts": [prompt]},
|
| 722 |
+
},
|
| 723 |
+
"supports_buffering": True,
|
| 724 |
+
"supported_encodings": ["v1"],
|
| 725 |
+
"client_contextual_info": {"app_name": "chatgpt.com"},
|
| 726 |
+
}
|
| 727 |
+
response = self.session.post(
|
| 728 |
+
self.base_url + path,
|
| 729 |
+
headers=self._image_headers(path, requirements),
|
| 730 |
+
json=payload,
|
| 731 |
+
timeout=60,
|
| 732 |
+
)
|
| 733 |
+
ensure_ok(response, path)
|
| 734 |
+
return response.json().get("conduit_token", "")
|
| 735 |
+
|
| 736 |
+
def _decode_image_base64(self, image: str) -> bytes:
|
| 737 |
+
"""把 base64 图片字符串或本地路径解码成二进制。"""
|
| 738 |
+
if (
|
| 739 |
+
image
|
| 740 |
+
and len(image) < 512
|
| 741 |
+
and not image.startswith("data:")
|
| 742 |
+
and "\n" not in image
|
| 743 |
+
and "\r" not in image
|
| 744 |
+
):
|
| 745 |
+
file_path = Path(os.path.expanduser(image))
|
| 746 |
+
if file_path.exists() and file_path.is_file():
|
| 747 |
+
return file_path.read_bytes()
|
| 748 |
+
payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
|
| 749 |
+
return base64.b64decode(payload)
|
| 750 |
+
|
| 751 |
+
def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
|
| 752 |
+
"""上传一张 base64 图片,返回底层文件元数据。"""
|
| 753 |
+
data = self._decode_image_base64(image)
|
| 754 |
+
if (
|
| 755 |
+
image
|
| 756 |
+
and len(image) < 512
|
| 757 |
+
and not image.startswith("data:")
|
| 758 |
+
and "\n" not in image
|
| 759 |
+
and "\r" not in image
|
| 760 |
+
):
|
| 761 |
+
candidate_path = Path(os.path.expanduser(image))
|
| 762 |
+
if candidate_path.exists() and candidate_path.is_file():
|
| 763 |
+
file_name = candidate_path.name
|
| 764 |
+
image = Image.open(BytesIO(data))
|
| 765 |
+
width, height = image.size
|
| 766 |
+
mime_type = Image.MIME.get(image.format, "image/png")
|
| 767 |
+
path = "/backend-api/files"
|
| 768 |
+
response = self.session.post(
|
| 769 |
+
self.base_url + path,
|
| 770 |
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
| 771 |
+
json={"file_name": file_name, "file_size": len(data), "use_case": "multimodal", "width": width,
|
| 772 |
+
"height": height},
|
| 773 |
+
timeout=60,
|
| 774 |
+
)
|
| 775 |
+
ensure_ok(response, path)
|
| 776 |
+
upload_meta = response.json()
|
| 777 |
+
time.sleep(0.5)
|
| 778 |
+
response = self.session.put(
|
| 779 |
+
upload_meta["upload_url"],
|
| 780 |
+
headers={
|
| 781 |
+
"Content-Type": mime_type,
|
| 782 |
+
"x-ms-blob-type": "BlockBlob",
|
| 783 |
+
"x-ms-version": "2020-04-08",
|
| 784 |
+
"Origin": self.base_url,
|
| 785 |
+
"Referer": self.base_url + "/",
|
| 786 |
+
"User-Agent": self.user_agent,
|
| 787 |
+
"Accept": "application/json, text/plain, */*",
|
| 788 |
+
"Accept-Language": "en-US,en;q=0.8",
|
| 789 |
+
},
|
| 790 |
+
data=data,
|
| 791 |
+
timeout=120,
|
| 792 |
+
)
|
| 793 |
+
ensure_ok(response, "image_upload")
|
| 794 |
+
path = f"/backend-api/files/{upload_meta['file_id']}/uploaded"
|
| 795 |
+
response = self.session.post(
|
| 796 |
+
self.base_url + path,
|
| 797 |
+
headers=self._headers(path, {"Content-Type": "application/json", "Accept": "application/json"}),
|
| 798 |
+
data="{}",
|
| 799 |
+
timeout=60,
|
| 800 |
+
)
|
| 801 |
+
ensure_ok(response, path)
|
| 802 |
+
return {
|
| 803 |
+
"file_id": upload_meta["file_id"],
|
| 804 |
+
"file_name": file_name,
|
| 805 |
+
"file_size": len(data),
|
| 806 |
+
"mime_type": mime_type,
|
| 807 |
+
"width": width,
|
| 808 |
+
"height": height,
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
def _start_image_generation(self, prompt: str, requirements: ChatRequirements, conduit_token: str, model: str,
|
| 812 |
+
references: Optional[list[Dict[str, Any]]] = None) -> requests.Response:
|
| 813 |
+
"""启动图片生成或编辑的 SSE 请求。"""
|
| 814 |
+
references = references or []
|
| 815 |
+
parts = [{
|
| 816 |
+
"content_type": "image_asset_pointer",
|
| 817 |
+
"asset_pointer": f"file-service://{item['file_id']}",
|
| 818 |
+
"width": item["width"],
|
| 819 |
+
"height": item["height"],
|
| 820 |
+
"size_bytes": item["file_size"],
|
| 821 |
+
} for item in references]
|
| 822 |
+
parts.append(prompt)
|
| 823 |
+
content = {"content_type": "multimodal_text", "parts": parts} if references else {"content_type": "text",
|
| 824 |
+
"parts": [prompt]}
|
| 825 |
+
metadata = {
|
| 826 |
+
"developer_mode_connector_ids": [],
|
| 827 |
+
"selected_github_repos": [],
|
| 828 |
+
"selected_all_github_repos": False,
|
| 829 |
+
"system_hints": ["picture_v2"],
|
| 830 |
+
"serialization_metadata": {"custom_symbol_offsets": []},
|
| 831 |
+
}
|
| 832 |
+
if references:
|
| 833 |
+
metadata["attachments"] = [{
|
| 834 |
+
"id": item["file_id"],
|
| 835 |
+
"mimeType": item["mime_type"],
|
| 836 |
+
"name": item["file_name"],
|
| 837 |
+
"size": item["file_size"],
|
| 838 |
+
"width": item["width"],
|
| 839 |
+
"height": item["height"],
|
| 840 |
+
} for item in references]
|
| 841 |
+
payload = {
|
| 842 |
+
"action": "next",
|
| 843 |
+
"messages": [{
|
| 844 |
+
"id": new_uuid(),
|
| 845 |
+
"author": {"role": "user"},
|
| 846 |
+
"create_time": time.time(),
|
| 847 |
+
"content": content,
|
| 848 |
+
"metadata": metadata,
|
| 849 |
+
}],
|
| 850 |
+
"parent_message_id": new_uuid(),
|
| 851 |
+
"model": self._image_model_slug(model),
|
| 852 |
+
"client_prepare_state": "sent",
|
| 853 |
+
"timezone_offset_min": -480,
|
| 854 |
+
"timezone": "Asia/Shanghai",
|
| 855 |
+
"conversation_mode": {"kind": "primary_assistant"},
|
| 856 |
+
"enable_message_followups": True,
|
| 857 |
+
"system_hints": ["picture_v2"],
|
| 858 |
+
"supports_buffering": True,
|
| 859 |
+
"supported_encodings": ["v1"],
|
| 860 |
+
"client_contextual_info": {
|
| 861 |
+
"is_dark_mode": False,
|
| 862 |
+
"time_since_loaded": 1200,
|
| 863 |
+
"page_height": 1072,
|
| 864 |
+
"page_width": 1724,
|
| 865 |
+
"pixel_ratio": 1.2,
|
| 866 |
+
"screen_height": 1440,
|
| 867 |
+
"screen_width": 2560,
|
| 868 |
+
"app_name": "chatgpt.com",
|
| 869 |
+
},
|
| 870 |
+
"paragen_cot_summary_display_override": "allow",
|
| 871 |
+
"force_parallel_switch": "auto",
|
| 872 |
+
}
|
| 873 |
+
path = "/backend-api/f/conversation"
|
| 874 |
+
response = self.session.post(
|
| 875 |
+
self.base_url + path,
|
| 876 |
+
headers=self._image_headers(path, requirements, conduit_token, "text/event-stream"),
|
| 877 |
+
json=payload,
|
| 878 |
+
timeout=300,
|
| 879 |
+
stream=True,
|
| 880 |
+
)
|
| 881 |
+
ensure_ok(response, path)
|
| 882 |
+
return response
|
| 883 |
+
|
| 884 |
+
def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
| 885 |
+
"""获取完整 conversation 详情。"""
|
| 886 |
+
path = f"/backend-api/conversation/{conversation_id}"
|
| 887 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 888 |
+
timeout=60)
|
| 889 |
+
ensure_ok(response, path)
|
| 890 |
+
return response.json()
|
| 891 |
+
|
| 892 |
+
def _extract_image_tool_records(self, data: Dict[str, Any]) -> list[Dict[str, Any]]:
|
| 893 |
+
"""从 conversation 明细里提取图片工具输出记录。"""
|
| 894 |
+
mapping = data.get("mapping") or {}
|
| 895 |
+
file_pat = re.compile(r"file-service://([A-Za-z0-9_-]+)")
|
| 896 |
+
sed_pat = re.compile(r"sediment://([A-Za-z0-9_-]+)")
|
| 897 |
+
records = []
|
| 898 |
+
for message_id, node in mapping.items():
|
| 899 |
+
message = (node or {}).get("message") or {}
|
| 900 |
+
author = message.get("author") or {}
|
| 901 |
+
metadata = message.get("metadata") or {}
|
| 902 |
+
content = message.get("content") or {}
|
| 903 |
+
if author.get("role") != "tool":
|
| 904 |
+
continue
|
| 905 |
+
if content.get("content_type") != "multimodal_text":
|
| 906 |
+
continue
|
| 907 |
+
file_ids, sediment_ids = [], []
|
| 908 |
+
for part in content.get("parts") or []:
|
| 909 |
+
text = (part.get("asset_pointer") or "") if isinstance(part, dict) else (
|
| 910 |
+
part if isinstance(part, str) else "")
|
| 911 |
+
for hit in file_pat.findall(text):
|
| 912 |
+
if hit not in file_ids:
|
| 913 |
+
file_ids.append(hit)
|
| 914 |
+
for hit in sed_pat.findall(text):
|
| 915 |
+
if hit not in sediment_ids:
|
| 916 |
+
sediment_ids.append(hit)
|
| 917 |
+
if metadata.get("async_task_type") != "image_gen" and not file_ids and not sediment_ids:
|
| 918 |
+
continue
|
| 919 |
+
records.append(
|
| 920 |
+
{"message_id": message_id, "create_time": message.get("create_time") or 0, "file_ids": file_ids,
|
| 921 |
+
"sediment_ids": sediment_ids})
|
| 922 |
+
return sorted(records, key=lambda item: item["create_time"])
|
| 923 |
+
|
| 924 |
+
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 925 |
+
"""Poll the conversation document until image file ids appear or budget runs out.
|
| 926 |
+
|
| 927 |
+
- Sleeps image_poll_initial_wait_secs first (default 10s, +jitter). ChatGPT
|
| 928 |
+
image generation takes ~30s; polling immediately wastes requests and trips
|
| 929 |
+
a transient 429 the upstream returns within ~200ms of the SSE stream
|
| 930 |
+
closing (the conversation document is not yet committed).
|
| 931 |
+
- Subsequent polls are image_poll_interval_secs apart (default 10s).
|
| 932 |
+
- On upstream 429 / 5xx or network errors, backs off exponentially
|
| 933 |
+
(capped at 16s, +jitter) honoring Retry-After when present.
|
| 934 |
+
- All sleeps stay within timeout_secs; on exhaustion raises ImagePollTimeoutError.
|
| 935 |
+
"""
|
| 936 |
+
start = time.time()
|
| 937 |
+
attempt = 0
|
| 938 |
+
interval = float(config.image_poll_interval_secs)
|
| 939 |
+
initial_wait = float(config.image_poll_initial_wait_secs)
|
| 940 |
+
logger.info({
|
| 941 |
+
"event": "image_poll_start",
|
| 942 |
+
"conversation_id": conversation_id,
|
| 943 |
+
"timeout_secs": timeout_secs,
|
| 944 |
+
"initial_wait_secs": initial_wait,
|
| 945 |
+
"interval_secs": interval,
|
| 946 |
+
})
|
| 947 |
+
|
| 948 |
+
def _remaining() -> float:
|
| 949 |
+
return timeout_secs - (time.time() - start)
|
| 950 |
+
|
| 951 |
+
if initial_wait > 0:
|
| 952 |
+
jitter = random.uniform(0, min(2.0, initial_wait * 0.2))
|
| 953 |
+
sleep_for = min(initial_wait + jitter, max(0.0, _remaining()))
|
| 954 |
+
if sleep_for > 0:
|
| 955 |
+
time.sleep(sleep_for)
|
| 956 |
+
|
| 957 |
+
def _retry_sleep(reason: str, status_code: int | None, error: str | None, retry_after: int | None) -> bool:
|
| 958 |
+
# retry_after=0 means "retry immediately" — must not be coerced via falsy check.
|
| 959 |
+
base = retry_after if retry_after is not None else min(2 ** min(attempt, 4), 16)
|
| 960 |
+
backoff = base + random.uniform(0, 0.5)
|
| 961 |
+
remaining = _remaining()
|
| 962 |
+
if remaining <= 0:
|
| 963 |
+
return False
|
| 964 |
+
sleep_for = min(backoff, remaining)
|
| 965 |
+
log_payload: Dict[str, Any] = {
|
| 966 |
+
"event": "image_poll_retry",
|
| 967 |
+
"conversation_id": conversation_id,
|
| 968 |
+
"attempt": attempt,
|
| 969 |
+
"reason": reason,
|
| 970 |
+
"sleep_secs": round(sleep_for, 2),
|
| 971 |
+
}
|
| 972 |
+
if status_code is not None:
|
| 973 |
+
log_payload["status_code"] = status_code
|
| 974 |
+
if error is not None:
|
| 975 |
+
log_payload["error"] = error
|
| 976 |
+
logger.warning(log_payload)
|
| 977 |
+
time.sleep(sleep_for)
|
| 978 |
+
return True
|
| 979 |
+
|
| 980 |
+
while _remaining() > 0:
|
| 981 |
+
attempt += 1
|
| 982 |
+
try:
|
| 983 |
+
conversation = self._get_conversation(conversation_id)
|
| 984 |
+
except UpstreamHTTPError as exc:
|
| 985 |
+
if exc.status_code in (429, 500, 502, 503, 504):
|
| 986 |
+
if _retry_sleep("upstream_status", exc.status_code, None, exc.retry_after):
|
| 987 |
+
continue
|
| 988 |
+
break
|
| 989 |
+
raise
|
| 990 |
+
except requests.exceptions.RequestException as exc:
|
| 991 |
+
if _retry_sleep("network", None, str(exc), None):
|
| 992 |
+
continue
|
| 993 |
+
break
|
| 994 |
+
|
| 995 |
+
file_ids, sediment_ids = [], []
|
| 996 |
+
for record in self._extract_image_tool_records(conversation):
|
| 997 |
+
for file_id in record["file_ids"]:
|
| 998 |
+
if file_id not in file_ids:
|
| 999 |
+
file_ids.append(file_id)
|
| 1000 |
+
for sediment_id in record["sediment_ids"]:
|
| 1001 |
+
if sediment_id not in sediment_ids:
|
| 1002 |
+
sediment_ids.append(sediment_id)
|
| 1003 |
+
logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt,
|
| 1004 |
+
"file_ids": file_ids, "sediment_ids": sediment_ids})
|
| 1005 |
+
if file_ids:
|
| 1006 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids,
|
| 1007 |
+
"sediment_ids": sediment_ids})
|
| 1008 |
+
return file_ids, sediment_ids
|
| 1009 |
+
if sediment_ids:
|
| 1010 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [],
|
| 1011 |
+
"sediment_ids": sediment_ids})
|
| 1012 |
+
return [], sediment_ids
|
| 1013 |
+
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id,
|
| 1014 |
+
"elapsed_secs": round(time.time() - start, 1)})
|
| 1015 |
+
wait = min(interval, max(0.0, _remaining()))
|
| 1016 |
+
if wait > 0:
|
| 1017 |
+
time.sleep(wait)
|
| 1018 |
+
logger.info({
|
| 1019 |
+
"event": "image_poll_timeout",
|
| 1020 |
+
"conversation_id": conversation_id,
|
| 1021 |
+
"timeout_secs": timeout_secs,
|
| 1022 |
+
"attempts_made": attempt,
|
| 1023 |
+
# attempts_made == 0 means the initial_wait consumed the entire budget — no HTTP attempted.
|
| 1024 |
+
"initial_wait_exhausted_budget": attempt == 0,
|
| 1025 |
+
})
|
| 1026 |
+
raise ImagePollTimeoutError(
|
| 1027 |
+
f"ChatGPT 生图超时(已等待 {timeout_secs} 秒)。"
|
| 1028 |
+
f"当前超时阈值可在 config.json 中调大 image_poll_timeout_secs,"
|
| 1029 |
+
f"也可能是账号被限流或生图队列拥堵导致。"
|
| 1030 |
+
)
|
| 1031 |
+
|
| 1032 |
+
def _get_file_download_url(self, file_id: str) -> str:
|
| 1033 |
+
"""获取文件下载地址。"""
|
| 1034 |
+
path = f"/backend-api/files/{file_id}/download"
|
| 1035 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 1036 |
+
timeout=60)
|
| 1037 |
+
ensure_ok(response, path)
|
| 1038 |
+
data = response.json()
|
| 1039 |
+
return data.get("download_url") or data.get("url") or ""
|
| 1040 |
+
|
| 1041 |
+
def _get_attachment_download_url(self, conversation_id: str, attachment_id: str) -> str:
|
| 1042 |
+
"""通过 conversation 附件接口获取下载地址。"""
|
| 1043 |
+
path = f"/backend-api/conversation/{conversation_id}/attachment/{attachment_id}/download"
|
| 1044 |
+
response = self.session.get(self.base_url + path, headers=self._headers(path, {"Accept": "application/json"}),
|
| 1045 |
+
timeout=60)
|
| 1046 |
+
ensure_ok(response, path)
|
| 1047 |
+
data = response.json()
|
| 1048 |
+
return data.get("download_url") or data.get("url") or ""
|
| 1049 |
+
|
| 1050 |
+
def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
|
| 1051 |
+
"""把图片结果 id 解析成可下载 URL。"""
|
| 1052 |
+
urls = []
|
| 1053 |
+
skip_patterns = {"file_upload"}
|
| 1054 |
+
for file_id in file_ids:
|
| 1055 |
+
if file_id in skip_patterns:
|
| 1056 |
+
logger.debug({
|
| 1057 |
+
"event": "image_file_id_skipped",
|
| 1058 |
+
"source": "file",
|
| 1059 |
+
"conversation_id": conversation_id,
|
| 1060 |
+
"id": file_id,
|
| 1061 |
+
})
|
| 1062 |
+
continue
|
| 1063 |
+
try:
|
| 1064 |
+
url = self._get_file_download_url(file_id)
|
| 1065 |
+
except Exception as exc:
|
| 1066 |
+
logger.debug({
|
| 1067 |
+
"event": "image_download_url_failed",
|
| 1068 |
+
"source": "file",
|
| 1069 |
+
"conversation_id": conversation_id,
|
| 1070 |
+
"id": file_id,
|
| 1071 |
+
"error": repr(exc),
|
| 1072 |
+
})
|
| 1073 |
+
continue
|
| 1074 |
+
if url:
|
| 1075 |
+
urls.append(url)
|
| 1076 |
+
else:
|
| 1077 |
+
logger.debug({
|
| 1078 |
+
"event": "image_download_url_empty",
|
| 1079 |
+
"source": "file",
|
| 1080 |
+
"conversation_id": conversation_id,
|
| 1081 |
+
"id": file_id,
|
| 1082 |
+
})
|
| 1083 |
+
if urls or not conversation_id:
|
| 1084 |
+
logger.debug({
|
| 1085 |
+
"event": "image_urls_resolved",
|
| 1086 |
+
"conversation_id": conversation_id,
|
| 1087 |
+
"file_ids": file_ids,
|
| 1088 |
+
"sediment_ids": sediment_ids,
|
| 1089 |
+
"urls": urls,
|
| 1090 |
+
})
|
| 1091 |
+
return urls
|
| 1092 |
+
for sediment_id in sediment_ids:
|
| 1093 |
+
try:
|
| 1094 |
+
url = self._get_attachment_download_url(conversation_id, sediment_id)
|
| 1095 |
+
except Exception as exc:
|
| 1096 |
+
logger.debug({
|
| 1097 |
+
"event": "image_download_url_failed",
|
| 1098 |
+
"source": "sediment",
|
| 1099 |
+
"conversation_id": conversation_id,
|
| 1100 |
+
"id": sediment_id,
|
| 1101 |
+
"error": repr(exc),
|
| 1102 |
+
})
|
| 1103 |
+
continue
|
| 1104 |
+
if url:
|
| 1105 |
+
urls.append(url)
|
| 1106 |
+
else:
|
| 1107 |
+
logger.debug({
|
| 1108 |
+
"event": "image_download_url_empty",
|
| 1109 |
+
"source": "sediment",
|
| 1110 |
+
"conversation_id": conversation_id,
|
| 1111 |
+
"id": sediment_id,
|
| 1112 |
+
})
|
| 1113 |
+
logger.debug({
|
| 1114 |
+
"event": "image_urls_resolved",
|
| 1115 |
+
"conversation_id": conversation_id,
|
| 1116 |
+
"file_ids": file_ids,
|
| 1117 |
+
"sediment_ids": sediment_ids,
|
| 1118 |
+
"urls": urls,
|
| 1119 |
+
})
|
| 1120 |
+
return urls
|
| 1121 |
+
|
| 1122 |
+
def resolve_conversation_image_urls(
|
| 1123 |
+
self,
|
| 1124 |
+
conversation_id: str,
|
| 1125 |
+
file_ids: list[str],
|
| 1126 |
+
sediment_ids: list[str],
|
| 1127 |
+
poll: bool = True,
|
| 1128 |
+
) -> list[str]:
|
| 1129 |
+
file_ids = [item for item in file_ids if item != "file_upload"]
|
| 1130 |
+
sediment_ids = list(sediment_ids)
|
| 1131 |
+
if poll and conversation_id and not file_ids and not sediment_ids:
|
| 1132 |
+
logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
|
| 1133 |
+
polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id,
|
| 1134 |
+
config.image_poll_timeout_secs)
|
| 1135 |
+
file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
|
| 1136 |
+
sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
|
| 1137 |
+
return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
| 1138 |
+
|
| 1139 |
+
def download_image_bytes(self, urls: list[str]) -> list[bytes]:
|
| 1140 |
+
images = []
|
| 1141 |
+
for url in urls:
|
| 1142 |
+
response = self.session.get(url, timeout=120)
|
| 1143 |
+
ensure_ok(response, "image_download")
|
| 1144 |
+
images.append(response.content)
|
| 1145 |
+
return images
|
| 1146 |
+
|
| 1147 |
+
def stream_conversation(
|
| 1148 |
+
self,
|
| 1149 |
+
messages: Optional[list[Dict[str, Any]]] = None,
|
| 1150 |
+
model: str = "auto",
|
| 1151 |
+
prompt: str = "",
|
| 1152 |
+
images: Optional[list[str]] = None,
|
| 1153 |
+
system_hints: Optional[list[str]] = None,
|
| 1154 |
+
) -> Iterator[str]:
|
| 1155 |
+
system_hints = system_hints or []
|
| 1156 |
+
if "picture_v2" in system_hints:
|
| 1157 |
+
yield from self._stream_picture_conversation(prompt, model, images or [])
|
| 1158 |
+
return
|
| 1159 |
+
|
| 1160 |
+
normalized = messages or [{"role": "user", "content": prompt}]
|
| 1161 |
+
self._bootstrap()
|
| 1162 |
+
requirements = self._get_chat_requirements()
|
| 1163 |
+
path, timezone = self._chat_target()
|
| 1164 |
+
payload = self._conversation_payload(normalized, model, timezone)
|
| 1165 |
+
response = self.session.post(
|
| 1166 |
+
self.base_url + path,
|
| 1167 |
+
headers=self._conversation_headers(path, requirements),
|
| 1168 |
+
json=payload,
|
| 1169 |
+
timeout=300,
|
| 1170 |
+
stream=True,
|
| 1171 |
+
)
|
| 1172 |
+
ensure_ok(response, path)
|
| 1173 |
+
try:
|
| 1174 |
+
yield from iter_sse_payloads(response)
|
| 1175 |
+
finally:
|
| 1176 |
+
response.close()
|
| 1177 |
+
|
| 1178 |
+
def _stream_picture_conversation(
|
| 1179 |
+
self,
|
| 1180 |
+
prompt: str,
|
| 1181 |
+
model: str,
|
| 1182 |
+
images: list[str],
|
| 1183 |
+
) -> Iterator[str]:
|
| 1184 |
+
if not self.access_token:
|
| 1185 |
+
raise RuntimeError("access_token is required for image endpoints")
|
| 1186 |
+
references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
|
| 1187 |
+
self._bootstrap()
|
| 1188 |
+
requirements = self._get_chat_requirements()
|
| 1189 |
+
conduit_token = self._prepare_image_conversation(prompt, requirements, model)
|
| 1190 |
+
response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
|
| 1191 |
+
try:
|
| 1192 |
+
yield from iter_sse_payloads(response)
|
| 1193 |
+
finally:
|
| 1194 |
+
response.close()
|
| 1195 |
+
|
| 1196 |
+
def _bootstrap(self) -> None:
|
| 1197 |
+
"""预热首页,并提取 PoW 相关脚本引用。"""
|
| 1198 |
+
response = self.session.get(
|
| 1199 |
+
self.base_url + "/",
|
| 1200 |
+
headers=self._bootstrap_headers(),
|
| 1201 |
+
timeout=30,
|
| 1202 |
+
)
|
| 1203 |
+
ensure_ok(response, "bootstrap")
|
| 1204 |
+
self.pow_script_sources, self.pow_data_build = parse_pow_resources(response.text)
|
| 1205 |
+
if not self.pow_script_sources:
|
| 1206 |
+
self.pow_script_sources = [DEFAULT_POW_SCRIPT]
|
| 1207 |
+
|
| 1208 |
+
def _get_chat_requirements(self) -> ChatRequirements:
|
| 1209 |
+
"""获取当前模式对话所需的 sentinel token。"""
|
| 1210 |
+
path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
|
| 1211 |
+
context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
|
| 1212 |
+
body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
|
| 1213 |
+
response = self.session.post(
|
| 1214 |
+
self.base_url + path,
|
| 1215 |
+
headers=self._headers(path, {"Content-Type": "application/json"}),
|
| 1216 |
+
json=body,
|
| 1217 |
+
timeout=30,
|
| 1218 |
+
)
|
| 1219 |
+
ensure_ok(response, context)
|
| 1220 |
+
requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
|
| 1221 |
+
if not requirements.token:
|
| 1222 |
+
message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
|
| 1223 |
+
raise RuntimeError(f"{message}: {requirements.raw_finalize}")
|
| 1224 |
+
return requirements
|
| 1225 |
+
|
| 1226 |
+
def _chat_target(self) -> tuple[str, str]:
|
| 1227 |
+
if self.access_token:
|
| 1228 |
+
return "/backend-api/conversation", "Asia/Shanghai"
|
| 1229 |
+
return "/backend-anon/conversation", "America/Los_Angeles"
|
| 1230 |
+
|
| 1231 |
+
def list_models(self) -> Dict[str, Any]:
|
| 1232 |
+
"""返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
|
| 1233 |
+
self._bootstrap()
|
| 1234 |
+
path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
|
| 1235 |
+
"/backend-anon/models?iim=false&is_gizmo=false"
|
| 1236 |
+
)
|
| 1237 |
+
route = "/backend-api/models" if self.access_token else "/backend-anon/models"
|
| 1238 |
+
context = "auth_models" if self.access_token else "anon_models"
|
| 1239 |
+
response = self.session.get(
|
| 1240 |
+
self.base_url + path,
|
| 1241 |
+
headers=self._headers(route),
|
| 1242 |
+
timeout=30,
|
| 1243 |
+
)
|
| 1244 |
+
ensure_ok(response, context)
|
| 1245 |
+
data = []
|
| 1246 |
+
seen = set()
|
| 1247 |
+
for item in response.json().get("models", []):
|
| 1248 |
+
if not isinstance(item, dict):
|
| 1249 |
+
continue
|
| 1250 |
+
slug = str(item.get("slug", "")).strip()
|
| 1251 |
+
if not slug or slug in seen:
|
| 1252 |
+
continue
|
| 1253 |
+
seen.add(slug)
|
| 1254 |
+
data.append({
|
| 1255 |
+
"id": slug,
|
| 1256 |
+
"object": "model",
|
| 1257 |
+
"created": int(item.get("created") or 0),
|
| 1258 |
+
"owned_by": str(item.get("owned_by") or "chatgpt"),
|
| 1259 |
+
"permission": [],
|
| 1260 |
+
"root": slug,
|
| 1261 |
+
"parent": None,
|
| 1262 |
+
})
|
| 1263 |
+
data.sort(key=lambda item: item["id"])
|
| 1264 |
+
return {"object": "list", "data": data}
|
services/protocol/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protocol converters for OpenAI-compatible endpoints."""
|
| 2 |
+
|
services/protocol/anthropic_v1_messages.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
from collections.abc import Callable, Iterable, Iterator
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from services.account_service import account_service
|
| 13 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 14 |
+
from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
|
| 15 |
+
from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
|
| 16 |
+
|
| 17 |
+
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
|
| 18 |
+
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class MessageRequest:
|
| 23 |
+
backend: OpenAIBackendAPI
|
| 24 |
+
messages: list[dict[str, Any]]
|
| 25 |
+
model: str
|
| 26 |
+
tools: Any = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
|
| 30 |
+
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
| 31 |
+
name = str(tool.get("name") or fn.get("name") or "").strip()
|
| 32 |
+
desc = str(tool.get("description") or fn.get("description") or "").strip()
|
| 33 |
+
schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
|
| 34 |
+
return name, desc, schema
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_tool_prompt(tools: object) -> str:
|
| 38 |
+
if not isinstance(tools, list):
|
| 39 |
+
return ""
|
| 40 |
+
blocks = []
|
| 41 |
+
for tool in tools:
|
| 42 |
+
if not isinstance(tool, dict):
|
| 43 |
+
continue
|
| 44 |
+
name, desc, schema = _tool_meta(tool)
|
| 45 |
+
if name:
|
| 46 |
+
blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
|
| 47 |
+
if not blocks:
|
| 48 |
+
return ""
|
| 49 |
+
return "Available tools:\n" + "\n".join(blocks) + """
|
| 50 |
+
|
| 51 |
+
Tool use rules:
|
| 52 |
+
- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
|
| 53 |
+
- To call tools, output ONLY XML and no prose/markdown:
|
| 54 |
+
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
|
| 55 |
+
- Put parameters under <parameters> using the exact schema names.
|
| 56 |
+
""".strip()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def merge_system(system: object, extra: str) -> object:
|
| 60 |
+
system = compact_system(system)
|
| 61 |
+
if _has_claude_code_system(system):
|
| 62 |
+
extra = XML_TOOL_RULE
|
| 63 |
+
if not extra:
|
| 64 |
+
return system
|
| 65 |
+
if isinstance(system, str) and system.strip():
|
| 66 |
+
return f"{system.strip()}\n\n{extra}"
|
| 67 |
+
if isinstance(system, list):
|
| 68 |
+
return [*system, {"type": "text", "text": extra}]
|
| 69 |
+
return extra
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _has_claude_code_system(system: object) -> bool:
|
| 73 |
+
if isinstance(system, str):
|
| 74 |
+
return "You are Claude Code" in system
|
| 75 |
+
if isinstance(system, list):
|
| 76 |
+
return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
|
| 77 |
+
return False
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def compact_system(system: object) -> object:
|
| 81 |
+
if isinstance(system, str):
|
| 82 |
+
return _compact_system_text(system)
|
| 83 |
+
if isinstance(system, list):
|
| 84 |
+
result = []
|
| 85 |
+
for item in system:
|
| 86 |
+
if isinstance(item, dict) and str(item.get("type") or "") == "text":
|
| 87 |
+
copied = dict(item)
|
| 88 |
+
copied["text"] = _compact_system_text(str(item.get("text") or ""))
|
| 89 |
+
result.append(copied)
|
| 90 |
+
else:
|
| 91 |
+
result.append(item)
|
| 92 |
+
return result
|
| 93 |
+
return system
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _compact_system_text(text: str) -> str:
|
| 97 |
+
return text or ""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _compact_message_text(text: str) -> str:
|
| 101 |
+
return text or ""
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
|
| 105 |
+
payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
|
| 106 |
+
payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
|
| 107 |
+
return payload
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def message_request(body: dict[str, Any]) -> MessageRequest:
|
| 111 |
+
payload = preprocess_payload(dict(body))
|
| 112 |
+
return MessageRequest(
|
| 113 |
+
backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
|
| 114 |
+
messages=normalize_messages(payload.get("messages"), payload.get("system")),
|
| 115 |
+
model=str(payload.get("model") or "auto").strip() or "auto",
|
| 116 |
+
tools=payload.get("tools"),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
|
| 121 |
+
if not isinstance(messages, list):
|
| 122 |
+
return messages
|
| 123 |
+
mapper = text_mapper or (lambda text: text)
|
| 124 |
+
result = []
|
| 125 |
+
for message in messages:
|
| 126 |
+
if not isinstance(message, dict):
|
| 127 |
+
continue
|
| 128 |
+
item = dict(message)
|
| 129 |
+
content = item.get("content")
|
| 130 |
+
if isinstance(content, str):
|
| 131 |
+
item["content"] = _compact_message_text(mapper(content))
|
| 132 |
+
elif isinstance(content, list):
|
| 133 |
+
item["content"] = [_preprocess_block(block, mapper) for block in content]
|
| 134 |
+
result.append(item)
|
| 135 |
+
return result
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
|
| 139 |
+
if not isinstance(block, dict):
|
| 140 |
+
return block
|
| 141 |
+
block_type = str(block.get("type") or "")
|
| 142 |
+
if block_type == "text":
|
| 143 |
+
item = dict(block)
|
| 144 |
+
item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
|
| 145 |
+
return item
|
| 146 |
+
if block_type == "tool_use":
|
| 147 |
+
return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
|
| 148 |
+
if block_type == "tool_result":
|
| 149 |
+
return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
|
| 150 |
+
return block
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
|
| 154 |
+
content, stop_reason = content_blocks(text, tools)
|
| 155 |
+
return {
|
| 156 |
+
"id": f"msg_{uuid.uuid4()}",
|
| 157 |
+
"type": "message",
|
| 158 |
+
"role": "assistant",
|
| 159 |
+
"model": model,
|
| 160 |
+
"content": content,
|
| 161 |
+
"stop_reason": stop_reason,
|
| 162 |
+
"stop_sequence": None,
|
| 163 |
+
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
|
| 168 |
+
calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
|
| 169 |
+
text = strip_tool_markup(text)
|
| 170 |
+
if calls:
|
| 171 |
+
content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
|
| 172 |
+
return content, "tool_use"
|
| 173 |
+
return [{"type": "text", "text": text}], "end_turn"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def strip_tool_markup(text: str) -> str:
|
| 177 |
+
return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def streamable_text(text: str) -> str:
|
| 181 |
+
text = text or ""
|
| 182 |
+
match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
|
| 183 |
+
return text[:match.start()].rstrip() if match else text
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
|
| 187 |
+
text = re.sub(r"(?is)```.*?```", "", text or "").strip()
|
| 188 |
+
blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
|
| 189 |
+
result = []
|
| 190 |
+
for block in (next((part for part in match if part), "") for match in blocks):
|
| 191 |
+
name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
|
| 192 |
+
params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
|
| 193 |
+
if name:
|
| 194 |
+
result.append((name, parse_tool_params(params)))
|
| 195 |
+
return result
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def xml_value(text: str, tag: str) -> str:
|
| 199 |
+
match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
|
| 200 |
+
if not match:
|
| 201 |
+
return ""
|
| 202 |
+
value = match.group(1).strip()
|
| 203 |
+
cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
|
| 204 |
+
return html.unescape(cdata.group(1) if cdata else value).strip()
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def parse_tool_params(raw: str) -> dict[str, object]:
|
| 208 |
+
raw = raw.strip()
|
| 209 |
+
try:
|
| 210 |
+
parsed = json.loads(raw)
|
| 211 |
+
return parsed if isinstance(parsed, dict) else {}
|
| 212 |
+
except Exception:
|
| 213 |
+
return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def parse_tool_value(raw: str) -> object:
|
| 217 |
+
value = xml_value(f"<x>{raw}</x>", "x")
|
| 218 |
+
try:
|
| 219 |
+
return json.loads(value)
|
| 220 |
+
except Exception:
|
| 221 |
+
return value
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
|
| 225 |
+
message_id = f"msg_{uuid.uuid4()}"
|
| 226 |
+
created = int(time.time())
|
| 227 |
+
current_text = ""
|
| 228 |
+
streamed_text = ""
|
| 229 |
+
tool_mode = isinstance(tools, list) and bool(tools)
|
| 230 |
+
tool_started = False
|
| 231 |
+
text_open = False
|
| 232 |
+
yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
|
| 233 |
+
if not tool_mode:
|
| 234 |
+
text_open = True
|
| 235 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 236 |
+
for chunk in chunks:
|
| 237 |
+
choice = (chunk.get("choices") or [{}])[0]
|
| 238 |
+
delta = choice.get("delta") or {}
|
| 239 |
+
text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
|
| 240 |
+
if text_delta:
|
| 241 |
+
current_text += text_delta
|
| 242 |
+
if not tool_started:
|
| 243 |
+
visible_text = current_text if not tool_mode else streamable_text(current_text)
|
| 244 |
+
if visible_text.startswith(streamed_text):
|
| 245 |
+
text_delta = visible_text[len(streamed_text):]
|
| 246 |
+
if text_delta:
|
| 247 |
+
if not text_open:
|
| 248 |
+
text_open = True
|
| 249 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 250 |
+
streamed_text = visible_text
|
| 251 |
+
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
|
| 252 |
+
tool_started = tool_mode and visible_text != current_text
|
| 253 |
+
if choice.get("finish_reason"):
|
| 254 |
+
content, stop_reason = content_blocks(current_text, tools)
|
| 255 |
+
if text_open:
|
| 256 |
+
yield {"type": "content_block_stop", "index": 0}
|
| 257 |
+
if stop_reason == "tool_use":
|
| 258 |
+
start_index = 1 if text_open else 0
|
| 259 |
+
if content and content[0]["type"] == "text":
|
| 260 |
+
remaining = str(content[0].get("text") or "")[len(streamed_text):]
|
| 261 |
+
if remaining:
|
| 262 |
+
if not text_open:
|
| 263 |
+
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
|
| 264 |
+
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
|
| 265 |
+
if not text_open:
|
| 266 |
+
yield {"type": "content_block_stop", "index": 0}
|
| 267 |
+
start_index = 1
|
| 268 |
+
content = content[1:]
|
| 269 |
+
yield from _stream_buffered_blocks(content, start_index)
|
| 270 |
+
yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
|
| 271 |
+
break
|
| 272 |
+
yield {"type": "message_stop", "created": created}
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
|
| 276 |
+
for offset, block in enumerate(content):
|
| 277 |
+
index = start_index + offset
|
| 278 |
+
if block["type"] == "tool_use":
|
| 279 |
+
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
|
| 280 |
+
delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
|
| 281 |
+
else:
|
| 282 |
+
start = {"type": "text", "text": ""}
|
| 283 |
+
delta = {"type": "text_delta", "text": block.get("text") or ""}
|
| 284 |
+
yield {"type": "content_block_start", "index": index, "content_block": start}
|
| 285 |
+
yield {"type": "content_block_delta", "index": index, "delta": delta}
|
| 286 |
+
yield {"type": "content_block_stop", "index": index}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 290 |
+
request = message_request(body)
|
| 291 |
+
if body.get("stream"):
|
| 292 |
+
return stream_events(
|
| 293 |
+
stream_text_chat_completion(request.backend, request.messages, request.model),
|
| 294 |
+
request.model,
|
| 295 |
+
count_message_tokens(request.messages, request.model),
|
| 296 |
+
lambda text: count_text_tokens(text, request.model),
|
| 297 |
+
request.tools,
|
| 298 |
+
)
|
| 299 |
+
text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
|
| 300 |
+
return message_response(
|
| 301 |
+
request.model,
|
| 302 |
+
text,
|
| 303 |
+
count_message_tokens(request.messages, request.model),
|
| 304 |
+
count_text_tokens(text, request.model),
|
| 305 |
+
request.tools,
|
| 306 |
+
)
|
services/protocol/chat_completion_cache.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import copy
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from typing import Any, Callable, Iterable, Iterator
|
| 10 |
+
|
| 11 |
+
from services.config import config
|
| 12 |
+
|
| 13 |
+
CACHEABLE_TEXT_KEYS = {
|
| 14 |
+
"frequency_penalty",
|
| 15 |
+
"max_completion_tokens",
|
| 16 |
+
"max_tokens",
|
| 17 |
+
"metadata",
|
| 18 |
+
"model",
|
| 19 |
+
"presence_penalty",
|
| 20 |
+
"reasoning_effort",
|
| 21 |
+
"response_format",
|
| 22 |
+
"seed",
|
| 23 |
+
"stop",
|
| 24 |
+
"temperature",
|
| 25 |
+
"tool_choice",
|
| 26 |
+
"tools",
|
| 27 |
+
"top_p",
|
| 28 |
+
"user",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass
|
| 33 |
+
class CacheEntry:
|
| 34 |
+
expires_at: float
|
| 35 |
+
value: Any
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class InflightCall:
|
| 40 |
+
condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
|
| 41 |
+
done: bool = False
|
| 42 |
+
value: Any = None
|
| 43 |
+
error: BaseException | None = None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _json_safe(value: Any) -> Any:
|
| 47 |
+
if isinstance(value, bytes):
|
| 48 |
+
return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
|
| 49 |
+
if isinstance(value, bytearray):
|
| 50 |
+
data = bytes(value)
|
| 51 |
+
return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
|
| 52 |
+
if isinstance(value, dict):
|
| 53 |
+
return {str(key): _json_safe(item) for key, item in value.items()}
|
| 54 |
+
if isinstance(value, (list, tuple)):
|
| 55 |
+
return [_json_safe(item) for item in value]
|
| 56 |
+
return value
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
|
| 60 |
+
payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
|
| 61 |
+
payload["messages"] = messages
|
| 62 |
+
payload["stream"] = bool(stream)
|
| 63 |
+
return payload
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
|
| 67 |
+
encoded = json.dumps(
|
| 68 |
+
_json_safe(canonical_body(body, messages, stream=stream)),
|
| 69 |
+
ensure_ascii=False,
|
| 70 |
+
sort_keys=True,
|
| 71 |
+
separators=(",", ":"),
|
| 72 |
+
).encode("utf-8")
|
| 73 |
+
return hashlib.sha256(encoded).hexdigest()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _message_signature(message: dict[str, Any]) -> str:
|
| 77 |
+
return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 81 |
+
settings = config.get_chat_completion_cache_settings()
|
| 82 |
+
if not settings.get("normalize_messages"):
|
| 83 |
+
return messages
|
| 84 |
+
|
| 85 |
+
normalized: list[dict[str, Any]] = []
|
| 86 |
+
previous_signature = ""
|
| 87 |
+
for message in messages:
|
| 88 |
+
if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
|
| 89 |
+
continue
|
| 90 |
+
signature = _message_signature(message)
|
| 91 |
+
if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
|
| 92 |
+
continue
|
| 93 |
+
normalized.append(message)
|
| 94 |
+
previous_signature = signature
|
| 95 |
+
return normalized
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
class ChatCompletionCache:
|
| 99 |
+
def __init__(self) -> None:
|
| 100 |
+
self._lock = threading.RLock()
|
| 101 |
+
self._entries: dict[str, CacheEntry] = {}
|
| 102 |
+
self._inflight: dict[str, InflightCall] = {}
|
| 103 |
+
|
| 104 |
+
def clear(self) -> None:
|
| 105 |
+
with self._lock:
|
| 106 |
+
self._entries.clear()
|
| 107 |
+
self._inflight.clear()
|
| 108 |
+
|
| 109 |
+
def _settings(self) -> dict[str, object]:
|
| 110 |
+
return config.get_chat_completion_cache_settings()
|
| 111 |
+
|
| 112 |
+
def _prune_locked(self, now: float, max_entries: int) -> None:
|
| 113 |
+
expired = [key for key, item in self._entries.items() if item.expires_at <= now]
|
| 114 |
+
for key in expired:
|
| 115 |
+
self._entries.pop(key, None)
|
| 116 |
+
while len(self._entries) > max_entries:
|
| 117 |
+
oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
|
| 118 |
+
self._entries.pop(oldest_key, None)
|
| 119 |
+
|
| 120 |
+
@staticmethod
|
| 121 |
+
def _copy(value: Any) -> Any:
|
| 122 |
+
return copy.deepcopy(value)
|
| 123 |
+
|
| 124 |
+
def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
|
| 125 |
+
settings = self._settings()
|
| 126 |
+
if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
|
| 127 |
+
return compute()
|
| 128 |
+
|
| 129 |
+
now = time.time()
|
| 130 |
+
max_entries = int(settings.get("max_entries") or 1)
|
| 131 |
+
with self._lock:
|
| 132 |
+
self._prune_locked(now, max_entries)
|
| 133 |
+
entry = self._entries.get(key)
|
| 134 |
+
if entry and entry.expires_at > now:
|
| 135 |
+
return self._copy(entry.value)
|
| 136 |
+
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
|
| 137 |
+
if inflight is None:
|
| 138 |
+
inflight = InflightCall()
|
| 139 |
+
if settings.get("dedupe_inflight"):
|
| 140 |
+
self._inflight[key] = inflight
|
| 141 |
+
owner = True
|
| 142 |
+
else:
|
| 143 |
+
owner = False
|
| 144 |
+
|
| 145 |
+
if not owner:
|
| 146 |
+
with inflight.condition:
|
| 147 |
+
while not inflight.done:
|
| 148 |
+
inflight.condition.wait()
|
| 149 |
+
if inflight.error:
|
| 150 |
+
raise inflight.error
|
| 151 |
+
return self._copy(inflight.value)
|
| 152 |
+
|
| 153 |
+
try:
|
| 154 |
+
value = compute()
|
| 155 |
+
except BaseException as exc:
|
| 156 |
+
with self._lock:
|
| 157 |
+
self._inflight.pop(key, None)
|
| 158 |
+
with inflight.condition:
|
| 159 |
+
inflight.error = exc
|
| 160 |
+
inflight.done = True
|
| 161 |
+
inflight.condition.notify_all()
|
| 162 |
+
raise
|
| 163 |
+
|
| 164 |
+
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
|
| 165 |
+
with self._lock:
|
| 166 |
+
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
|
| 167 |
+
self._prune_locked(time.time(), max_entries)
|
| 168 |
+
self._inflight.pop(key, None)
|
| 169 |
+
with inflight.condition:
|
| 170 |
+
inflight.value = self._copy(value)
|
| 171 |
+
inflight.done = True
|
| 172 |
+
inflight.condition.notify_all()
|
| 173 |
+
return value
|
| 174 |
+
|
| 175 |
+
def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
|
| 176 |
+
settings = self._settings()
|
| 177 |
+
if (
|
| 178 |
+
not settings.get("enabled")
|
| 179 |
+
or not settings.get("stream_cache")
|
| 180 |
+
or int(settings.get("ttl_seconds") or 0) <= 0
|
| 181 |
+
):
|
| 182 |
+
yield from compute()
|
| 183 |
+
return
|
| 184 |
+
|
| 185 |
+
now = time.time()
|
| 186 |
+
max_entries = int(settings.get("max_entries") or 1)
|
| 187 |
+
with self._lock:
|
| 188 |
+
self._prune_locked(now, max_entries)
|
| 189 |
+
entry = self._entries.get(key)
|
| 190 |
+
if entry and entry.expires_at > now:
|
| 191 |
+
yield from self._copy(entry.value)
|
| 192 |
+
return
|
| 193 |
+
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
|
| 194 |
+
if inflight is None:
|
| 195 |
+
inflight = InflightCall()
|
| 196 |
+
if settings.get("dedupe_inflight"):
|
| 197 |
+
self._inflight[key] = inflight
|
| 198 |
+
owner = True
|
| 199 |
+
else:
|
| 200 |
+
owner = False
|
| 201 |
+
|
| 202 |
+
if not owner:
|
| 203 |
+
with inflight.condition:
|
| 204 |
+
while not inflight.done:
|
| 205 |
+
inflight.condition.wait()
|
| 206 |
+
if inflight.error:
|
| 207 |
+
raise inflight.error
|
| 208 |
+
yield from self._copy(inflight.value)
|
| 209 |
+
return
|
| 210 |
+
|
| 211 |
+
chunks: list[dict[str, Any]] = []
|
| 212 |
+
try:
|
| 213 |
+
for chunk in compute():
|
| 214 |
+
chunks.append(self._copy(chunk))
|
| 215 |
+
yield chunk
|
| 216 |
+
except BaseException as exc:
|
| 217 |
+
with self._lock:
|
| 218 |
+
self._inflight.pop(key, None)
|
| 219 |
+
with inflight.condition:
|
| 220 |
+
inflight.error = exc
|
| 221 |
+
inflight.done = True
|
| 222 |
+
inflight.condition.notify_all()
|
| 223 |
+
raise
|
| 224 |
+
|
| 225 |
+
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
|
| 226 |
+
with self._lock:
|
| 227 |
+
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
|
| 228 |
+
self._prune_locked(time.time(), max_entries)
|
| 229 |
+
self._inflight.pop(key, None)
|
| 230 |
+
with inflight.condition:
|
| 231 |
+
inflight.value = self._copy(chunks)
|
| 232 |
+
inflight.done = True
|
| 233 |
+
inflight.condition.notify_all()
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
chat_completion_cache = ChatCompletionCache()
|
services/protocol/conversation.py
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import time
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from typing import Any, Iterable, Iterator
|
| 9 |
+
|
| 10 |
+
import tiktoken
|
| 11 |
+
|
| 12 |
+
from services.account_service import account_service
|
| 13 |
+
from services.config import config
|
| 14 |
+
from services.image_storage_service import image_storage_service
|
| 15 |
+
from services.openai_backend_api import ImagePollTimeoutError, OpenAIBackendAPI
|
| 16 |
+
from utils.helper import (
|
| 17 |
+
IMAGE_MODELS,
|
| 18 |
+
extract_image_from_message_content,
|
| 19 |
+
is_codex_image_model,
|
| 20 |
+
is_supported_image_model,
|
| 21 |
+
split_image_model,
|
| 22 |
+
)
|
| 23 |
+
from utils.image_tokens import count_image_content_tokens
|
| 24 |
+
from utils.log import logger
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ImageGenerationError(Exception):
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
message: str,
|
| 31 |
+
status_code: int = 502,
|
| 32 |
+
error_type: str = "server_error",
|
| 33 |
+
code: str | None = "upstream_error",
|
| 34 |
+
param: str | None = None,
|
| 35 |
+
account_email: str = "",
|
| 36 |
+
) -> None:
|
| 37 |
+
super().__init__(message)
|
| 38 |
+
self.status_code = status_code
|
| 39 |
+
self.error_type = error_type
|
| 40 |
+
self.code = code
|
| 41 |
+
self.param = param
|
| 42 |
+
self.account_email = account_email
|
| 43 |
+
|
| 44 |
+
def to_openai_error(self) -> dict[str, Any]:
|
| 45 |
+
return {
|
| 46 |
+
"error": {
|
| 47 |
+
"message": public_image_error_message(str(self)),
|
| 48 |
+
"type": self.error_type,
|
| 49 |
+
"param": self.param,
|
| 50 |
+
"code": self.code,
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def public_image_error_message(message: str) -> str:
|
| 56 |
+
text = str(message or "").strip()
|
| 57 |
+
lower = text.lower()
|
| 58 |
+
if any(item in lower for item in ("backend-api/", "status=", "body=", "chatgpt.com", "upstreamhttperror")):
|
| 59 |
+
return "The image generation request failed. Please try again later."
|
| 60 |
+
return text or "The image generation request failed. Please try again later."
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def is_token_invalid_error(message: str) -> bool:
|
| 64 |
+
text = str(message or "").lower()
|
| 65 |
+
return (
|
| 66 |
+
"token_invalidated" in text
|
| 67 |
+
or "token_revoked" in text
|
| 68 |
+
or "authentication token has been invalidated" in text
|
| 69 |
+
or "invalidated oauth token" in text
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def image_stream_error_message(message: str) -> str:
|
| 74 |
+
text = str(message or "")
|
| 75 |
+
lower = text.lower()
|
| 76 |
+
if is_token_invalid_error(text):
|
| 77 |
+
return "image generation failed"
|
| 78 |
+
if "curl: (35)" in lower or "tls connect error" in lower or "openssl_internal" in lower:
|
| 79 |
+
return "upstream image connection failed, please retry later"
|
| 80 |
+
return text or "image generation failed"
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
|
| 84 |
+
return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
|
| 88 |
+
return image_storage_service.save(image_data, base_url).url
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def message_text(content: Any) -> str:
|
| 92 |
+
if isinstance(content, str):
|
| 93 |
+
return content
|
| 94 |
+
if isinstance(content, list):
|
| 95 |
+
parts = []
|
| 96 |
+
for item in content:
|
| 97 |
+
if isinstance(item, str):
|
| 98 |
+
parts.append(item)
|
| 99 |
+
elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
|
| 100 |
+
parts.append(str(item.get("text") or ""))
|
| 101 |
+
return "".join(parts)
|
| 102 |
+
return ""
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
|
| 106 |
+
normalized = []
|
| 107 |
+
if config.global_system_prompt:
|
| 108 |
+
normalized.append({"role": "system", "content": config.global_system_prompt})
|
| 109 |
+
system_text = message_text(system)
|
| 110 |
+
if system_text:
|
| 111 |
+
normalized.append({"role": "system", "content": system_text})
|
| 112 |
+
if isinstance(messages, list):
|
| 113 |
+
for message in messages:
|
| 114 |
+
if not isinstance(message, dict):
|
| 115 |
+
continue
|
| 116 |
+
role = message.get("role", "user")
|
| 117 |
+
content = message.get("content", "")
|
| 118 |
+
text = message_text(content)
|
| 119 |
+
images: list[tuple[bytes, str]] = []
|
| 120 |
+
if role == "user":
|
| 121 |
+
images.extend(extract_image_from_message_content(content))
|
| 122 |
+
if isinstance(content, list):
|
| 123 |
+
for part in content:
|
| 124 |
+
if not isinstance(part, dict) or part.get("type") != "image":
|
| 125 |
+
continue
|
| 126 |
+
data = part.get("data")
|
| 127 |
+
if isinstance(data, (bytes, bytearray)):
|
| 128 |
+
images.append((bytes(data), str(part.get("mime") or "image/png")))
|
| 129 |
+
if images:
|
| 130 |
+
parts: list[Any] = []
|
| 131 |
+
if text:
|
| 132 |
+
parts.append({"type": "text", "text": text})
|
| 133 |
+
for data, mime in images:
|
| 134 |
+
parts.append({"type": "image", "data": data, "mime": mime})
|
| 135 |
+
normalized.append({"role": role, "content": parts})
|
| 136 |
+
else:
|
| 137 |
+
normalized.append({"role": role, "content": text})
|
| 138 |
+
return normalized
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def prompt_with_global_system(prompt: str) -> str:
|
| 142 |
+
return f"{config.global_system_prompt}\n\n{prompt}" if config.global_system_prompt else prompt
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def assistant_history_text(messages: list[dict[str, Any]]) -> str:
|
| 146 |
+
return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def assistant_history_messages(messages: list[dict[str, Any]]) -> list[str]:
|
| 150 |
+
return [str(item.get("content") or "") for item in messages if item.get("role") == "assistant" and item.get("content")]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_image_prompt(prompt: str, size: str | None, quality: str = "auto") -> str:
|
| 154 |
+
hints = []
|
| 155 |
+
if size:
|
| 156 |
+
hints.append(f"输出图片尺寸为 {size}。")
|
| 157 |
+
if quality:
|
| 158 |
+
hints.append(f"输出图片质量为 {quality}。")
|
| 159 |
+
return f"{prompt.strip()}\n\n{''.join(hints)}" if hints else prompt
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def encoding_for_model(model: str):
|
| 163 |
+
try:
|
| 164 |
+
return tiktoken.encoding_for_model(model)
|
| 165 |
+
except KeyError:
|
| 166 |
+
try:
|
| 167 |
+
return tiktoken.get_encoding("o200k_base")
|
| 168 |
+
except KeyError:
|
| 169 |
+
return tiktoken.get_encoding("cl100k_base")
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def count_message_image_tokens(messages: list[dict[str, Any]], model: str) -> int:
|
| 173 |
+
return sum(count_image_content_tokens(message.get("content"), model) for message in messages)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def count_message_text_tokens(messages: list[dict[str, Any]], model: str) -> int:
|
| 177 |
+
encoding = encoding_for_model(model)
|
| 178 |
+
total = 0
|
| 179 |
+
for message in messages:
|
| 180 |
+
total += 3
|
| 181 |
+
for key, value in message.items():
|
| 182 |
+
if key == "content" and isinstance(value, list):
|
| 183 |
+
total += len(encoding.encode(message_text(value)))
|
| 184 |
+
elif isinstance(value, str):
|
| 185 |
+
total += len(encoding.encode(value))
|
| 186 |
+
else:
|
| 187 |
+
continue
|
| 188 |
+
if key == "name":
|
| 189 |
+
total += 1
|
| 190 |
+
return total + 3
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
|
| 194 |
+
return count_message_text_tokens(messages, model) + count_message_image_tokens(messages, model)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def count_text_tokens(text: str, model: str) -> int:
|
| 198 |
+
return len(encoding_for_model(model).encode(text))
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def format_image_result(
|
| 202 |
+
items: list[dict[str, Any]],
|
| 203 |
+
prompt: str,
|
| 204 |
+
response_format: str,
|
| 205 |
+
base_url: str | None = None,
|
| 206 |
+
created: int | None = None,
|
| 207 |
+
message: str = "",
|
| 208 |
+
) -> dict[str, Any]:
|
| 209 |
+
data: list[dict[str, Any]] = []
|
| 210 |
+
for item in items:
|
| 211 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 212 |
+
if not b64_json:
|
| 213 |
+
continue
|
| 214 |
+
revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
|
| 215 |
+
if response_format == "b64_json":
|
| 216 |
+
data.append({
|
| 217 |
+
"b64_json": b64_json,
|
| 218 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 219 |
+
"revised_prompt": revised_prompt,
|
| 220 |
+
})
|
| 221 |
+
else:
|
| 222 |
+
data.append({
|
| 223 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 224 |
+
"revised_prompt": revised_prompt,
|
| 225 |
+
})
|
| 226 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 227 |
+
if message and not data:
|
| 228 |
+
result["message"] = message
|
| 229 |
+
return result
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@dataclass
|
| 233 |
+
class ConversationRequest:
|
| 234 |
+
model: str = "auto"
|
| 235 |
+
prompt: str = ""
|
| 236 |
+
messages: list[dict[str, Any]] | None = None
|
| 237 |
+
images: list[str] | None = None
|
| 238 |
+
n: int = 1
|
| 239 |
+
size: str | None = None
|
| 240 |
+
quality: str = "auto"
|
| 241 |
+
response_format: str = "b64_json"
|
| 242 |
+
base_url: str | None = None
|
| 243 |
+
message_as_error: bool = False
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
@dataclass
|
| 247 |
+
class ConversationState:
|
| 248 |
+
text: str = ""
|
| 249 |
+
raw_text: str = ""
|
| 250 |
+
conversation_id: str = ""
|
| 251 |
+
file_ids: list[str] = field(default_factory=list)
|
| 252 |
+
sediment_ids: list[str] = field(default_factory=list)
|
| 253 |
+
blocked: bool = False
|
| 254 |
+
tool_invoked: bool | None = None
|
| 255 |
+
turn_use_case: str = ""
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@dataclass
|
| 259 |
+
class ImageOutput:
|
| 260 |
+
kind: str
|
| 261 |
+
model: str
|
| 262 |
+
index: int
|
| 263 |
+
total: int
|
| 264 |
+
created: int = field(default_factory=lambda: int(time.time()))
|
| 265 |
+
text: str = ""
|
| 266 |
+
upstream_event_type: str = ""
|
| 267 |
+
data: list[dict[str, Any]] = field(default_factory=list)
|
| 268 |
+
account_email: str = ""
|
| 269 |
+
|
| 270 |
+
def to_chunk(self) -> dict[str, Any]:
|
| 271 |
+
chunk: dict[str, Any] = {
|
| 272 |
+
"object": "image.generation.chunk",
|
| 273 |
+
"created": self.created,
|
| 274 |
+
"model": self.model,
|
| 275 |
+
"index": self.index,
|
| 276 |
+
"total": self.total,
|
| 277 |
+
"progress_text": self.text,
|
| 278 |
+
"upstream_event_type": self.upstream_event_type,
|
| 279 |
+
"data": [],
|
| 280 |
+
}
|
| 281 |
+
if self.account_email:
|
| 282 |
+
chunk["_account_email"] = self.account_email
|
| 283 |
+
if self.kind == "message":
|
| 284 |
+
chunk.update({
|
| 285 |
+
"object": "image.generation.message",
|
| 286 |
+
"message": self.text,
|
| 287 |
+
})
|
| 288 |
+
chunk.pop("progress_text", None)
|
| 289 |
+
chunk.pop("upstream_event_type", None)
|
| 290 |
+
elif self.kind == "result":
|
| 291 |
+
chunk.update({
|
| 292 |
+
"object": "image.generation.result",
|
| 293 |
+
"data": self.data,
|
| 294 |
+
})
|
| 295 |
+
chunk.pop("progress_text", None)
|
| 296 |
+
chunk.pop("upstream_event_type", None)
|
| 297 |
+
return chunk
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def assistant_message_text(message: dict[str, Any]) -> str:
|
| 301 |
+
content = message.get("content") or {}
|
| 302 |
+
parts = content.get("parts") or []
|
| 303 |
+
if not isinstance(parts, list):
|
| 304 |
+
return ""
|
| 305 |
+
return "".join(part for part in parts if isinstance(part, str))
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def strip_history(text: str, history_text: str = "") -> str:
|
| 309 |
+
text = str(text or "")
|
| 310 |
+
history_text = str(history_text or "")
|
| 311 |
+
while history_text and text.startswith(history_text):
|
| 312 |
+
text = text[len(history_text):]
|
| 313 |
+
return text
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def sanitize_output_text(text: str) -> str:
|
| 317 |
+
text = str(text or "")
|
| 318 |
+
|
| 319 |
+
def replace_url(match: re.Match[str]) -> str:
|
| 320 |
+
label = match.group(1).strip()
|
| 321 |
+
url = match.group(2).strip()
|
| 322 |
+
if label and url.startswith(("http://", "https://")):
|
| 323 |
+
return f"{label} ({url})"
|
| 324 |
+
return label or url
|
| 325 |
+
|
| 326 |
+
# ChatGPT web sometimes returns rich annotation markers using private-use
|
| 327 |
+
# characters. API clients cannot render those, so convert links and drop
|
| 328 |
+
# citations before emitting OpenAI-compatible text.
|
| 329 |
+
text = re.sub(r"\ue200url\ue202([^\ue202\ue201]*)\ue202([^\ue201]*)\ue201", replace_url, text)
|
| 330 |
+
text = re.sub(r"\ue200cite\ue202[^\ue201]*\ue201", "", text)
|
| 331 |
+
text = re.sub(r"\ue200[^\ue201]*\ue201", "", text)
|
| 332 |
+
text = re.sub(r"\ue200[^\ue201]*$", "", text)
|
| 333 |
+
return text
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def assistant_raw_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 337 |
+
for candidate in (event, event.get("v")):
|
| 338 |
+
if not isinstance(candidate, dict):
|
| 339 |
+
continue
|
| 340 |
+
message = candidate.get("message")
|
| 341 |
+
if not isinstance(message, dict):
|
| 342 |
+
continue
|
| 343 |
+
role = str((message.get("author") or {}).get("role") or "").strip().lower()
|
| 344 |
+
if role != "assistant":
|
| 345 |
+
continue
|
| 346 |
+
text = assistant_message_text(message)
|
| 347 |
+
if text:
|
| 348 |
+
return strip_history(text, history_text)
|
| 349 |
+
return apply_text_patch(event, current_text, history_text)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 353 |
+
return sanitize_output_text(assistant_raw_text(event, current_text, history_text))
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def event_assistant_text(event: dict[str, Any], history_text: str = "") -> str:
|
| 357 |
+
for candidate in (event, event.get("v")):
|
| 358 |
+
if not isinstance(candidate, dict):
|
| 359 |
+
continue
|
| 360 |
+
message = candidate.get("message")
|
| 361 |
+
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 362 |
+
return strip_history(assistant_message_text(message), history_text)
|
| 363 |
+
return ""
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 367 |
+
if event.get("p") == "/message/content/parts/0":
|
| 368 |
+
return apply_patch_op(event, current_text, history_text)
|
| 369 |
+
|
| 370 |
+
operations = event.get("v")
|
| 371 |
+
if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
|
| 372 |
+
return current_text + operations
|
| 373 |
+
|
| 374 |
+
if event.get("o") == "patch" and isinstance(operations, list):
|
| 375 |
+
text = current_text
|
| 376 |
+
for item in operations:
|
| 377 |
+
if isinstance(item, dict):
|
| 378 |
+
text = apply_text_patch(item, text, history_text)
|
| 379 |
+
return text
|
| 380 |
+
|
| 381 |
+
if not isinstance(operations, list):
|
| 382 |
+
return current_text
|
| 383 |
+
|
| 384 |
+
text = current_text
|
| 385 |
+
for item in operations:
|
| 386 |
+
if isinstance(item, dict):
|
| 387 |
+
text = apply_text_patch(item, text, history_text)
|
| 388 |
+
return text
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
|
| 392 |
+
op = operation.get("o")
|
| 393 |
+
value = str(operation.get("v") or "")
|
| 394 |
+
if op == "append":
|
| 395 |
+
return current_text + value
|
| 396 |
+
if op == "replace":
|
| 397 |
+
return strip_history(value, history_text)
|
| 398 |
+
return current_text
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def add_unique(values: list[str], candidates: list[str]) -> None:
|
| 402 |
+
for candidate in candidates:
|
| 403 |
+
if candidate and candidate not in values:
|
| 404 |
+
values.append(candidate)
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
|
| 408 |
+
conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 409 |
+
conversation_id = conversation_match.group(1) if conversation_match else ""
|
| 410 |
+
# Negative lookahead excludes "file-service" (URI prefix, not a real id).
|
| 411 |
+
file_ids = re.findall(r"(file[-_](?!service\b)[A-Za-z0-9]+)", payload)
|
| 412 |
+
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 413 |
+
return conversation_id, file_ids, sediment_ids
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def is_image_tool_event(event: dict[str, Any]) -> bool:
|
| 417 |
+
value = event.get("v")
|
| 418 |
+
message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
|
| 419 |
+
if not isinstance(message, dict):
|
| 420 |
+
return False
|
| 421 |
+
metadata = message.get("metadata") or {}
|
| 422 |
+
author = message.get("author") or {}
|
| 423 |
+
content = message.get("content") or {}
|
| 424 |
+
if author.get("role") != "tool":
|
| 425 |
+
return False
|
| 426 |
+
if metadata.get("async_task_type") == "image_gen":
|
| 427 |
+
return True
|
| 428 |
+
if content.get("content_type") != "multimodal_text":
|
| 429 |
+
return False
|
| 430 |
+
return any(
|
| 431 |
+
isinstance(part, dict) and (
|
| 432 |
+
part.get("content_type") == "image_asset_pointer"
|
| 433 |
+
or str(part.get("asset_pointer") or "").startswith(("file-service://", "sediment://"))
|
| 434 |
+
)
|
| 435 |
+
for part in content.get("parts") or []
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
|
| 440 |
+
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
|
| 441 |
+
if conversation_id and not state.conversation_id:
|
| 442 |
+
state.conversation_id = conversation_id
|
| 443 |
+
# Accept file_id / sediment_id when any of:
|
| 444 |
+
# 1) event is a complete image_gen tool message
|
| 445 |
+
# 2) prior server_ste_metadata already flipped tool_invoked True (in an image_gen turn)
|
| 446 |
+
# 3) patch event whose payload references asset_pointer / file-service://
|
| 447 |
+
# User messages (type=conversation.message) never satisfy these, so attacker-controlled
|
| 448 |
+
# substrings in user input cannot inject file ids into state.
|
| 449 |
+
is_patch_event = isinstance(event, dict) and event.get("o") == "patch"
|
| 450 |
+
image_context = (
|
| 451 |
+
(isinstance(event, dict) and is_image_tool_event(event))
|
| 452 |
+
or state.tool_invoked is True
|
| 453 |
+
or (is_patch_event and ("asset_pointer" in payload or "file-service://" in payload))
|
| 454 |
+
)
|
| 455 |
+
if image_context:
|
| 456 |
+
add_unique(state.file_ids, file_ids)
|
| 457 |
+
add_unique(state.sediment_ids, sediment_ids)
|
| 458 |
+
if not isinstance(event, dict):
|
| 459 |
+
return
|
| 460 |
+
state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
|
| 461 |
+
value = event.get("v")
|
| 462 |
+
if isinstance(value, dict):
|
| 463 |
+
state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
|
| 464 |
+
if event.get("type") == "moderation":
|
| 465 |
+
moderation = event.get("moderation_response")
|
| 466 |
+
if isinstance(moderation, dict) and moderation.get("blocked") is True:
|
| 467 |
+
state.blocked = True
|
| 468 |
+
if event.get("type") == "server_ste_metadata":
|
| 469 |
+
metadata = event.get("metadata")
|
| 470 |
+
if isinstance(metadata, dict):
|
| 471 |
+
if isinstance(metadata.get("tool_invoked"), bool):
|
| 472 |
+
state.tool_invoked = metadata["tool_invoked"]
|
| 473 |
+
state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
|
| 477 |
+
return {
|
| 478 |
+
"type": event_type,
|
| 479 |
+
"text": state.text,
|
| 480 |
+
"conversation_id": state.conversation_id,
|
| 481 |
+
"file_ids": list(state.file_ids),
|
| 482 |
+
"sediment_ids": list(state.sediment_ids),
|
| 483 |
+
"blocked": state.blocked,
|
| 484 |
+
"tool_invoked": state.tool_invoked,
|
| 485 |
+
"turn_use_case": state.turn_use_case,
|
| 486 |
+
**extra,
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "",
|
| 491 |
+
history_messages: list[str] | None = None) -> Iterator[dict[str, Any]]:
|
| 492 |
+
state = ConversationState()
|
| 493 |
+
history_messages = history_messages or []
|
| 494 |
+
history_index = 0
|
| 495 |
+
for payload in payloads:
|
| 496 |
+
# print(f"[upstream_sse] {payload}", flush=True)
|
| 497 |
+
if not payload:
|
| 498 |
+
continue
|
| 499 |
+
if payload == "[DONE]":
|
| 500 |
+
yield conversation_base_event("conversation.done", state, done=True)
|
| 501 |
+
break
|
| 502 |
+
try:
|
| 503 |
+
event = json.loads(payload)
|
| 504 |
+
except json.JSONDecodeError:
|
| 505 |
+
update_conversation_state(state, payload)
|
| 506 |
+
yield conversation_base_event("conversation.raw", state, payload=payload)
|
| 507 |
+
continue
|
| 508 |
+
if not isinstance(event, dict):
|
| 509 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 510 |
+
continue
|
| 511 |
+
update_conversation_state(state, payload, event)
|
| 512 |
+
if history_index < len(history_messages) and event_assistant_text(event, history_text) == history_messages[history_index]:
|
| 513 |
+
history_index += 1
|
| 514 |
+
state.raw_text = ""
|
| 515 |
+
state.text = ""
|
| 516 |
+
continue
|
| 517 |
+
next_raw_text = assistant_raw_text(event, state.raw_text, history_text)
|
| 518 |
+
next_text = sanitize_output_text(next_raw_text)
|
| 519 |
+
state.raw_text = next_raw_text
|
| 520 |
+
if next_text != state.text:
|
| 521 |
+
delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
|
| 522 |
+
state.text = next_text
|
| 523 |
+
yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
|
| 524 |
+
continue
|
| 525 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
def conversation_events(
|
| 529 |
+
backend: OpenAIBackendAPI,
|
| 530 |
+
messages: list[dict[str, Any]] | None = None,
|
| 531 |
+
model: str = "auto",
|
| 532 |
+
prompt: str = "",
|
| 533 |
+
images: list[str] | None = None,
|
| 534 |
+
size: str | None = None,
|
| 535 |
+
quality: str = "auto",
|
| 536 |
+
) -> Iterator[dict[str, Any]]:
|
| 537 |
+
normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
|
| 538 |
+
image_model = is_supported_image_model(model)
|
| 539 |
+
history_text = "" if image_model else assistant_history_text(normalized)
|
| 540 |
+
history_messages = [] if image_model else assistant_history_messages(normalized)
|
| 541 |
+
final_prompt = prompt_with_global_system(build_image_prompt(prompt, size, quality)) if image_model else prompt
|
| 542 |
+
payloads = backend.stream_conversation(
|
| 543 |
+
messages=normalized,
|
| 544 |
+
model=model,
|
| 545 |
+
prompt=final_prompt,
|
| 546 |
+
images=images if image_model else None,
|
| 547 |
+
system_hints=["picture_v2"] if image_model else None,
|
| 548 |
+
)
|
| 549 |
+
yield from iter_conversation_payloads(payloads, history_text, history_messages)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
def text_backend() -> OpenAIBackendAPI:
|
| 553 |
+
return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
|
| 557 |
+
attempted_tokens: set[str] = set()
|
| 558 |
+
token = getattr(backend, "access_token", "")
|
| 559 |
+
emitted = False
|
| 560 |
+
while True:
|
| 561 |
+
if token and token in attempted_tokens:
|
| 562 |
+
raise RuntimeError("no available text account")
|
| 563 |
+
if token:
|
| 564 |
+
attempted_tokens.add(token)
|
| 565 |
+
try:
|
| 566 |
+
active_backend = OpenAIBackendAPI(access_token=token)
|
| 567 |
+
for event in conversation_events(active_backend, messages=request.messages, model=request.model, prompt=request.prompt):
|
| 568 |
+
if event.get("type") != "conversation.delta":
|
| 569 |
+
continue
|
| 570 |
+
delta = str(event.get("delta") or "")
|
| 571 |
+
if delta:
|
| 572 |
+
emitted = True
|
| 573 |
+
yield delta
|
| 574 |
+
account_service.mark_text_used(token)
|
| 575 |
+
return
|
| 576 |
+
except Exception as exc:
|
| 577 |
+
error_message = str(exc)
|
| 578 |
+
if token and not emitted and is_token_invalid_error(error_message):
|
| 579 |
+
refreshed_token = account_service.refresh_access_token(token, force=True, event="text_stream")
|
| 580 |
+
if refreshed_token and refreshed_token != token and refreshed_token not in attempted_tokens:
|
| 581 |
+
token = refreshed_token
|
| 582 |
+
else:
|
| 583 |
+
account_service.remove_invalid_token(token, "text_stream")
|
| 584 |
+
token = account_service.get_text_access_token(attempted_tokens)
|
| 585 |
+
if token:
|
| 586 |
+
continue
|
| 587 |
+
raise
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
|
| 591 |
+
return "".join(stream_text_deltas(backend, request))
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def stream_image_outputs(
|
| 595 |
+
backend: OpenAIBackendAPI,
|
| 596 |
+
request: ConversationRequest,
|
| 597 |
+
index: int = 1,
|
| 598 |
+
total: int = 1,
|
| 599 |
+
) -> Iterator[ImageOutput]:
|
| 600 |
+
last: dict[str, Any] = {}
|
| 601 |
+
for event in conversation_events(
|
| 602 |
+
backend,
|
| 603 |
+
prompt=request.prompt,
|
| 604 |
+
model=request.model,
|
| 605 |
+
images=request.images or [],
|
| 606 |
+
size=request.size,
|
| 607 |
+
quality=request.quality,
|
| 608 |
+
):
|
| 609 |
+
last = event
|
| 610 |
+
if event.get("type") == "conversation.delta":
|
| 611 |
+
yield ImageOutput(
|
| 612 |
+
kind="progress",
|
| 613 |
+
model=request.model,
|
| 614 |
+
index=index,
|
| 615 |
+
total=total,
|
| 616 |
+
text=str(event.get("delta") or ""),
|
| 617 |
+
upstream_event_type="conversation.delta",
|
| 618 |
+
)
|
| 619 |
+
continue
|
| 620 |
+
if event.get("type") == "conversation.event":
|
| 621 |
+
raw = event.get("raw")
|
| 622 |
+
raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
|
| 623 |
+
yield ImageOutput(
|
| 624 |
+
kind="progress",
|
| 625 |
+
model=request.model,
|
| 626 |
+
index=index,
|
| 627 |
+
total=total,
|
| 628 |
+
upstream_event_type=raw_type,
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
conversation_id = str(last.get("conversation_id") or "")
|
| 632 |
+
file_ids = [str(item) for item in last.get("file_ids") or []]
|
| 633 |
+
sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
|
| 634 |
+
message = str(last.get("text") or "").strip()
|
| 635 |
+
logger.info({
|
| 636 |
+
"event": "image_stream_resolve_start",
|
| 637 |
+
"conversation_id": conversation_id,
|
| 638 |
+
"file_ids": file_ids,
|
| 639 |
+
"sediment_ids": sediment_ids,
|
| 640 |
+
"tool_invoked": last.get("tool_invoked"),
|
| 641 |
+
"turn_use_case": last.get("turn_use_case"),
|
| 642 |
+
})
|
| 643 |
+
if message and not file_ids and not sediment_ids and last.get("blocked"):
|
| 644 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 645 |
+
return
|
| 646 |
+
should_poll_for_image = bool(request.images) or last.get("turn_use_case") == "image gen"
|
| 647 |
+
if message and not file_ids and not sediment_ids and not should_poll_for_image:
|
| 648 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 649 |
+
return
|
| 650 |
+
|
| 651 |
+
image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
|
| 652 |
+
if image_urls:
|
| 653 |
+
image_items = [
|
| 654 |
+
{"b64_json": base64.b64encode(image_data).decode("ascii")}
|
| 655 |
+
for image_data in backend.download_image_bytes(image_urls)
|
| 656 |
+
]
|
| 657 |
+
data = format_image_result(
|
| 658 |
+
image_items,
|
| 659 |
+
request.prompt,
|
| 660 |
+
request.response_format,
|
| 661 |
+
request.base_url,
|
| 662 |
+
int(time.time()),
|
| 663 |
+
)["data"]
|
| 664 |
+
if data:
|
| 665 |
+
yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
|
| 666 |
+
return
|
| 667 |
+
|
| 668 |
+
if message:
|
| 669 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _codex_response_images(value: Any) -> list[str]:
|
| 673 |
+
if isinstance(value, dict):
|
| 674 |
+
if value.get("type") == "image_generation_call" and isinstance(value.get("result"), str):
|
| 675 |
+
result = value["result"].strip()
|
| 676 |
+
if result:
|
| 677 |
+
return [result.split(",", 1)[1] if result.startswith("data:image/") else result]
|
| 678 |
+
images: list[str] = []
|
| 679 |
+
for item in value.values():
|
| 680 |
+
images.extend(_codex_response_images(item))
|
| 681 |
+
return images
|
| 682 |
+
if isinstance(value, list):
|
| 683 |
+
images: list[str] = []
|
| 684 |
+
for item in value:
|
| 685 |
+
images.extend(_codex_response_images(item))
|
| 686 |
+
return images
|
| 687 |
+
return []
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
def stream_codex_image_outputs(
|
| 691 |
+
backend: OpenAIBackendAPI,
|
| 692 |
+
request: ConversationRequest,
|
| 693 |
+
index: int = 1,
|
| 694 |
+
total: int = 1,
|
| 695 |
+
) -> Iterator[ImageOutput]:
|
| 696 |
+
images = _codex_response_images(list(backend.iter_codex_image_response_events(
|
| 697 |
+
prompt=request.prompt,
|
| 698 |
+
images=request.images or [],
|
| 699 |
+
size=request.size,
|
| 700 |
+
quality=request.quality,
|
| 701 |
+
)))
|
| 702 |
+
if not images:
|
| 703 |
+
raise ImageGenerationError("No image result found in response")
|
| 704 |
+
data = format_image_result(
|
| 705 |
+
[{"b64_json": item, "revised_prompt": request.prompt} for item in images],
|
| 706 |
+
request.prompt,
|
| 707 |
+
request.response_format,
|
| 708 |
+
request.base_url,
|
| 709 |
+
int(time.time()),
|
| 710 |
+
)["data"]
|
| 711 |
+
if data:
|
| 712 |
+
yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
|
| 713 |
+
return
|
| 714 |
+
raise ImageGenerationError("No image result found in response")
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
|
| 718 |
+
if not is_supported_image_model(request.model):
|
| 719 |
+
raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(sorted(IMAGE_MODELS)))
|
| 720 |
+
|
| 721 |
+
emitted = False
|
| 722 |
+
last_error = ""
|
| 723 |
+
for index in range(1, request.n + 1):
|
| 724 |
+
while True:
|
| 725 |
+
try:
|
| 726 |
+
plan_type, _ = split_image_model(request.model)
|
| 727 |
+
codex_model = is_codex_image_model(request.model)
|
| 728 |
+
token = account_service.get_available_access_token(
|
| 729 |
+
plan_type=plan_type,
|
| 730 |
+
source_type="codex" if codex_model else None,
|
| 731 |
+
plan_types=("plus", "team", "pro") if codex_model and not plan_type else None,
|
| 732 |
+
)
|
| 733 |
+
except RuntimeError as exc:
|
| 734 |
+
if emitted:
|
| 735 |
+
return
|
| 736 |
+
raise ImageGenerationError(str(exc) or "image generation failed") from exc
|
| 737 |
+
|
| 738 |
+
emitted_for_token = False
|
| 739 |
+
returned_message = False
|
| 740 |
+
returned_result = False
|
| 741 |
+
account = account_service.get_account(token) or {}
|
| 742 |
+
account_email = str(account.get("email") or "").strip()
|
| 743 |
+
try:
|
| 744 |
+
backend = OpenAIBackendAPI(access_token=token)
|
| 745 |
+
stream_fn = stream_codex_image_outputs if is_codex_image_model(request.model) else stream_image_outputs
|
| 746 |
+
for output in stream_fn(backend, request, index, request.n):
|
| 747 |
+
if account_email and not output.account_email:
|
| 748 |
+
output.account_email = account_email
|
| 749 |
+
if output.kind == "message" and request.message_as_error:
|
| 750 |
+
raise ImageGenerationError(
|
| 751 |
+
output.text or "Image generation was rejected by upstream policy.",
|
| 752 |
+
status_code=400,
|
| 753 |
+
error_type="invalid_request_error",
|
| 754 |
+
code="content_policy_violation",
|
| 755 |
+
account_email=account_email,
|
| 756 |
+
)
|
| 757 |
+
emitted = True
|
| 758 |
+
emitted_for_token = True
|
| 759 |
+
returned_message = output.kind == "message"
|
| 760 |
+
returned_result = returned_result or output.kind == "result"
|
| 761 |
+
yield output
|
| 762 |
+
if returned_message or not returned_result:
|
| 763 |
+
account_service.mark_image_result(token, False)
|
| 764 |
+
return
|
| 765 |
+
account_service.mark_image_result(token, True)
|
| 766 |
+
break
|
| 767 |
+
except ImagePollTimeoutError as exc:
|
| 768 |
+
if account_email and not getattr(exc, "account_email", ""):
|
| 769 |
+
exc.account_email = account_email
|
| 770 |
+
raise
|
| 771 |
+
except ImageGenerationError as exc:
|
| 772 |
+
account_service.mark_image_result(token, False)
|
| 773 |
+
if account_email and not getattr(exc, "account_email", ""):
|
| 774 |
+
exc.account_email = account_email
|
| 775 |
+
logger.warning({
|
| 776 |
+
"event": "image_stream_generation_error",
|
| 777 |
+
"request_token": token,
|
| 778 |
+
"account_email": account_email,
|
| 779 |
+
"error": str(exc),
|
| 780 |
+
})
|
| 781 |
+
raise
|
| 782 |
+
except Exception as exc:
|
| 783 |
+
account_service.mark_image_result(token, False)
|
| 784 |
+
last_error = str(exc)
|
| 785 |
+
logger.warning({
|
| 786 |
+
"event": "image_stream_fail",
|
| 787 |
+
"request_token": token,
|
| 788 |
+
"account_email": account_email,
|
| 789 |
+
"error": last_error,
|
| 790 |
+
})
|
| 791 |
+
if not emitted_for_token and is_token_invalid_error(last_error):
|
| 792 |
+
refreshed_token = account_service.refresh_access_token(token, force=True, event="image_stream")
|
| 793 |
+
if refreshed_token and refreshed_token != token:
|
| 794 |
+
token = refreshed_token
|
| 795 |
+
continue
|
| 796 |
+
account_service.remove_invalid_token(token, "image_stream")
|
| 797 |
+
continue
|
| 798 |
+
raise ImageGenerationError(image_stream_error_message(last_error), account_email=account_email) from exc
|
| 799 |
+
|
| 800 |
+
if not emitted:
|
| 801 |
+
if not last_error:
|
| 802 |
+
last_error = "no account in the pool could generate images — check account quota and rate-limit status"
|
| 803 |
+
raise ImageGenerationError(image_stream_error_message(last_error))
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
|
| 807 |
+
for output in outputs:
|
| 808 |
+
yield output.to_chunk()
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
|
| 812 |
+
created = None
|
| 813 |
+
data: list[dict[str, Any]] = []
|
| 814 |
+
message = ""
|
| 815 |
+
progress_parts: list[str] = []
|
| 816 |
+
account_email = ""
|
| 817 |
+
for output in outputs:
|
| 818 |
+
created = created or output.created
|
| 819 |
+
if output.account_email and not account_email:
|
| 820 |
+
account_email = output.account_email
|
| 821 |
+
if output.kind == "progress" and output.text:
|
| 822 |
+
progress_parts.append(output.text)
|
| 823 |
+
elif output.kind == "message":
|
| 824 |
+
message = output.text
|
| 825 |
+
elif output.kind == "result":
|
| 826 |
+
data.extend(output.data)
|
| 827 |
+
|
| 828 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 829 |
+
if not data:
|
| 830 |
+
text = message or "".join(progress_parts).strip()
|
| 831 |
+
if text:
|
| 832 |
+
result["message"] = text
|
| 833 |
+
if account_email:
|
| 834 |
+
result["_account_email"] = account_email
|
| 835 |
+
return result
|
services/protocol/error_response.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from fastapi.responses import JSONResponse
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _message_from_value(value: object) -> str:
|
| 9 |
+
if isinstance(value, str):
|
| 10 |
+
return value
|
| 11 |
+
if not isinstance(value, dict):
|
| 12 |
+
return ""
|
| 13 |
+
message = value.get("message")
|
| 14 |
+
if isinstance(message, str) and message:
|
| 15 |
+
return message
|
| 16 |
+
return _message_from_value(value.get("error"))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def error_message_from_detail(detail: object) -> str:
|
| 20 |
+
if isinstance(detail, list):
|
| 21 |
+
messages = []
|
| 22 |
+
for item in detail:
|
| 23 |
+
if not isinstance(item, dict):
|
| 24 |
+
continue
|
| 25 |
+
location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
|
| 26 |
+
message = str(item.get("msg") or "").strip()
|
| 27 |
+
if location and message:
|
| 28 |
+
messages.append(f"{location}: {message}")
|
| 29 |
+
elif message:
|
| 30 |
+
messages.append(message)
|
| 31 |
+
return "; ".join(messages)
|
| 32 |
+
if isinstance(detail, dict):
|
| 33 |
+
message = _message_from_value(detail.get("error")) or _message_from_value(detail)
|
| 34 |
+
if message:
|
| 35 |
+
return message
|
| 36 |
+
return str(detail or "").strip()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _default_error_type(status_code: int) -> str:
|
| 40 |
+
if status_code == 401:
|
| 41 |
+
return "authentication_error"
|
| 42 |
+
if status_code == 403:
|
| 43 |
+
return "permission_error"
|
| 44 |
+
if status_code == 429:
|
| 45 |
+
return "rate_limit_error"
|
| 46 |
+
if 400 <= status_code < 500:
|
| 47 |
+
return "invalid_request_error"
|
| 48 |
+
return "server_error"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _default_error_code(status_code: int) -> str:
|
| 52 |
+
if status_code == 401:
|
| 53 |
+
return "invalid_api_key"
|
| 54 |
+
if status_code == 403:
|
| 55 |
+
return "permission_denied"
|
| 56 |
+
if status_code == 429:
|
| 57 |
+
return "rate_limit_exceeded"
|
| 58 |
+
if 400 <= status_code < 500:
|
| 59 |
+
return "bad_request"
|
| 60 |
+
return "upstream_error"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def openai_error_payload(
|
| 64 |
+
detail: object,
|
| 65 |
+
status_code: int,
|
| 66 |
+
*,
|
| 67 |
+
error_type: str | None = None,
|
| 68 |
+
code: object | None = None,
|
| 69 |
+
param: object | None = None,
|
| 70 |
+
) -> dict[str, Any]:
|
| 71 |
+
error_detail = detail.get("error") if isinstance(detail, dict) else None
|
| 72 |
+
if isinstance(error_detail, dict):
|
| 73 |
+
return {
|
| 74 |
+
"error": {
|
| 75 |
+
"message": error_message_from_detail(error_detail) or "request failed",
|
| 76 |
+
"type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
|
| 77 |
+
"param": error_detail.get("param", param),
|
| 78 |
+
"code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return {
|
| 82 |
+
"error": {
|
| 83 |
+
"message": error_message_from_detail(detail) or "request failed",
|
| 84 |
+
"type": error_type or _default_error_type(status_code),
|
| 85 |
+
"param": param,
|
| 86 |
+
"code": code if code is not None else _default_error_code(status_code),
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def openai_error_response(
|
| 92 |
+
detail: object,
|
| 93 |
+
status_code: int,
|
| 94 |
+
*,
|
| 95 |
+
headers: dict[str, str] | None = None,
|
| 96 |
+
error_type: str | None = None,
|
| 97 |
+
code: object | None = None,
|
| 98 |
+
param: object | None = None,
|
| 99 |
+
) -> JSONResponse:
|
| 100 |
+
return JSONResponse(
|
| 101 |
+
status_code=status_code,
|
| 102 |
+
content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
|
| 103 |
+
headers=headers,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def anthropic_error_response(
|
| 108 |
+
detail: object,
|
| 109 |
+
status_code: int,
|
| 110 |
+
*,
|
| 111 |
+
headers: dict[str, str] | None = None,
|
| 112 |
+
) -> JSONResponse:
|
| 113 |
+
error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
|
| 114 |
+
return JSONResponse(
|
| 115 |
+
status_code=status_code,
|
| 116 |
+
content={
|
| 117 |
+
"type": "error",
|
| 118 |
+
"error": {
|
| 119 |
+
"type": error_type,
|
| 120 |
+
"message": error_message_from_detail(detail) or "request failed",
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
headers=headers,
|
| 124 |
+
)
|
services/protocol/openai_v1_chat_complete.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Any, Iterable, Iterator
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
|
| 10 |
+
from services.protocol.conversation import (
|
| 11 |
+
ConversationRequest,
|
| 12 |
+
ImageOutput,
|
| 13 |
+
collect_image_outputs,
|
| 14 |
+
collect_text,
|
| 15 |
+
count_message_image_tokens,
|
| 16 |
+
count_message_text_tokens,
|
| 17 |
+
count_text_tokens,
|
| 18 |
+
encode_images,
|
| 19 |
+
normalize_messages,
|
| 20 |
+
stream_image_outputs_with_pool,
|
| 21 |
+
stream_text_deltas,
|
| 22 |
+
text_backend,
|
| 23 |
+
)
|
| 24 |
+
from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
|
| 25 |
+
from utils.image_tokens import (
|
| 26 |
+
chat_usage_from_image_usage,
|
| 27 |
+
count_image_inputs_tokens,
|
| 28 |
+
count_image_output_items_tokens,
|
| 29 |
+
image_usage,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
|
| 33 |
+
"This compatibility backend cannot execute local tools, shell commands, web searches, "
|
| 34 |
+
"or file operations. Do not claim to have run tools or inspected external resources. "
|
| 35 |
+
"If a user asks you to use a tool, say that tool execution is unavailable through this backend."
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
|
| 40 |
+
return {
|
| 41 |
+
"id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
|
| 42 |
+
"object": "chat.completion.chunk",
|
| 43 |
+
"created": created or int(time.time()),
|
| 44 |
+
"model": model,
|
| 45 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def completion_response(
|
| 50 |
+
model: str,
|
| 51 |
+
content: str,
|
| 52 |
+
created: int | None = None,
|
| 53 |
+
messages: list[dict[str, Any]] | None = None,
|
| 54 |
+
) -> dict[str, Any]:
|
| 55 |
+
prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0
|
| 56 |
+
prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0
|
| 57 |
+
prompt_tokens = prompt_text_tokens + prompt_image_tokens
|
| 58 |
+
completion_tokens = count_text_tokens(content, model) if messages else 0
|
| 59 |
+
return {
|
| 60 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 61 |
+
"object": "chat.completion",
|
| 62 |
+
"created": created or int(time.time()),
|
| 63 |
+
"model": model,
|
| 64 |
+
"choices": [{
|
| 65 |
+
"index": 0,
|
| 66 |
+
"message": {"role": "assistant", "content": content},
|
| 67 |
+
"finish_reason": "stop",
|
| 68 |
+
}],
|
| 69 |
+
"usage": {
|
| 70 |
+
"prompt_tokens": prompt_tokens,
|
| 71 |
+
"completion_tokens": completion_tokens,
|
| 72 |
+
"total_tokens": prompt_tokens + completion_tokens,
|
| 73 |
+
"prompt_tokens_details": {
|
| 74 |
+
"text_tokens": prompt_text_tokens,
|
| 75 |
+
"image_tokens": prompt_image_tokens,
|
| 76 |
+
"cached_tokens": 0,
|
| 77 |
+
},
|
| 78 |
+
"completion_tokens_details": {
|
| 79 |
+
"text_tokens": completion_tokens,
|
| 80 |
+
"image_tokens": 0,
|
| 81 |
+
"reasoning_tokens": 0,
|
| 82 |
+
},
|
| 83 |
+
},
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
|
| 88 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 89 |
+
created = int(time.time())
|
| 90 |
+
sent_role = False
|
| 91 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 92 |
+
for delta_text in stream_text_deltas(backend, request):
|
| 93 |
+
if not sent_role:
|
| 94 |
+
sent_role = True
|
| 95 |
+
yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
|
| 96 |
+
else:
|
| 97 |
+
yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
|
| 98 |
+
if not sent_role:
|
| 99 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 100 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
|
| 104 |
+
parts: list[str] = []
|
| 105 |
+
for chunk in chunks:
|
| 106 |
+
choices = chunk.get("choices")
|
| 107 |
+
first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 108 |
+
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
|
| 109 |
+
content = str(delta.get("content") or "")
|
| 110 |
+
if content:
|
| 111 |
+
parts.append(content)
|
| 112 |
+
return "".join(parts)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
| 116 |
+
messages = body.get("messages")
|
| 117 |
+
if isinstance(messages, list) and messages:
|
| 118 |
+
return [message for message in messages if isinstance(message, dict)]
|
| 119 |
+
prompt = str(body.get("prompt") or "").strip()
|
| 120 |
+
if prompt:
|
| 121 |
+
return [{"role": "user", "content": prompt}]
|
| 122 |
+
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
|
| 126 |
+
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 127 |
+
prompt = extract_chat_prompt(body)
|
| 128 |
+
if not prompt:
|
| 129 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 130 |
+
images = [
|
| 131 |
+
(data, f"image_{idx}.png", mime)
|
| 132 |
+
for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
|
| 133 |
+
]
|
| 134 |
+
return model, prompt, parse_image_count(body.get("n")), images
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
| 138 |
+
model = str(body.get("model") or "auto").strip() or "auto"
|
| 139 |
+
messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
|
| 140 |
+
tools = body.get("tools")
|
| 141 |
+
if isinstance(tools, list) and tools:
|
| 142 |
+
messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
|
| 143 |
+
return model, messages
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def image_result_content(result: dict[str, Any]) -> str:
|
| 147 |
+
data = result.get("data")
|
| 148 |
+
if isinstance(data, list) and data:
|
| 149 |
+
return build_chat_image_markdown_content(result)
|
| 150 |
+
return str(result.get("message") or "Image generation completed.")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
|
| 154 |
+
model, prompt, n, images = chat_image_args(body)
|
| 155 |
+
result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
|
| 156 |
+
prompt=prompt,
|
| 157 |
+
model=model,
|
| 158 |
+
n=n,
|
| 159 |
+
response_format="b64_json",
|
| 160 |
+
images=encode_images(images) or None,
|
| 161 |
+
)))
|
| 162 |
+
response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
|
| 163 |
+
usage = image_usage(
|
| 164 |
+
input_text_tokens=count_text_tokens(prompt, model),
|
| 165 |
+
input_image_tokens=count_image_inputs_tokens(images, model),
|
| 166 |
+
output_tokens=count_image_output_items_tokens(result.get("data")),
|
| 167 |
+
)
|
| 168 |
+
response["usage"] = chat_usage_from_image_usage(usage)
|
| 169 |
+
return response
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 173 |
+
model, prompt, n, images = chat_image_args(body)
|
| 174 |
+
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 175 |
+
prompt=prompt,
|
| 176 |
+
model=model,
|
| 177 |
+
n=n,
|
| 178 |
+
response_format="b64_json",
|
| 179 |
+
images=encode_images(images) or None,
|
| 180 |
+
))
|
| 181 |
+
yield from stream_image_chat_completion(image_outputs, model)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
|
| 185 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 186 |
+
created = int(time.time())
|
| 187 |
+
sent_role = False
|
| 188 |
+
sent_text = ""
|
| 189 |
+
for output in image_outputs:
|
| 190 |
+
content = ""
|
| 191 |
+
if output.kind == "progress":
|
| 192 |
+
content = output.text
|
| 193 |
+
sent_text += content
|
| 194 |
+
elif output.kind == "result":
|
| 195 |
+
content = build_chat_image_markdown_content({"data": output.data})
|
| 196 |
+
elif output.kind == "message":
|
| 197 |
+
content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
|
| 198 |
+
if not content:
|
| 199 |
+
continue
|
| 200 |
+
if not sent_role:
|
| 201 |
+
sent_role = True
|
| 202 |
+
yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
|
| 203 |
+
else:
|
| 204 |
+
yield completion_chunk(model, {"content": content}, None, completion_id, created)
|
| 205 |
+
if not sent_role:
|
| 206 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 207 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 211 |
+
if body.get("stream"):
|
| 212 |
+
if is_image_chat_request(body):
|
| 213 |
+
return image_chat_events(body)
|
| 214 |
+
model, messages = text_chat_parts(body)
|
| 215 |
+
key = cache_key(body, messages, stream=True)
|
| 216 |
+
return chat_completion_cache.get_or_compute_stream(
|
| 217 |
+
key,
|
| 218 |
+
lambda: stream_text_chat_completion(text_backend(), messages, model),
|
| 219 |
+
)
|
| 220 |
+
if is_image_chat_request(body):
|
| 221 |
+
return image_chat_response(body)
|
| 222 |
+
model, messages = text_chat_parts(body)
|
| 223 |
+
key = cache_key(body, messages, stream=False)
|
| 224 |
+
return chat_completion_cache.get_or_compute_response(
|
| 225 |
+
key,
|
| 226 |
+
lambda: completion_response(
|
| 227 |
+
model,
|
| 228 |
+
collect_text(text_backend(), ConversationRequest(model=model, messages=messages)),
|
| 229 |
+
messages=messages,
|
| 230 |
+
),
|
| 231 |
+
)
|