mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-03 00:02:28 +08:00
Compare commits
24 Commits
5f0c1fb347
...
7553b0da80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7553b0da80 | ||
|
|
8f30bf0bef | ||
|
|
8c12174521 | ||
|
|
6aa1876955 | ||
|
|
7f07122aea | ||
|
|
c2ddc6bd3c | ||
|
|
af476ff21e | ||
|
|
3bbc1c6b66 | ||
|
|
c69a0a8506 | ||
|
|
1fae202bde | ||
|
|
b9a26c4550 | ||
|
|
e42bd35d48 | ||
|
|
f22a073fd9 | ||
|
|
5c7ad089d2 | ||
|
|
97425ac68f | ||
|
|
912f6643e2 | ||
|
|
6c0373fda6 | ||
|
|
070121717d | ||
|
|
85fafeacb8 | ||
|
|
daf8b870f0 | ||
|
|
880fb61c66 | ||
|
|
7e792dabfc | ||
|
|
cd06169b2f | ||
|
|
50ffd47546 |
10
.env.example
10
.env.example
@@ -1,8 +1,16 @@
|
|||||||
# ==================== 必须配置(启动前) ====================
|
# ==================== 必须配置(启动前) ====================
|
||||||
# 以下配置项必须在项目启动前设置
|
# 以下配置项必须在项目启动前设置
|
||||||
|
|
||||||
# 数据库密码
|
# 数据库配置
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_NAME=aether
|
||||||
DB_PASSWORD=your_secure_password_here
|
DB_PASSWORD=your_secure_password_here
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=your_redis_password_here
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
|
|
||||||
# JWT密钥(使用 python generate_keys.py 生成)
|
# JWT密钥(使用 python generate_keys.py 生成)
|
||||||
|
|||||||
39
.github/workflows/docker-publish.yml
vendored
39
.github/workflows/docker-publish.yml
vendored
@@ -15,6 +15,8 @@ env:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
BASE_IMAGE_NAME: fawney19/aether-base
|
BASE_IMAGE_NAME: fawney19/aether-base
|
||||||
APP_IMAGE_NAME: fawney19/aether
|
APP_IMAGE_NAME: fawney19/aether
|
||||||
|
# Files that affect base image - used for hash calculation
|
||||||
|
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-base-changes:
|
check-base-changes:
|
||||||
@@ -23,8 +25,13 @@ jobs:
|
|||||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Check if base image needs rebuild
|
- name: Check if base image needs rebuild
|
||||||
id: check
|
id: check
|
||||||
@@ -34,10 +41,26 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if base-related files changed
|
# Calculate current hash of base-related files
|
||||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
|
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||||
|
echo "Current base files hash: $CURRENT_HASH"
|
||||||
|
|
||||||
|
# Try to get hash label from remote image config
|
||||||
|
# Pull the image config and extract labels
|
||||||
|
REMOTE_HASH=""
|
||||||
|
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
|
||||||
|
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||||
|
# No remote image or no hash label, need to rebuild
|
||||||
|
echo "No remote base image or hash label found, need rebuild"
|
||||||
|
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "$CURRENT_HASH" != "$REMOTE_HASH" ]; then
|
||||||
|
echo "Hash mismatch: remote=$REMOTE_HASH, current=$CURRENT_HASH"
|
||||||
echo "base_changed=true" >> $GITHUB_OUTPUT
|
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||||
else
|
else
|
||||||
|
echo "Hash matches, no rebuild needed"
|
||||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -61,6 +84,12 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Calculate base files hash
|
||||||
|
id: hash
|
||||||
|
run: |
|
||||||
|
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
||||||
|
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Extract metadata for base image
|
- name: Extract metadata for base image
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
@@ -69,6 +98,8 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||||
|
|
||||||
- name: Build and push base image
|
- name: Build and push base image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -117,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update Dockerfile.app to use registry base image
|
- name: Update Dockerfile.app to use registry base image
|
||||||
run: |
|
run: |
|
||||||
sed -i "s|FROM aether-base:latest|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest|g" Dockerfile.app
|
sed -i "s|FROM aether-base:latest AS builder|FROM ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest AS builder|g" Dockerfile.app
|
||||||
|
|
||||||
- name: Build and push app image
|
- name: Build and push app image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
|
|||||||
132
Dockerfile.app
132
Dockerfile.app
@@ -1,16 +1,134 @@
|
|||||||
# 应用镜像:基于基础镜像,只复制代码(秒级构建)
|
# 运行镜像:从 base 提取产物到精简运行时
|
||||||
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
# 构建命令: docker build -f Dockerfile.app -t aether-app:latest .
|
||||||
FROM aether-base:latest
|
# 用于 GitHub Actions CI(官方源)
|
||||||
|
FROM aether-base:latest AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制前端源码并构建
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
# ==================== 运行时镜像 ====================
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 运行时依赖(无 gcc/nodejs/npm)
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 从 base 镜像复制 Python 包
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
|
||||||
|
# 只复制需要的 Python 可执行文件
|
||||||
|
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||||
|
|
||||||
|
# 从 builder 阶段复制前端构建产物
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
# 复制后端代码
|
# 复制后端代码
|
||||||
COPY src/ ./src/
|
COPY src/ ./src/
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
# 构建前端(使用基础镜像中已安装的 node_modules)
|
# Nginx 配置模板
|
||||||
COPY frontend/ /tmp/frontend/
|
RUN printf '%s\n' \
|
||||||
RUN cd /tmp/frontend && npm run build && \
|
'server {' \
|
||||||
cp -r dist/* /usr/share/nginx/html/ && \
|
' listen 80;' \
|
||||||
rm -rf /tmp/frontend
|
' server_name _;' \
|
||||||
|
' root /usr/share/nginx/html;' \
|
||||||
|
' index index.html;' \
|
||||||
|
' client_max_body_size 100M;' \
|
||||||
|
'' \
|
||||||
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
|
' expires 1y;' \
|
||||||
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
|
' try_files $uri =404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(src|node_modules)/ {' \
|
||||||
|
' deny all;' \
|
||||||
|
' return 404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||||
|
' try_files $uri $uri/ /index.html;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location / {' \
|
||||||
|
' try_files $uri $uri/ @backend;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location @backend {' \
|
||||||
|
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||||
|
' proxy_http_version 1.1;' \
|
||||||
|
' proxy_set_header Host $host;' \
|
||||||
|
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||||
|
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||||
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' proxy_set_header Connection "";' \
|
||||||
|
' proxy_set_header Accept $http_accept;' \
|
||||||
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
|
' proxy_buffering off;' \
|
||||||
|
' proxy_cache off;' \
|
||||||
|
' proxy_request_buffering off;' \
|
||||||
|
' chunked_transfer_encoding on;' \
|
||||||
|
' gzip off;' \
|
||||||
|
' add_header X-Accel-Buffering no;' \
|
||||||
|
' proxy_connect_timeout 600s;' \
|
||||||
|
' proxy_send_timeout 600s;' \
|
||||||
|
' proxy_read_timeout 600s;' \
|
||||||
|
' }' \
|
||||||
|
'}' > /etc/nginx/sites-available/default.template
|
||||||
|
|
||||||
|
# Supervisor 配置
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[supervisord]' \
|
||||||
|
'nodaemon=true' \
|
||||||
|
'logfile=/var/log/supervisor/supervisord.log' \
|
||||||
|
'pidfile=/var/run/supervisord.pid' \
|
||||||
|
'' \
|
||||||
|
'[program:nginx]' \
|
||||||
|
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/var/log/nginx/access.log' \
|
||||||
|
'stderr_logfile=/var/log/nginx/error.log' \
|
||||||
|
'' \
|
||||||
|
'[program:app]' \
|
||||||
|
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||||
|
'directory=/app' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/dev/stdout' \
|
||||||
|
'stdout_logfile_maxbytes=0' \
|
||||||
|
'stderr_logfile=/dev/stderr' \
|
||||||
|
'stderr_logfile_maxbytes=0' \
|
||||||
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
PORT=8084
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
|
|||||||
135
Dockerfile.app.local
Normal file
135
Dockerfile.app.local
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# 运行镜像:从 base 提取产物到精简运行时(国内镜像源版本)
|
||||||
|
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
|
# 用于本地/国内服务器部署
|
||||||
|
FROM aether-base:latest AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 复制前端源码并构建
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
# ==================== 运行时镜像 ====================
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 运行时依赖(使用清华镜像源)
|
||||||
|
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
|
apt-get update && apt-get install -y \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
libpq5 \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 从 base 镜像复制 Python 包
|
||||||
|
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
|
||||||
|
|
||||||
|
# 只复制需要的 Python 可执行文件
|
||||||
|
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||||
|
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||||
|
|
||||||
|
# 从 builder 阶段复制前端构建产物
|
||||||
|
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# 复制后端代码
|
||||||
|
COPY src/ ./src/
|
||||||
|
COPY alembic.ini ./
|
||||||
|
COPY alembic/ ./alembic/
|
||||||
|
|
||||||
|
# Nginx 配置模板
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'server {' \
|
||||||
|
' listen 80;' \
|
||||||
|
' server_name _;' \
|
||||||
|
' root /usr/share/nginx/html;' \
|
||||||
|
' index index.html;' \
|
||||||
|
' client_max_body_size 100M;' \
|
||||||
|
'' \
|
||||||
|
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
||||||
|
' expires 1y;' \
|
||||||
|
' add_header Cache-Control "public, no-transform";' \
|
||||||
|
' try_files $uri =404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(src|node_modules)/ {' \
|
||||||
|
' deny all;' \
|
||||||
|
' return 404;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
||||||
|
' try_files $uri $uri/ /index.html;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location / {' \
|
||||||
|
' try_files $uri $uri/ @backend;' \
|
||||||
|
' }' \
|
||||||
|
'' \
|
||||||
|
' location @backend {' \
|
||||||
|
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
||||||
|
' proxy_http_version 1.1;' \
|
||||||
|
' proxy_set_header Host $host;' \
|
||||||
|
' proxy_set_header X-Real-IP $remote_addr;' \
|
||||||
|
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
||||||
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' proxy_set_header Connection "";' \
|
||||||
|
' proxy_set_header Accept $http_accept;' \
|
||||||
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
|
' proxy_buffering off;' \
|
||||||
|
' proxy_cache off;' \
|
||||||
|
' proxy_request_buffering off;' \
|
||||||
|
' chunked_transfer_encoding on;' \
|
||||||
|
' gzip off;' \
|
||||||
|
' add_header X-Accel-Buffering no;' \
|
||||||
|
' proxy_connect_timeout 600s;' \
|
||||||
|
' proxy_send_timeout 600s;' \
|
||||||
|
' proxy_read_timeout 600s;' \
|
||||||
|
' }' \
|
||||||
|
'}' > /etc/nginx/sites-available/default.template
|
||||||
|
|
||||||
|
# Supervisor 配置
|
||||||
|
RUN printf '%s\n' \
|
||||||
|
'[supervisord]' \
|
||||||
|
'nodaemon=true' \
|
||||||
|
'logfile=/var/log/supervisor/supervisord.log' \
|
||||||
|
'pidfile=/var/run/supervisord.pid' \
|
||||||
|
'' \
|
||||||
|
'[program:nginx]' \
|
||||||
|
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/var/log/nginx/access.log' \
|
||||||
|
'stderr_logfile=/var/log/nginx/error.log' \
|
||||||
|
'' \
|
||||||
|
'[program:app]' \
|
||||||
|
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
||||||
|
'directory=/app' \
|
||||||
|
'autostart=true' \
|
||||||
|
'autorestart=true' \
|
||||||
|
'stdout_logfile=/dev/stdout' \
|
||||||
|
'stdout_logfile_maxbytes=0' \
|
||||||
|
'stderr_logfile=/dev/stderr' \
|
||||||
|
'stderr_logfile_maxbytes=0' \
|
||||||
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
# 创建目录
|
||||||
|
RUN mkdir -p /var/log/supervisor /app/logs /app/data
|
||||||
|
|
||||||
|
# 环境变量
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONIOENCODING=utf-8 \
|
||||||
|
LANG=C.UTF-8 \
|
||||||
|
LC_ALL=C.UTF-8 \
|
||||||
|
PORT=8084
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
117
Dockerfile.base
117
Dockerfile.base
@@ -1,122 +1,25 @@
|
|||||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
# 构建镜像:编译环境 + 预编译的依赖
|
||||||
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
# 用于 GitHub Actions CI 构建(不使用国内镜像源)
|
||||||
|
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||||
|
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 系统依赖
|
# 构建工具
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
nginx \
|
|
||||||
supervisor \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Python 依赖(安装到系统,不用 -e 模式)
|
# Python 依赖
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
RUN mkdir -p src && touch src/__init__.py && \
|
RUN mkdir -p src && touch src/__init__.py && \
|
||||||
pip install --no-cache-dir .
|
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||||
|
pip cache purge
|
||||||
|
|
||||||
# 前端依赖
|
# 前端依赖(只安装,不构建)
|
||||||
COPY frontend/package*.json /tmp/frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
WORKDIR /tmp/frontend
|
RUN cd frontend && npm ci
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Nginx 配置模板
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'server {' \
|
|
||||||
' listen 80;' \
|
|
||||||
' server_name _;' \
|
|
||||||
' root /usr/share/nginx/html;' \
|
|
||||||
' index index.html;' \
|
|
||||||
' client_max_body_size 100M;' \
|
|
||||||
'' \
|
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
|
||||||
' expires 1y;' \
|
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
|
||||||
' try_files $uri =404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(src|node_modules)/ {' \
|
|
||||||
' deny all;' \
|
|
||||||
' return 404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
|
||||||
' try_files $uri $uri/ /index.html;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location / {' \
|
|
||||||
' try_files $uri $uri/ @backend;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location @backend {' \
|
|
||||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
|
||||||
' proxy_http_version 1.1;' \
|
|
||||||
' proxy_set_header Host $host;' \
|
|
||||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
|
||||||
' proxy_set_header Connection "";' \
|
|
||||||
' proxy_set_header Accept $http_accept;' \
|
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
|
||||||
' proxy_buffering off;' \
|
|
||||||
' proxy_cache off;' \
|
|
||||||
' proxy_request_buffering off;' \
|
|
||||||
' chunked_transfer_encoding on;' \
|
|
||||||
' proxy_connect_timeout 600s;' \
|
|
||||||
' proxy_send_timeout 600s;' \
|
|
||||||
' proxy_read_timeout 600s;' \
|
|
||||||
' }' \
|
|
||||||
'}' > /etc/nginx/sites-available/default.template
|
|
||||||
|
|
||||||
# Supervisor 配置
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'[supervisord]' \
|
|
||||||
'nodaemon=true' \
|
|
||||||
'logfile=/var/log/supervisor/supervisord.log' \
|
|
||||||
'pidfile=/var/run/supervisord.pid' \
|
|
||||||
'' \
|
|
||||||
'[program:nginx]' \
|
|
||||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/var/log/nginx/access.log' \
|
|
||||||
'stderr_logfile=/var/log/nginx/error.log' \
|
|
||||||
'' \
|
|
||||||
'[program:app]' \
|
|
||||||
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
|
||||||
'directory=/app' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/dev/stdout' \
|
|
||||||
'stdout_logfile_maxbytes=0' \
|
|
||||||
'stderr_logfile=/dev/stderr' \
|
|
||||||
'stderr_logfile_maxbytes=0' \
|
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
|
|
||||||
# 创建目录
|
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 环境变量
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONIOENCODING=utf-8 \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
PORT=8084
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost/health || exit 1
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
# 基础镜像:包含所有依赖,只在依赖变化时需要重建
|
# 构建镜像:编译环境 + 预编译的依赖(国内镜像源版本)
|
||||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
# 构建命令: docker build -f Dockerfile.base.local -t aether-base:latest .
|
||||||
|
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 系统依赖
|
# 构建工具(使用清华镜像源)
|
||||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
|
||||||
apt-get update && apt-get install -y \
|
apt-get update && apt-get install -y \
|
||||||
nginx \
|
|
||||||
supervisor \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
gcc \
|
gcc \
|
||||||
curl \
|
|
||||||
gettext-base \
|
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
@@ -20,107 +17,12 @@ RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.li
|
|||||||
# pip 镜像源
|
# pip 镜像源
|
||||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
|
||||||
# Python 依赖(安装到系统,不用 -e 模式)
|
# Python 依赖
|
||||||
COPY pyproject.toml README.md ./
|
COPY pyproject.toml README.md ./
|
||||||
RUN mkdir -p src && touch src/__init__.py && \
|
RUN mkdir -p src && touch src/__init__.py && \
|
||||||
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir .
|
SETUPTOOLS_SCM_PRETEND_VERSION=0.1.0 pip install --no-cache-dir . && \
|
||||||
|
pip cache purge
|
||||||
|
|
||||||
# 前端依赖
|
# 前端依赖(只安装,不构建,使用淘宝镜像源)
|
||||||
COPY frontend/package*.json /tmp/frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
WORKDIR /tmp/frontend
|
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||||
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
|
||||||
|
|
||||||
# Nginx 配置模板
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'server {' \
|
|
||||||
' listen 80;' \
|
|
||||||
' server_name _;' \
|
|
||||||
' root /usr/share/nginx/html;' \
|
|
||||||
' index index.html;' \
|
|
||||||
' client_max_body_size 100M;' \
|
|
||||||
'' \
|
|
||||||
' location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {' \
|
|
||||||
' expires 1y;' \
|
|
||||||
' add_header Cache-Control "public, no-transform";' \
|
|
||||||
' try_files $uri =404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(src|node_modules)/ {' \
|
|
||||||
' deny all;' \
|
|
||||||
' return 404;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location ~ ^/(dashboard|admin|login)(/|$) {' \
|
|
||||||
' try_files $uri $uri/ /index.html;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location / {' \
|
|
||||||
' try_files $uri $uri/ @backend;' \
|
|
||||||
' }' \
|
|
||||||
'' \
|
|
||||||
' location @backend {' \
|
|
||||||
' proxy_pass http://127.0.0.1:PORT_PLACEHOLDER;' \
|
|
||||||
' proxy_http_version 1.1;' \
|
|
||||||
' proxy_set_header Host $host;' \
|
|
||||||
' proxy_set_header X-Real-IP $remote_addr;' \
|
|
||||||
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;' \
|
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
|
||||||
' proxy_set_header Connection "";' \
|
|
||||||
' proxy_set_header Accept $http_accept;' \
|
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
|
||||||
' proxy_buffering off;' \
|
|
||||||
' proxy_cache off;' \
|
|
||||||
' proxy_request_buffering off;' \
|
|
||||||
' chunked_transfer_encoding on;' \
|
|
||||||
' proxy_connect_timeout 600s;' \
|
|
||||||
' proxy_send_timeout 600s;' \
|
|
||||||
' proxy_read_timeout 600s;' \
|
|
||||||
' }' \
|
|
||||||
'}' > /etc/nginx/sites-available/default.template
|
|
||||||
|
|
||||||
# Supervisor 配置
|
|
||||||
RUN printf '%s\n' \
|
|
||||||
'[supervisord]' \
|
|
||||||
'nodaemon=true' \
|
|
||||||
'logfile=/var/log/supervisor/supervisord.log' \
|
|
||||||
'pidfile=/var/run/supervisord.pid' \
|
|
||||||
'' \
|
|
||||||
'[program:nginx]' \
|
|
||||||
'command=/bin/bash -c "sed \"s/PORT_PLACEHOLDER/${PORT:-8084}/g\" /etc/nginx/sites-available/default.template > /etc/nginx/sites-available/default && /usr/sbin/nginx -g \"daemon off;\""' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/var/log/nginx/access.log' \
|
|
||||||
'stderr_logfile=/var/log/nginx/error.log' \
|
|
||||||
'' \
|
|
||||||
'[program:app]' \
|
|
||||||
'command=gunicorn src.main:app -w %(ENV_GUNICORN_WORKERS)s -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:%(ENV_PORT)s --timeout 120 --access-logfile - --error-logfile - --log-level info' \
|
|
||||||
'directory=/app' \
|
|
||||||
'autostart=true' \
|
|
||||||
'autorestart=true' \
|
|
||||||
'stdout_logfile=/dev/stdout' \
|
|
||||||
'stdout_logfile_maxbytes=0' \
|
|
||||||
'stderr_logfile=/dev/stderr' \
|
|
||||||
'stderr_logfile_maxbytes=0' \
|
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' > /etc/supervisor/conf.d/supervisord.conf
|
|
||||||
|
|
||||||
# 创建目录
|
|
||||||
RUN mkdir -p /var/log/supervisor /app/logs /app/data /usr/share/nginx/html
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 环境变量
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
|
||||||
PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONIOENCODING=utf-8 \
|
|
||||||
LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
PORT=8084
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost/health || exit 1
|
|
||||||
|
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
|
||||||
|
|||||||
15
deploy.sh
15
deploy.sh
@@ -21,9 +21,9 @@ HASH_FILE=".deps-hash"
|
|||||||
CODE_HASH_FILE=".code-hash"
|
CODE_HASH_FILE=".code-hash"
|
||||||
MIGRATION_HASH_FILE=".migration-hash"
|
MIGRATION_HASH_FILE=".migration-hash"
|
||||||
|
|
||||||
# 计算依赖文件的哈希值
|
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||||
calc_deps_hash() {
|
calc_deps_hash() {
|
||||||
cat pyproject.toml frontend/package.json frontend/package-lock.json 2>/dev/null | md5sum | cut -d' ' -f1
|
cat pyproject.toml frontend/package.json frontend/package-lock.json Dockerfile.base.local 2>/dev/null | md5sum | cut -d' ' -f1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算代码文件的哈希值
|
# 计算代码文件的哈希值
|
||||||
@@ -88,7 +88,7 @@ build_base() {
|
|||||||
# 构建应用镜像
|
# 构建应用镜像
|
||||||
build_app() {
|
build_app() {
|
||||||
echo ">>> Building app image (code only)..."
|
echo ">>> Building app image (code only)..."
|
||||||
docker build -f Dockerfile.app -t aether-app:latest .
|
docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
save_code_hash
|
save_code_hash
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,25 +162,32 @@ git pull
|
|||||||
|
|
||||||
# 标记是否需要重启
|
# 标记是否需要重启
|
||||||
NEED_RESTART=false
|
NEED_RESTART=false
|
||||||
|
BASE_REBUILT=false
|
||||||
|
|
||||||
# 检查基础镜像是否存在,或依赖是否变化
|
# 检查基础镜像是否存在,或依赖是否变化
|
||||||
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
|
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
|
||||||
echo ">>> Base image not found, building..."
|
echo ">>> Base image not found, building..."
|
||||||
build_base
|
build_base
|
||||||
|
BASE_REBUILT=true
|
||||||
NEED_RESTART=true
|
NEED_RESTART=true
|
||||||
elif check_deps_changed; then
|
elif check_deps_changed; then
|
||||||
echo ">>> Dependencies changed, rebuilding base image..."
|
echo ">>> Dependencies changed, rebuilding base image..."
|
||||||
build_base
|
build_base
|
||||||
|
BASE_REBUILT=true
|
||||||
NEED_RESTART=true
|
NEED_RESTART=true
|
||||||
else
|
else
|
||||||
echo ">>> Dependencies unchanged."
|
echo ">>> Dependencies unchanged."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查代码是否变化
|
# 检查代码是否变化,或者 base 重建了(app 依赖 base)
|
||||||
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
|
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
|
||||||
echo ">>> App image not found, building..."
|
echo ">>> App image not found, building..."
|
||||||
build_app
|
build_app
|
||||||
NEED_RESTART=true
|
NEED_RESTART=true
|
||||||
|
elif [ "$BASE_REBUILT" = true ]; then
|
||||||
|
echo ">>> Base image rebuilt, rebuilding app image..."
|
||||||
|
build_app
|
||||||
|
NEED_RESTART=true
|
||||||
elif check_code_changed; then
|
elif check_code_changed; then
|
||||||
echo ">>> Code changed, rebuilding app image..."
|
echo ">>> Code changed, rebuilding app image..."
|
||||||
build_app
|
build_app
|
||||||
|
|||||||
3
dev.sh
3
dev.sh
@@ -8,7 +8,8 @@ source .env
|
|||||||
set +a
|
set +a
|
||||||
|
|
||||||
# 构建 DATABASE_URL
|
# 构建 DATABASE_URL
|
||||||
export DATABASE_URL="postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether"
|
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
|
||||||
|
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
|
||||||
|
|
||||||
# 启动 uvicorn(热重载模式)
|
# 启动 uvicorn(热重载模式)
|
||||||
echo "🚀 启动本地开发服务器..."
|
echo "🚀 启动本地开发服务器..."
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ services:
|
|||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.app
|
dockerfile: Dockerfile.app.local
|
||||||
image: aether-app:latest
|
image: aether-app:latest
|
||||||
container_name: aether-app
|
container_name: aether-app
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface UserAffinity {
|
|||||||
key_name: string | null
|
key_name: string | null
|
||||||
key_prefix: string | null // Provider Key 脱敏显示(前4...后4)
|
key_prefix: string | null // Provider Key 脱敏显示(前4...后4)
|
||||||
rate_multiplier: number
|
rate_multiplier: number
|
||||||
|
global_model_id: string | null // 原始的 global_model_id(用于删除)
|
||||||
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514)
|
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514)
|
||||||
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5)
|
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5)
|
||||||
api_format: string | null // API 格式 (claude/openai)
|
api_format: string | null // API 格式 (claude/openai)
|
||||||
@@ -119,6 +120,18 @@ export const cacheApi = {
|
|||||||
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
|
await api.delete(`/api/admin/monitoring/cache/users/${userIdentifier}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除单条缓存亲和性
|
||||||
|
*
|
||||||
|
* @param affinityKey API Key ID
|
||||||
|
* @param endpointId Endpoint ID
|
||||||
|
* @param modelId GlobalModel ID
|
||||||
|
* @param apiFormat API 格式 (claude/openai)
|
||||||
|
*/
|
||||||
|
async clearSingleAffinity(affinityKey: string, endpointId: string, modelId: string, apiFormat: string): Promise<void> {
|
||||||
|
await api.delete(`/api/admin/monitoring/cache/affinity/${affinityKey}/${endpointId}/${modelId}/${apiFormat}`)
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除所有缓存
|
* 清除所有缓存
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useSlots, type Component } from 'vue'
|
import { computed, useSlots, type Component } from 'vue'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
|
|
||||||
// Props 定义
|
// Props 定义
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -157,4 +158,14 @@ const maxWidthClass = computed(() => {
|
|||||||
const containerZIndex = computed(() => props.zIndex || 60)
|
const containerZIndex = computed(() => props.zIndex || 60)
|
||||||
const backdropZIndex = computed(() => props.zIndex || 60)
|
const backdropZIndex = computed(() => props.zIndex || 60)
|
||||||
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
80
frontend/src/composables/useEscapeKey.ts
Normal file
80
frontend/src/composables/useEscapeKey.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESC 键监听 Composable(简化版本,直接使用独立监听器)
|
||||||
|
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
|
||||||
|
*
|
||||||
|
* @param callback - 按 ESC 键时执行的回调函数
|
||||||
|
* @param options - 配置选项
|
||||||
|
*/
|
||||||
|
export function useEscapeKey(
|
||||||
|
callback: () => void,
|
||||||
|
options: {
|
||||||
|
/** 是否在输入框获得焦点时禁用 ESC 键,默认 true */
|
||||||
|
disableOnInput?: boolean
|
||||||
|
/** 是否只监听一次,默认 false */
|
||||||
|
once?: boolean
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { disableOnInput = true, once = false } = options
|
||||||
|
const isActive = ref(true)
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
// 只处理 ESC 键
|
||||||
|
if (event.key !== 'Escape') return
|
||||||
|
|
||||||
|
// 检查组件是否还活跃
|
||||||
|
if (!isActive.value) return
|
||||||
|
|
||||||
|
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
|
||||||
|
if (disableOnInput) {
|
||||||
|
const activeElement = document.activeElement
|
||||||
|
const isInputElement = activeElement && (
|
||||||
|
activeElement.tagName === 'INPUT' ||
|
||||||
|
activeElement.tagName === 'TEXTAREA' ||
|
||||||
|
activeElement.tagName === 'SELECT' ||
|
||||||
|
activeElement.contentEditable === 'true' ||
|
||||||
|
activeElement.getAttribute('role') === 'textbox' ||
|
||||||
|
activeElement.getAttribute('role') === 'combobox'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果焦点在输入框中,不处理 ESC 键
|
||||||
|
if (isInputElement) return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行回调
|
||||||
|
callback()
|
||||||
|
|
||||||
|
// 移除当前元素的焦点,避免残留样式
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只监听一次,则移除监听器
|
||||||
|
if (once) {
|
||||||
|
removeEventListener()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEventListener() {
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEventListener() {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addEventListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isActive.value = false
|
||||||
|
removeEventListener()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEventListener,
|
||||||
|
removeEventListener
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -698,6 +698,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
BarChart3
|
BarChart3
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
@@ -833,6 +834,16 @@ watch(() => props.open, (newOpen) => {
|
|||||||
detailTab.value = 'basic'
|
detailTab.value = 'basic'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -655,6 +655,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
Copy
|
Copy
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
@@ -1296,6 +1297,16 @@ async function loadEndpoints() {
|
|||||||
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -472,6 +472,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
import Separator from '@/components/ui/separator.vue'
|
import Separator from '@/components/ui/separator.vue'
|
||||||
@@ -897,6 +898,16 @@ const providerHeadersWithDiff = computed(() => {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -136,11 +136,20 @@
|
|||||||
<!-- 分隔线 -->
|
<!-- 分隔线 -->
|
||||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 自动刷新按钮 -->
|
||||||
<RefreshButton
|
<Button
|
||||||
:loading="loading"
|
variant="ghost"
|
||||||
@click="$emit('refresh')"
|
size="icon"
|
||||||
/>
|
class="h-8 w-8"
|
||||||
|
:class="autoRefresh ? 'text-primary' : ''"
|
||||||
|
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新(每10秒刷新)'"
|
||||||
|
@click="$emit('update:autoRefresh', !autoRefresh)"
|
||||||
|
>
|
||||||
|
<RefreshCcw
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
:class="autoRefresh ? 'animate-spin' : ''"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
@@ -408,6 +417,7 @@ import { ref, computed, onUnmounted, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
TableCard,
|
TableCard,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Select,
|
Select,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
@@ -420,8 +430,8 @@ import {
|
|||||||
TableHead,
|
TableHead,
|
||||||
TableCell,
|
TableCell,
|
||||||
Pagination,
|
Pagination,
|
||||||
RefreshButton,
|
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
import { RefreshCcw } from 'lucide-vue-next'
|
||||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||||
import { formatDateTime } from '../composables'
|
import { formatDateTime } from '../composables'
|
||||||
import { useRowClick } from '@/composables/useRowClick'
|
import { useRowClick } from '@/composables/useRowClick'
|
||||||
@@ -453,6 +463,8 @@ const props = defineProps<{
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
totalRecords: number
|
totalRecords: number
|
||||||
pageSizeOptions: number[]
|
pageSizeOptions: number[]
|
||||||
|
// 自动刷新
|
||||||
|
autoRefresh: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -463,6 +475,7 @@ const emit = defineEmits<{
|
|||||||
'update:filterStatus': [value: string]
|
'update:filterStatus': [value: string]
|
||||||
'update:currentPage': [value: number]
|
'update:currentPage': [value: number]
|
||||||
'update:pageSize': [value: number]
|
'update:pageSize': [value: number]
|
||||||
|
'update:autoRefresh': [value: boolean]
|
||||||
'refresh': []
|
'refresh': []
|
||||||
'showDetail': [id: string]
|
'showDetail': [id: string]
|
||||||
}>()
|
}>()
|
||||||
|
|||||||
@@ -142,32 +142,37 @@ async function resetAffinitySearch() {
|
|||||||
await fetchAffinityList()
|
await fetchAffinityList()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearUserCache(identifier: string, displayName?: string) {
|
async function clearSingleAffinity(item: UserAffinity) {
|
||||||
const target = identifier?.trim()
|
const affinityKey = item.affinity_key?.trim()
|
||||||
if (!target) {
|
const endpointId = item.endpoint_id?.trim()
|
||||||
showError('无法识别标识符')
|
const modelId = item.global_model_id?.trim()
|
||||||
|
const apiFormat = item.api_format?.trim()
|
||||||
|
|
||||||
|
if (!affinityKey || !endpointId || !modelId || !apiFormat) {
|
||||||
|
showError('缓存记录信息不完整,无法删除')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = displayName || target
|
const label = item.user_api_key_name || affinityKey
|
||||||
|
const modelLabel = item.model_display_name || item.model_name || modelId
|
||||||
const confirmed = await showConfirm({
|
const confirmed = await showConfirm({
|
||||||
title: '确认清除',
|
title: '确认清除',
|
||||||
message: `确定要清除 ${label} 的缓存吗?`,
|
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
|
||||||
confirmText: '确认清除',
|
confirmText: '确认清除',
|
||||||
variant: 'destructive'
|
variant: 'destructive'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
clearingRowAffinityKey.value = target
|
clearingRowAffinityKey.value = affinityKey
|
||||||
try {
|
try {
|
||||||
await cacheApi.clearUserCache(target)
|
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
|
||||||
showSuccess('清除成功')
|
showSuccess('清除成功')
|
||||||
await fetchCacheStats()
|
await fetchCacheStats()
|
||||||
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError('清除失败')
|
showError('清除失败')
|
||||||
log.error('清除用户缓存失败', error)
|
log.error('清除单条缓存失败', error)
|
||||||
} finally {
|
} finally {
|
||||||
clearingRowAffinityKey.value = null
|
clearingRowAffinityKey.value = null
|
||||||
}
|
}
|
||||||
@@ -618,7 +623,7 @@ onBeforeUnmount(() => {
|
|||||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||||
title="清除缓存"
|
title="清除缓存"
|
||||||
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
|
@click="clearSingleAffinity(item)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -668,7 +673,7 @@ onBeforeUnmount(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
|
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
|
||||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||||
@click="clearUserCache(item.affinity_key, item.user_api_key_name || item.affinity_key)"
|
@click="clearSingleAffinity(item)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3.5 w-3.5" />
|
<Trash2 class="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -464,6 +464,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardSection>
|
</CardSection>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 导入配置对话框 -->
|
<!-- 导入配置对话框 -->
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
:total-records="totalRecords"
|
:total-records="totalRecords"
|
||||||
:page-size-options="pageSizeOptions"
|
:page-size-options="pageSizeOptions"
|
||||||
|
:auto-refresh="globalAutoRefresh"
|
||||||
@update:selected-period="handlePeriodChange"
|
@update:selected-period="handlePeriodChange"
|
||||||
@update:filter-user="handleFilterUserChange"
|
@update:filter-user="handleFilterUserChange"
|
||||||
@update:filter-model="handleFilterModelChange"
|
@update:filter-model="handleFilterModelChange"
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
@update:filter-status="handleFilterStatusChange"
|
@update:filter-status="handleFilterStatusChange"
|
||||||
@update:current-page="handlePageChange"
|
@update:current-page="handlePageChange"
|
||||||
@update:page-size="handlePageSizeChange"
|
@update:page-size="handlePageSizeChange"
|
||||||
|
@update:auto-refresh="handleAutoRefreshChange"
|
||||||
@refresh="refreshData"
|
@refresh="refreshData"
|
||||||
@export="exportData"
|
@export="exportData"
|
||||||
@show-detail="showRequestDetail"
|
@show-detail="showRequestDetail"
|
||||||
@@ -214,7 +216,10 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
|||||||
|
|
||||||
// 自动刷新定时器
|
// 自动刷新定时器
|
||||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次
|
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次(用于活跃请求)
|
||||||
|
const GLOBAL_AUTO_REFRESH_INTERVAL = 10000 // 10秒刷新一次(全局自动刷新)
|
||||||
|
const globalAutoRefresh = ref(false) // 全局自动刷新开关
|
||||||
|
|
||||||
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
// 轮询活跃请求状态(轻量级,只更新状态变化的记录)
|
||||||
async function pollActiveRequests() {
|
async function pollActiveRequests() {
|
||||||
@@ -278,9 +283,34 @@ watch(hasActiveRequests, (hasActive) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 启动全局自动刷新
|
||||||
|
function startGlobalAutoRefresh() {
|
||||||
|
if (globalAutoRefreshTimer) return
|
||||||
|
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止全局自动刷新
|
||||||
|
function stopGlobalAutoRefresh() {
|
||||||
|
if (globalAutoRefreshTimer) {
|
||||||
|
clearInterval(globalAutoRefreshTimer)
|
||||||
|
globalAutoRefreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理自动刷新开关变化
|
||||||
|
function handleAutoRefreshChange(value: boolean) {
|
||||||
|
globalAutoRefresh.value = value
|
||||||
|
if (value) {
|
||||||
|
startGlobalAutoRefresh()
|
||||||
|
} else {
|
||||||
|
stopGlobalAutoRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
// 组件卸载时清理定时器
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
stopGlobalAutoRefresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 用户页面的前端分页
|
// 用户页面的前端分页
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Image as ImageIcon
|
Image as ImageIcon
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Badge from '@/components/ui/badge.vue'
|
import Badge from '@/components/ui/badge.vue'
|
||||||
@@ -453,6 +454,16 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
|
|||||||
if (!tieredPricing?.tiers?.length) return '-'
|
if (!tieredPricing?.tiers?.length) return '-'
|
||||||
return get1hCachePrice(tieredPricing.tiers[0])
|
return get1hCachePrice(tieredPricing.tiers[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加 ESC 键监听
|
||||||
|
useEscapeKey(() => {
|
||||||
|
if (props.open) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
disableOnInput: true,
|
||||||
|
once: false
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -186,6 +186,30 @@ async def clear_user_cache(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}")
|
||||||
|
async def clear_single_affinity(
|
||||||
|
affinity_key: str,
|
||||||
|
endpoint_id: str,
|
||||||
|
model_id: str,
|
||||||
|
api_format: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Clear a single cache affinity entry
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- affinity_key: API Key ID
|
||||||
|
- endpoint_id: Endpoint ID
|
||||||
|
- model_id: Model ID (GlobalModel ID)
|
||||||
|
- api_format: API format (claude/openai)
|
||||||
|
"""
|
||||||
|
adapter = AdminClearSingleAffinityAdapter(
|
||||||
|
affinity_key=affinity_key, endpoint_id=endpoint_id, model_id=model_id, api_format=api_format
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("")
|
@router.delete("")
|
||||||
async def clear_all_cache(
|
async def clear_all_cache(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -655,6 +679,7 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
|
|||||||
"key_name": key.name if key else None,
|
"key_name": key.name if key else None,
|
||||||
"key_prefix": provider_key_masked,
|
"key_prefix": provider_key_masked,
|
||||||
"rate_multiplier": key.rate_multiplier if key else 1.0,
|
"rate_multiplier": key.rate_multiplier if key else 1.0,
|
||||||
|
"global_model_id": affinity.get("model_name"), # 原始的 global_model_id
|
||||||
"model_name": (
|
"model_name": (
|
||||||
global_model_map.get(affinity.get("model_name")).name
|
global_model_map.get(affinity.get("model_name")).name
|
||||||
if affinity.get("model_name") and global_model_map.get(affinity.get("model_name"))
|
if affinity.get("model_name") and global_model_map.get(affinity.get("model_name"))
|
||||||
@@ -817,6 +842,65 @@ class AdminClearUserCacheAdapter(AdminApiAdapter):
|
|||||||
raise HTTPException(status_code=500, detail=f"清除失败: {exc}")
|
raise HTTPException(status_code=500, detail=f"清除失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminClearSingleAffinityAdapter(AdminApiAdapter):
|
||||||
|
affinity_key: str
|
||||||
|
endpoint_id: str
|
||||||
|
model_id: str
|
||||||
|
api_format: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
try:
|
||||||
|
redis_client = get_redis_client_sync()
|
||||||
|
affinity_mgr = await get_affinity_manager(redis_client)
|
||||||
|
|
||||||
|
# 直接获取指定的亲和性记录(无需遍历全部)
|
||||||
|
existing_affinity = await affinity_mgr.get_affinity(
|
||||||
|
self.affinity_key, self.api_format, self.model_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not existing_affinity:
|
||||||
|
raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录")
|
||||||
|
|
||||||
|
# 验证 endpoint_id 是否匹配
|
||||||
|
if existing_affinity.endpoint_id != self.endpoint_id:
|
||||||
|
raise HTTPException(status_code=404, detail="未找到指定的缓存亲和性记录")
|
||||||
|
|
||||||
|
# 失效单条记录
|
||||||
|
await affinity_mgr.invalidate_affinity(
|
||||||
|
self.affinity_key, self.api_format, self.model_id, endpoint_id=self.endpoint_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取用于日志的信息
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == self.affinity_key).first()
|
||||||
|
api_key_name = api_key.name if api_key else None
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"已清除单条缓存亲和性: affinity_key={self.affinity_key[:8]}..., "
|
||||||
|
f"endpoint_id={self.endpoint_id[:8]}..., model_id={self.model_id[:8]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="cache_clear_single",
|
||||||
|
affinity_key=self.affinity_key,
|
||||||
|
endpoint_id=self.endpoint_id,
|
||||||
|
model_id=self.model_id,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"已清除缓存亲和性: {api_key_name or self.affinity_key[:8]}",
|
||||||
|
"affinity_key": self.affinity_key,
|
||||||
|
"endpoint_id": self.endpoint_id,
|
||||||
|
"model_id": self.model_id,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"清除单条缓存亲和性失败: {exc}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"清除失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
class AdminClearAllCacheAdapter(AdminApiAdapter):
|
class AdminClearAllCacheAdapter(AdminApiAdapter):
|
||||||
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Provider Query API 端点
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -45,7 +46,11 @@ async def _fetch_openai_models(
|
|||||||
Returns:
|
Returns:
|
||||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {api_key}"}
|
useragent = os.getenv("OPENAI_USER_AGENT") or "codex_cli_rs/0.73.0 (Mac OS 14.8.4; x86_64) Apple_Terminal/453"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": useragent,
|
||||||
|
}
|
||||||
if extra_headers:
|
if extra_headers:
|
||||||
# 防止 extra_headers 覆盖 Authorization
|
# 防止 extra_headers 覆盖 Authorization
|
||||||
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
|
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
|
||||||
@@ -91,10 +96,12 @@ async def _fetch_claude_models(
|
|||||||
Returns:
|
Returns:
|
||||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
||||||
"""
|
"""
|
||||||
|
useragent = os.getenv("CLAUDE_USER_AGENT") or "claude-cli/2.0.62 (external, cli)"
|
||||||
headers = {
|
headers = {
|
||||||
"x-api-key": api_key,
|
"x-api-key": api_key,
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"anthropic-version": "2023-06-01",
|
"anthropic-version": "2023-06-01",
|
||||||
|
"User-Agent": useragent,
|
||||||
}
|
}
|
||||||
|
|
||||||
# 构建 /v1/models URL
|
# 构建 /v1/models URL
|
||||||
@@ -142,9 +149,12 @@ async def _fetch_gemini_models(
|
|||||||
models_url = f"{base_url_clean}/models?key={api_key}"
|
models_url = f"{base_url_clean}/models?key={api_key}"
|
||||||
else:
|
else:
|
||||||
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
|
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
|
||||||
|
useragent = os.getenv("GEMINI_USER_AGENT") or "gemini-cli/0.1.0 (external, cli)"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": useragent,
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
response = await client.get(models_url)
|
response = await client.get(models_url, headers=headers)
|
||||||
logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
|
logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Request
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
from src.api.base.admin_adapter import AdminApiAdapter
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.models_service import invalidate_models_list_cache
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -419,4 +420,8 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
|
|||||||
f"Batch assigned {len(success)} GlobalModels to provider {provider.name} by {context.user.username}"
|
f"Batch assigned {len(success)} GlobalModels to provider {provider.name} by {context.user.username}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
if success:
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
return BatchAssignModelsToProviderResponse(success=success, errors=errors)
|
||||||
|
|||||||
@@ -55,6 +55,23 @@ async def _set_cached_models(api_formats: list[str], models: list["ModelInfo"])
|
|||||||
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
|
logger.warning(f"[ModelsService] 缓存写入失败: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_models_list_cache() -> None:
|
||||||
|
"""
|
||||||
|
清除所有 /v1/models 列表缓存
|
||||||
|
|
||||||
|
在模型创建、更新、删除时调用,确保模型列表实时更新
|
||||||
|
"""
|
||||||
|
# 清除所有格式的缓存
|
||||||
|
all_formats = ["CLAUDE", "OPENAI", "GEMINI"]
|
||||||
|
for fmt in all_formats:
|
||||||
|
cache_key = f"{_CACHE_KEY_PREFIX}:{fmt}"
|
||||||
|
try:
|
||||||
|
await CacheService.delete(cache_key)
|
||||||
|
logger.debug(f"[ModelsService] 已清除缓存: {cache_key}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ModelsService] 清除缓存失败 {cache_key}: {e}")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ModelInfo:
|
class ModelInfo:
|
||||||
"""统一的模型信息结构"""
|
"""统一的模型信息结构"""
|
||||||
|
|||||||
@@ -639,6 +639,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
|
|
||||||
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
||||||
f"模型={model} -> {mapped_model or '无映射'}")
|
f"模型={model} -> {mapped_model or '无映射'}")
|
||||||
|
logger.debug(f" [{self.request_id}] 请求URL: {url}")
|
||||||
|
logger.debug(f" [{self.request_id}] 请求体stream字段: {provider_payload.get('stream', 'N/A')}")
|
||||||
|
|
||||||
# 创建 HTTP 客户端(支持代理配置)
|
# 创建 HTTP 客户端(支持代理配置)
|
||||||
from src.clients.http_client import HTTPClientPool
|
from src.clients.http_client import HTTPClientPool
|
||||||
@@ -662,10 +664,32 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
response_headers=response_headers,
|
response_headers=response_headers,
|
||||||
)
|
)
|
||||||
elif resp.status_code >= 500:
|
elif resp.status_code >= 500:
|
||||||
raise ProviderNotAvailableException(f"提供商服务不可用: {provider.name}")
|
# 记录响应体以便调试
|
||||||
elif resp.status_code != 200:
|
error_body = ""
|
||||||
|
try:
|
||||||
|
error_body = resp.text[:1000]
|
||||||
|
logger.error(f" [{self.request_id}] 上游返回5xx错误: status={resp.status_code}, body={error_body[:500]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
raise ProviderNotAvailableException(
|
raise ProviderNotAvailableException(
|
||||||
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}"
|
f"提供商服务不可用: {provider.name}",
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=resp.status_code,
|
||||||
|
upstream_response=error_body,
|
||||||
|
)
|
||||||
|
elif resp.status_code != 200:
|
||||||
|
# 记录非200响应以便调试
|
||||||
|
error_body = ""
|
||||||
|
try:
|
||||||
|
error_body = resp.text[:1000]
|
||||||
|
logger.warning(f" [{self.request_id}] 上游返回非200: status={resp.status_code}, body={error_body[:500]}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise ProviderNotAvailableException(
|
||||||
|
f"提供商返回错误: {provider.name}, 状态: {resp.status_code}",
|
||||||
|
provider_name=str(provider.name),
|
||||||
|
upstream_status=resp.status_code,
|
||||||
|
upstream_response=error_body,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_json = resp.json()
|
response_json = resp.json()
|
||||||
|
|||||||
@@ -1114,8 +1114,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
async for chunk in stream_generator:
|
async for chunk in stream_generator:
|
||||||
yield chunk
|
yield chunk
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
ctx.status_code = 499
|
# 如果响应已完成,不标记为失败
|
||||||
ctx.error_message = "Client disconnected"
|
if not ctx.has_completion:
|
||||||
|
ctx.status_code = 499
|
||||||
|
ctx.error_message = "Client disconnected"
|
||||||
raise
|
raise
|
||||||
except httpx.TimeoutException as e:
|
except httpx.TimeoutException as e:
|
||||||
ctx.status_code = 504
|
ctx.status_code = 504
|
||||||
|
|||||||
274
src/api/handlers/base/content_extractors.py
Normal file
274
src/api/handlers/base/content_extractors.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""
|
||||||
|
流式内容提取器 - 策略模式实现
|
||||||
|
|
||||||
|
为不同 API 格式(OpenAI、Claude、Gemini)提供内容提取和 chunk 构造的抽象。
|
||||||
|
StreamSmoother 使用这些提取器来处理不同格式的 SSE 事件。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ContentExtractor(ABC):
|
||||||
|
"""
|
||||||
|
流式内容提取器抽象基类
|
||||||
|
|
||||||
|
定义从 SSE 事件中提取文本内容和构造新 chunk 的接口。
|
||||||
|
每种 API 格式(OpenAI、Claude、Gemini)需要实现自己的提取器。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def extract_content(self, data: dict) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 SSE 数据中提取可拆分的文本内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: 解析后的 JSON 数据
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
提取的文本内容,如果无法提取则返回 None
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def create_chunk(
|
||||||
|
self,
|
||||||
|
original_data: dict,
|
||||||
|
new_content: str,
|
||||||
|
event_type: str = "",
|
||||||
|
is_first: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
使用新内容构造 SSE chunk
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original_data: 原始 JSON 数据
|
||||||
|
new_content: 新的文本内容
|
||||||
|
event_type: SSE 事件类型(某些格式需要)
|
||||||
|
is_first: 是否是第一个 chunk(用于保留 role 等字段)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
编码后的 SSE 字节数据
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIContentExtractor(ContentExtractor):
|
||||||
|
"""
|
||||||
|
OpenAI 格式内容提取器
|
||||||
|
|
||||||
|
处理 OpenAI Chat Completions API 的流式响应格式:
|
||||||
|
- 数据结构: choices[0].delta.content
|
||||||
|
- 只在 delta 仅包含 role/content 时允许拆分,避免破坏 tool_calls 等结构
|
||||||
|
"""
|
||||||
|
|
||||||
|
def extract_content(self, data: dict) -> Optional[str]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
choices = data.get("choices")
|
||||||
|
if not isinstance(choices, list) or len(choices) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_choice = choices[0]
|
||||||
|
if not isinstance(first_choice, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = first_choice.get("delta")
|
||||||
|
if not isinstance(delta, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = delta.get("content")
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 只有 delta 仅包含 role/content 时才允许拆分
|
||||||
|
# 避免破坏 tool_calls、function_call 等复杂结构
|
||||||
|
allowed_keys = {"role", "content"}
|
||||||
|
if not all(key in allowed_keys for key in delta.keys()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def create_chunk(
|
||||||
|
self,
|
||||||
|
original_data: dict,
|
||||||
|
new_content: str,
|
||||||
|
event_type: str = "",
|
||||||
|
is_first: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
new_data = original_data.copy()
|
||||||
|
|
||||||
|
if "choices" in new_data and new_data["choices"]:
|
||||||
|
new_choices = []
|
||||||
|
for choice in new_data["choices"]:
|
||||||
|
new_choice = choice.copy()
|
||||||
|
if "delta" in new_choice:
|
||||||
|
new_delta = {}
|
||||||
|
# 只有第一个 chunk 保留 role
|
||||||
|
if is_first and "role" in new_choice["delta"]:
|
||||||
|
new_delta["role"] = new_choice["delta"]["role"]
|
||||||
|
new_delta["content"] = new_content
|
||||||
|
new_choice["delta"] = new_delta
|
||||||
|
new_choices.append(new_choice)
|
||||||
|
new_data["choices"] = new_choices
|
||||||
|
|
||||||
|
return f"data: {json.dumps(new_data, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeContentExtractor(ContentExtractor):
|
||||||
|
"""
|
||||||
|
Claude 格式内容提取器
|
||||||
|
|
||||||
|
处理 Claude Messages API 的流式响应格式:
|
||||||
|
- 事件类型: content_block_delta
|
||||||
|
- 数据结构: delta.type=text_delta, delta.text
|
||||||
|
"""
|
||||||
|
|
||||||
|
def extract_content(self, data: dict) -> Optional[str]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查事件类型
|
||||||
|
if data.get("type") != "content_block_delta":
|
||||||
|
return None
|
||||||
|
|
||||||
|
delta = data.get("delta", {})
|
||||||
|
if not isinstance(delta, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 检查 delta 类型
|
||||||
|
if delta.get("type") != "text_delta":
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = delta.get("text")
|
||||||
|
if not isinstance(text, str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def create_chunk(
|
||||||
|
self,
|
||||||
|
original_data: dict,
|
||||||
|
new_content: str,
|
||||||
|
event_type: str = "",
|
||||||
|
is_first: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
new_data = original_data.copy()
|
||||||
|
|
||||||
|
if "delta" in new_data:
|
||||||
|
new_delta = new_data["delta"].copy()
|
||||||
|
new_delta["text"] = new_content
|
||||||
|
new_data["delta"] = new_delta
|
||||||
|
|
||||||
|
# Claude 格式需要 event: 前缀
|
||||||
|
event_name = event_type or "content_block_delta"
|
||||||
|
return f"event: {event_name}\ndata: {json.dumps(new_data, ensure_ascii=False)}\n\n".encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiContentExtractor(ContentExtractor):
|
||||||
|
"""
|
||||||
|
Gemini 格式内容提取器
|
||||||
|
|
||||||
|
处理 Gemini API 的流式响应格式:
|
||||||
|
- 数据结构: candidates[0].content.parts[0].text
|
||||||
|
- 只有纯文本块才拆分
|
||||||
|
"""
|
||||||
|
|
||||||
|
def extract_content(self, data: dict) -> Optional[str]:
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
candidates = data.get("candidates")
|
||||||
|
if not isinstance(candidates, list) or len(candidates) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_candidate = candidates[0]
|
||||||
|
if not isinstance(first_candidate, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = first_candidate.get("content", {})
|
||||||
|
if not isinstance(content, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = content.get("parts", [])
|
||||||
|
if not isinstance(parts, list) or len(parts) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first_part = parts[0]
|
||||||
|
if not isinstance(first_part, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = first_part.get("text")
|
||||||
|
# 只有纯文本块(只有 text 字段)才拆分
|
||||||
|
if not isinstance(text, str) or len(first_part) != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def create_chunk(
|
||||||
|
self,
|
||||||
|
original_data: dict,
|
||||||
|
new_content: str,
|
||||||
|
event_type: str = "",
|
||||||
|
is_first: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
new_data = copy.deepcopy(original_data)
|
||||||
|
|
||||||
|
if "candidates" in new_data and new_data["candidates"]:
|
||||||
|
first_candidate = new_data["candidates"][0]
|
||||||
|
if "content" in first_candidate:
|
||||||
|
content = first_candidate["content"]
|
||||||
|
if "parts" in content and content["parts"]:
|
||||||
|
content["parts"][0]["text"] = new_content
|
||||||
|
|
||||||
|
return f"data: {json.dumps(new_data, ensure_ascii=False)}\n\n".encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# 提取器注册表
|
||||||
|
_EXTRACTORS: dict[str, type[ContentExtractor]] = {
|
||||||
|
"openai": OpenAIContentExtractor,
|
||||||
|
"claude": ClaudeContentExtractor,
|
||||||
|
"gemini": GeminiContentExtractor,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_extractor(format_name: str) -> Optional[ContentExtractor]:
|
||||||
|
"""
|
||||||
|
根据格式名获取对应的内容提取器实例
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_name: 格式名称(openai, claude, gemini)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
对应的提取器实例,如果格式不支持则返回 None
|
||||||
|
"""
|
||||||
|
extractor_class = _EXTRACTORS.get(format_name.lower())
|
||||||
|
if extractor_class:
|
||||||
|
return extractor_class()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def register_extractor(format_name: str, extractor_class: type[ContentExtractor]) -> None:
|
||||||
|
"""
|
||||||
|
注册新的内容提取器
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_name: 格式名称
|
||||||
|
extractor_class: 提取器类
|
||||||
|
"""
|
||||||
|
_EXTRACTORS[format_name.lower()] = extractor_class
|
||||||
|
|
||||||
|
|
||||||
|
def get_extractor_formats() -> list[str]:
|
||||||
|
"""
|
||||||
|
获取所有已注册的格式名称列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
格式名称列表
|
||||||
|
"""
|
||||||
|
return list(_EXTRACTORS.keys())
|
||||||
@@ -6,16 +6,22 @@
|
|||||||
2. 响应流生成
|
2. 响应流生成
|
||||||
3. 预读和嵌套错误检测
|
3. 预读和嵌套错误检测
|
||||||
4. 客户端断开检测
|
4. 客户端断开检测
|
||||||
|
5. 流式平滑输出
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import codecs
|
import codecs
|
||||||
import json
|
import json
|
||||||
import time
|
from dataclasses import dataclass
|
||||||
from typing import Any, AsyncGenerator, Callable, Optional
|
from typing import Any, AsyncGenerator, Callable, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.api.handlers.base.content_extractors import (
|
||||||
|
ContentExtractor,
|
||||||
|
get_extractor,
|
||||||
|
get_extractor_formats,
|
||||||
|
)
|
||||||
from src.api.handlers.base.parsers import get_parser_for_format
|
from src.api.handlers.base.parsers import get_parser_for_format
|
||||||
from src.api.handlers.base.response_parser import ResponseParser
|
from src.api.handlers.base.response_parser import ResponseParser
|
||||||
from src.api.handlers.base.stream_context import StreamContext
|
from src.api.handlers.base.stream_context import StreamContext
|
||||||
@@ -25,11 +31,20 @@ from src.models.database import Provider, ProviderEndpoint
|
|||||||
from src.utils.sse_parser import SSEEventParser
|
from src.utils.sse_parser import SSEEventParser
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamSmoothingConfig:
|
||||||
|
"""流式平滑输出配置"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
chunk_size: int = 20
|
||||||
|
delay_ms: int = 8
|
||||||
|
|
||||||
|
|
||||||
class StreamProcessor:
|
class StreamProcessor:
|
||||||
"""
|
"""
|
||||||
流式响应处理器
|
流式响应处理器
|
||||||
|
|
||||||
负责处理 SSE 流的解析、错误检测和响应生成。
|
负责处理 SSE 流的解析、错误检测、响应生成和平滑输出。
|
||||||
从 ChatHandlerBase 中提取,使其职责更加单一。
|
从 ChatHandlerBase 中提取,使其职责更加单一。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -40,6 +55,7 @@ class StreamProcessor:
|
|||||||
on_streaming_start: Optional[Callable[[], None]] = None,
|
on_streaming_start: Optional[Callable[[], None]] = None,
|
||||||
*,
|
*,
|
||||||
collect_text: bool = False,
|
collect_text: bool = False,
|
||||||
|
smoothing_config: Optional[StreamSmoothingConfig] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
初始化流处理器
|
初始化流处理器
|
||||||
@@ -48,11 +64,17 @@ class StreamProcessor:
|
|||||||
request_id: 请求 ID(用于日志)
|
request_id: 请求 ID(用于日志)
|
||||||
default_parser: 默认响应解析器
|
default_parser: 默认响应解析器
|
||||||
on_streaming_start: 流开始时的回调(用于更新状态)
|
on_streaming_start: 流开始时的回调(用于更新状态)
|
||||||
|
collect_text: 是否收集文本内容
|
||||||
|
smoothing_config: 流式平滑输出配置
|
||||||
"""
|
"""
|
||||||
self.request_id = request_id
|
self.request_id = request_id
|
||||||
self.default_parser = default_parser
|
self.default_parser = default_parser
|
||||||
self.on_streaming_start = on_streaming_start
|
self.on_streaming_start = on_streaming_start
|
||||||
self.collect_text = collect_text
|
self.collect_text = collect_text
|
||||||
|
self.smoothing_config = smoothing_config or StreamSmoothingConfig()
|
||||||
|
|
||||||
|
# 内容提取器缓存
|
||||||
|
self._extractors: dict[str, ContentExtractor] = {}
|
||||||
|
|
||||||
def get_parser_for_provider(self, ctx: StreamContext) -> ResponseParser:
|
def get_parser_for_provider(self, ctx: StreamContext) -> ResponseParser:
|
||||||
"""
|
"""
|
||||||
@@ -127,6 +149,13 @@ class StreamProcessor:
|
|||||||
if event_type in ("response.completed", "message_stop"):
|
if event_type in ("response.completed", "message_stop"):
|
||||||
ctx.has_completion = True
|
ctx.has_completion = True
|
||||||
|
|
||||||
|
# 检查 OpenAI 格式的 finish_reason
|
||||||
|
choices = data.get("choices", [])
|
||||||
|
if choices and isinstance(choices, list) and len(choices) > 0:
|
||||||
|
finish_reason = choices[0].get("finish_reason")
|
||||||
|
if finish_reason is not None:
|
||||||
|
ctx.has_completion = True
|
||||||
|
|
||||||
async def prefetch_and_check_error(
|
async def prefetch_and_check_error(
|
||||||
self,
|
self,
|
||||||
byte_iterator: Any,
|
byte_iterator: Any,
|
||||||
@@ -369,7 +398,7 @@ class StreamProcessor:
|
|||||||
sse_parser: SSE 解析器
|
sse_parser: SSE 解析器
|
||||||
line: 原始行数据
|
line: 原始行数据
|
||||||
"""
|
"""
|
||||||
# SSEEventParser 以“去掉换行符”的单行文本作为输入;这里统一剔除 CR/LF,
|
# SSEEventParser 以"去掉换行符"的单行文本作为输入;这里统一剔除 CR/LF,
|
||||||
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
|
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
|
||||||
normalized_line = line.rstrip("\r\n")
|
normalized_line = line.rstrip("\r\n")
|
||||||
events = sse_parser.feed_line(normalized_line)
|
events = sse_parser.feed_line(normalized_line)
|
||||||
@@ -400,32 +429,201 @@ class StreamProcessor:
|
|||||||
响应数据块
|
响应数据块
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# 断连检查频率:每次 await 都会引入调度开销,过于频繁会让流式"发一段停一段"
|
# 使用后台任务检查断连,完全不阻塞流式传输
|
||||||
# 这里按时间间隔节流,兼顾及时停止上游读取与吞吐平滑性。
|
disconnected = False
|
||||||
next_disconnect_check_at = 0.0
|
|
||||||
disconnect_check_interval_s = 0.25
|
|
||||||
|
|
||||||
async for chunk in stream_generator:
|
async def check_disconnect_background() -> None:
|
||||||
now = time.monotonic()
|
nonlocal disconnected
|
||||||
if now >= next_disconnect_check_at:
|
while not disconnected and not ctx.has_completion:
|
||||||
next_disconnect_check_at = now + disconnect_check_interval_s
|
await asyncio.sleep(0.5)
|
||||||
if await is_disconnected():
|
if await is_disconnected():
|
||||||
logger.warning(f"ID:{self.request_id} | Client disconnected")
|
disconnected = True
|
||||||
ctx.status_code = 499 # Client Closed Request
|
|
||||||
ctx.error_message = "client_disconnected"
|
|
||||||
|
|
||||||
break
|
break
|
||||||
yield chunk
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
ctx.status_code = 499
|
|
||||||
ctx.error_message = "client_disconnected"
|
|
||||||
|
|
||||||
|
# 启动后台检查任务
|
||||||
|
check_task = asyncio.create_task(check_disconnect_background())
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
if disconnected:
|
||||||
|
# 如果响应已完成,客户端断开不算失败
|
||||||
|
if ctx.has_completion:
|
||||||
|
logger.info(
|
||||||
|
f"ID:{self.request_id} | Client disconnected after completion"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"ID:{self.request_id} | Client disconnected")
|
||||||
|
ctx.status_code = 499
|
||||||
|
ctx.error_message = "client_disconnected"
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
check_task.cancel()
|
||||||
|
try:
|
||||||
|
await check_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# 如果响应已完成,不标记为失败
|
||||||
|
if not ctx.has_completion:
|
||||||
|
ctx.status_code = 499
|
||||||
|
ctx.error_message = "client_disconnected"
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ctx.status_code = 500
|
ctx.status_code = 500
|
||||||
ctx.error_message = str(e)
|
ctx.error_message = str(e)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def create_smoothed_stream(
|
||||||
|
self,
|
||||||
|
stream_generator: AsyncGenerator[bytes, None],
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
|
"""
|
||||||
|
创建平滑输出的流生成器
|
||||||
|
|
||||||
|
如果启用了平滑输出,将大 chunk 拆分成小块并添加微小延迟。
|
||||||
|
否则直接透传原始流。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_generator: 原始流生成器
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
平滑处理后的响应数据块
|
||||||
|
"""
|
||||||
|
if not self.smoothing_config.enabled:
|
||||||
|
# 未启用平滑输出,直接透传
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
|
||||||
|
# 启用平滑输出
|
||||||
|
buffer = b""
|
||||||
|
is_first_content = True
|
||||||
|
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
buffer += chunk
|
||||||
|
|
||||||
|
# 按双换行分割 SSE 事件(标准 SSE 格式)
|
||||||
|
while b"\n\n" in buffer:
|
||||||
|
event_block, buffer = buffer.split(b"\n\n", 1)
|
||||||
|
event_str = event_block.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
# 解析事件块
|
||||||
|
lines = event_str.strip().split("\n")
|
||||||
|
data_str = None
|
||||||
|
event_type = ""
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.rstrip("\r")
|
||||||
|
if line.startswith("event: "):
|
||||||
|
event_type = line[7:].strip()
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data_str = line[6:]
|
||||||
|
|
||||||
|
# 没有 data 行,直接透传
|
||||||
|
if data_str is None:
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# [DONE] 直接透传
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 检测格式并提取内容
|
||||||
|
content, extractor = self._detect_format_and_extract(data)
|
||||||
|
|
||||||
|
# 只有内容长度大于 1 才需要平滑处理
|
||||||
|
if content and len(content) > 1 and extractor:
|
||||||
|
# 获取配置的延迟
|
||||||
|
delay_seconds = self._calculate_delay()
|
||||||
|
|
||||||
|
# 拆分内容
|
||||||
|
content_chunks = self._split_content(content)
|
||||||
|
|
||||||
|
for i, sub_content in enumerate(content_chunks):
|
||||||
|
is_first = is_first_content and i == 0
|
||||||
|
|
||||||
|
# 使用提取器创建新 chunk
|
||||||
|
sse_chunk = extractor.create_chunk(
|
||||||
|
data,
|
||||||
|
sub_content,
|
||||||
|
event_type=event_type,
|
||||||
|
is_first=is_first,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield sse_chunk
|
||||||
|
|
||||||
|
# 除了最后一个块,其他块之间加延迟
|
||||||
|
if i < len(content_chunks) - 1:
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
|
||||||
|
is_first_content = False
|
||||||
|
else:
|
||||||
|
# 不需要拆分,直接透传
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
if content:
|
||||||
|
is_first_content = False
|
||||||
|
|
||||||
|
# 处理剩余数据
|
||||||
|
if buffer:
|
||||||
|
yield buffer
|
||||||
|
|
||||||
|
def _get_extractor(self, format_name: str) -> Optional[ContentExtractor]:
|
||||||
|
"""获取或创建格式对应的提取器(带缓存)"""
|
||||||
|
if format_name not in self._extractors:
|
||||||
|
extractor = get_extractor(format_name)
|
||||||
|
if extractor:
|
||||||
|
self._extractors[format_name] = extractor
|
||||||
|
return self._extractors.get(format_name)
|
||||||
|
|
||||||
|
def _detect_format_and_extract(
|
||||||
|
self, data: dict
|
||||||
|
) -> tuple[Optional[str], Optional[ContentExtractor]]:
|
||||||
|
"""
|
||||||
|
检测数据格式并提取内容
|
||||||
|
|
||||||
|
依次尝试各格式的提取器,返回第一个成功提取内容的结果。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(content, extractor): 提取的内容和对应的提取器
|
||||||
|
"""
|
||||||
|
for format_name in get_extractor_formats():
|
||||||
|
extractor = self._get_extractor(format_name)
|
||||||
|
if extractor:
|
||||||
|
content = extractor.extract_content(data)
|
||||||
|
if content is not None:
|
||||||
|
return content, extractor
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _calculate_delay(self) -> float:
|
||||||
|
"""获取配置的延迟(秒)"""
|
||||||
|
return self.smoothing_config.delay_ms / 1000.0
|
||||||
|
|
||||||
|
def _split_content(self, content: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
按块拆分文本
|
||||||
|
"""
|
||||||
|
chunk_size = self.smoothing_config.chunk_size
|
||||||
|
text_length = len(content)
|
||||||
|
|
||||||
|
if text_length <= chunk_size:
|
||||||
|
return [content]
|
||||||
|
|
||||||
|
# 按块拆分
|
||||||
|
chunks = []
|
||||||
|
for i in range(0, text_length, chunk_size):
|
||||||
|
chunks.append(content[i : i + chunk_size])
|
||||||
|
return chunks
|
||||||
|
|
||||||
async def _cleanup(
|
async def _cleanup(
|
||||||
self,
|
self,
|
||||||
response_ctx: Any,
|
response_ctx: Any,
|
||||||
@@ -440,3 +638,128 @@ class StreamProcessor:
|
|||||||
await http_client.aclose()
|
await http_client.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def create_smoothed_stream(
|
||||||
|
stream_generator: AsyncGenerator[bytes, None],
|
||||||
|
chunk_size: int = 20,
|
||||||
|
delay_ms: int = 8,
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
|
"""
|
||||||
|
独立的平滑流生成函数
|
||||||
|
|
||||||
|
供 CLI handler 等场景使用,无需创建完整的 StreamProcessor 实例。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_generator: 原始流生成器
|
||||||
|
chunk_size: 每块字符数
|
||||||
|
delay_ms: 每块之间的延迟毫秒数
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
平滑处理后的响应数据块
|
||||||
|
"""
|
||||||
|
processor = _LightweightSmoother(chunk_size=chunk_size, delay_ms=delay_ms)
|
||||||
|
async for chunk in processor.smooth(stream_generator):
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
class _LightweightSmoother:
|
||||||
|
"""
|
||||||
|
轻量级平滑处理器
|
||||||
|
|
||||||
|
只包含平滑输出所需的最小逻辑,不依赖 StreamProcessor 的其他功能。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, chunk_size: int = 20, delay_ms: int = 8) -> None:
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
self.delay_ms = delay_ms
|
||||||
|
self._extractors: dict[str, ContentExtractor] = {}
|
||||||
|
|
||||||
|
def _get_extractor(self, format_name: str) -> Optional[ContentExtractor]:
|
||||||
|
if format_name not in self._extractors:
|
||||||
|
extractor = get_extractor(format_name)
|
||||||
|
if extractor:
|
||||||
|
self._extractors[format_name] = extractor
|
||||||
|
return self._extractors.get(format_name)
|
||||||
|
|
||||||
|
def _detect_format_and_extract(
|
||||||
|
self, data: dict
|
||||||
|
) -> tuple[Optional[str], Optional[ContentExtractor]]:
|
||||||
|
for format_name in get_extractor_formats():
|
||||||
|
extractor = self._get_extractor(format_name)
|
||||||
|
if extractor:
|
||||||
|
content = extractor.extract_content(data)
|
||||||
|
if content is not None:
|
||||||
|
return content, extractor
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def _calculate_delay(self) -> float:
|
||||||
|
return self.delay_ms / 1000.0
|
||||||
|
|
||||||
|
def _split_content(self, content: str) -> list[str]:
|
||||||
|
text_length = len(content)
|
||||||
|
if text_length <= self.chunk_size:
|
||||||
|
return [content]
|
||||||
|
return [content[i : i + self.chunk_size] for i in range(0, text_length, self.chunk_size)]
|
||||||
|
|
||||||
|
async def smooth(
|
||||||
|
self, stream_generator: AsyncGenerator[bytes, None]
|
||||||
|
) -> AsyncGenerator[bytes, None]:
|
||||||
|
buffer = b""
|
||||||
|
is_first_content = True
|
||||||
|
|
||||||
|
async for chunk in stream_generator:
|
||||||
|
buffer += chunk
|
||||||
|
|
||||||
|
while b"\n\n" in buffer:
|
||||||
|
event_block, buffer = buffer.split(b"\n\n", 1)
|
||||||
|
event_str = event_block.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
lines = event_str.strip().split("\n")
|
||||||
|
data_str = None
|
||||||
|
event_type = ""
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.rstrip("\r")
|
||||||
|
if line.startswith("event: "):
|
||||||
|
event_type = line[7:].strip()
|
||||||
|
elif line.startswith("data: "):
|
||||||
|
data_str = line[6:]
|
||||||
|
|
||||||
|
if data_str is None:
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
if data_str.strip() == "[DONE]":
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(data_str)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
continue
|
||||||
|
|
||||||
|
content, extractor = self._detect_format_and_extract(data)
|
||||||
|
|
||||||
|
if content and len(content) > 1 and extractor:
|
||||||
|
delay_seconds = self._calculate_delay()
|
||||||
|
content_chunks = self._split_content(content)
|
||||||
|
|
||||||
|
for i, sub_content in enumerate(content_chunks):
|
||||||
|
is_first = is_first_content and i == 0
|
||||||
|
sse_chunk = extractor.create_chunk(
|
||||||
|
data, sub_content, event_type=event_type, is_first=is_first
|
||||||
|
)
|
||||||
|
yield sse_chunk
|
||||||
|
if i < len(content_chunks) - 1:
|
||||||
|
await asyncio.sleep(delay_seconds)
|
||||||
|
|
||||||
|
is_first_content = False
|
||||||
|
else:
|
||||||
|
yield event_block + b"\n\n"
|
||||||
|
if content:
|
||||||
|
is_first_content = False
|
||||||
|
|
||||||
|
if buffer:
|
||||||
|
yield buffer
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ class APIFormat(Enum):
|
|||||||
"""API格式枚举 - 决定请求/响应的处理方式"""
|
"""API格式枚举 - 决定请求/响应的处理方式"""
|
||||||
|
|
||||||
CLAUDE = "CLAUDE" # Claude API 格式
|
CLAUDE = "CLAUDE" # Claude API 格式
|
||||||
OPENAI = "OPENAI" # OpenAI API 格式
|
|
||||||
CLAUDE_CLI = "CLAUDE_CLI" # Claude CLI API 格式(使用 authorization: Bearer)
|
CLAUDE_CLI = "CLAUDE_CLI" # Claude CLI API 格式(使用 authorization: Bearer)
|
||||||
|
OPENAI = "OPENAI" # OpenAI API 格式
|
||||||
OPENAI_CLI = "OPENAI_CLI" # OpenAI CLI/Responses API 格式(用于 Claude Code 等客户端)
|
OPENAI_CLI = "OPENAI_CLI" # OpenAI CLI/Responses API 格式(用于 Claude Code 等客户端)
|
||||||
GEMINI = "GEMINI" # Google Gemini API 格式
|
GEMINI = "GEMINI" # Google Gemini API 格式
|
||||||
GEMINI_CLI = "GEMINI_CLI" # Gemini CLI API 格式
|
GEMINI_CLI = "GEMINI_CLI" # Gemini CLI API 格式
|
||||||
|
|||||||
@@ -188,12 +188,16 @@ class ProviderNotAvailableException(ProviderException):
|
|||||||
message: str,
|
message: str,
|
||||||
provider_name: Optional[str] = None,
|
provider_name: Optional[str] = None,
|
||||||
request_metadata: Optional[Any] = None,
|
request_metadata: Optional[Any] = None,
|
||||||
|
upstream_status: Optional[int] = None,
|
||||||
|
upstream_response: Optional[str] = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
message=message,
|
message=message,
|
||||||
provider_name=provider_name,
|
provider_name=provider_name,
|
||||||
request_metadata=request_metadata,
|
request_metadata=request_metadata,
|
||||||
)
|
)
|
||||||
|
self.upstream_status = upstream_status
|
||||||
|
self.upstream_response = upstream_response
|
||||||
|
|
||||||
|
|
||||||
class ProviderTimeoutException(ProviderException):
|
class ProviderTimeoutException(ProviderException):
|
||||||
@@ -442,6 +446,36 @@ class EmbeddedErrorException(ProviderException):
|
|||||||
self.error_status = error_status
|
self.error_status = error_status
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderCompatibilityException(ProviderException):
|
||||||
|
"""Provider 兼容性错误异常 - 应该触发故障转移
|
||||||
|
|
||||||
|
用于处理因 Provider 不支持某些参数或功能导致的错误。
|
||||||
|
这类错误不是用户请求本身的问题,换一个 Provider 可能就能成功,应该触发故障转移。
|
||||||
|
|
||||||
|
常见场景:
|
||||||
|
- Unsupported parameter(不支持的参数)
|
||||||
|
- Unsupported model(不支持的模型)
|
||||||
|
- Unsupported feature(不支持的功能)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
provider_name: Optional[str] = None,
|
||||||
|
status_code: int = 400,
|
||||||
|
upstream_error: Optional[str] = None,
|
||||||
|
request_metadata: Optional[Any] = None,
|
||||||
|
):
|
||||||
|
self.upstream_error = upstream_error
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
provider_name=provider_name,
|
||||||
|
request_metadata=request_metadata,
|
||||||
|
)
|
||||||
|
# 覆盖状态码为 400(保持与上游一致)
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
class UpstreamClientException(ProxyException):
|
class UpstreamClientException(ProxyException):
|
||||||
"""上游返回的客户端错误异常 - HTTP 4xx 错误,不应该重试
|
"""上游返回的客户端错误异常 - HTTP 4xx 错误,不应该重试
|
||||||
|
|
||||||
|
|||||||
32
src/main.py
32
src/main.py
@@ -4,13 +4,10 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
|
|
||||||
from src.api.admin import router as admin_router
|
from src.api.admin import router as admin_router
|
||||||
from src.api.announcements import router as announcement_router
|
from src.api.announcements import router as announcement_router
|
||||||
@@ -299,33 +296,6 @@ app.include_router(dashboard_router) # 仪表盘端点
|
|||||||
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
|
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
|
||||||
app.include_router(monitoring_router) # 监控端点
|
app.include_router(monitoring_router) # 监控端点
|
||||||
|
|
||||||
# 静态文件服务(前端构建产物)
|
|
||||||
# 检查前端构建目录是否存在
|
|
||||||
frontend_dist = Path(__file__).parent.parent / "frontend" / "dist"
|
|
||||||
if frontend_dist.exists():
|
|
||||||
# 挂载静态资源目录
|
|
||||||
app.mount("/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets")
|
|
||||||
|
|
||||||
# SPA catch-all路由 - 必须放在最后
|
|
||||||
@app.get("/{full_path:path}")
|
|
||||||
async def serve_spa(request: Request, full_path: str):
|
|
||||||
"""
|
|
||||||
处理所有未匹配的GET请求,返回index.html供前端路由处理
|
|
||||||
仅对非API路径生效
|
|
||||||
"""
|
|
||||||
# 如果是API路径,不处理
|
|
||||||
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
|
|
||||||
raise HTTPException(status_code=404, detail="Not Found")
|
|
||||||
|
|
||||||
# 返回index.html,让前端路由处理
|
|
||||||
index_file = frontend_dist / "index.html"
|
|
||||||
if index_file.exists():
|
|
||||||
return FileResponse(str(index_file))
|
|
||||||
else:
|
|
||||||
raise HTTPException(status_code=404, detail="Frontend not built")
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("前端构建目录不存在,前端路由将无法使用")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException
|
|||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
from src.models.api import ModelCreate, ModelResponse, ModelUpdate
|
from src.models.api import ModelCreate, ModelResponse, ModelUpdate
|
||||||
from src.models.database import Model, Provider
|
from src.models.database import Model, Provider
|
||||||
|
from src.api.base.models_service import invalidate_models_list_cache
|
||||||
from src.services.cache.invalidation import get_cache_invalidation_service
|
from src.services.cache.invalidation import get_cache_invalidation_service
|
||||||
from src.services.cache.model_cache import ModelCacheService
|
from src.services.cache.model_cache import ModelCacheService
|
||||||
|
|
||||||
@@ -75,6 +76,10 @@ class ModelService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"创建模型成功: provider={provider.name}, model={model.provider_model_name}, global_model_id={model.global_model_id}")
|
logger.info(f"创建模型成功: provider={provider.name}, model={model.provider_model_name}, global_model_id={model.global_model_id}")
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
|
|
||||||
return model
|
return model
|
||||||
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
@@ -197,6 +202,9 @@ class ModelService:
|
|||||||
cache_service = get_cache_invalidation_service()
|
cache_service = get_cache_invalidation_service()
|
||||||
cache_service.on_model_changed(model.provider_id, model.global_model_id)
|
cache_service.on_model_changed(model.provider_id, model.global_model_id)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
|
|
||||||
logger.info(f"更新模型成功: id={model_id}, 最终 supports_vision: {model.supports_vision}, supports_function_calling: {model.supports_function_calling}, supports_extended_thinking: {model.supports_extended_thinking}")
|
logger.info(f"更新模型成功: id={model_id}, 最终 supports_vision: {model.supports_vision}, supports_function_calling: {model.supports_function_calling}, supports_extended_thinking: {model.supports_extended_thinking}")
|
||||||
return model
|
return model
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
@@ -261,6 +269,9 @@ class ModelService:
|
|||||||
cache_service = get_cache_invalidation_service()
|
cache_service = get_cache_invalidation_service()
|
||||||
cache_service.on_model_changed(cache_info["provider_id"], cache_info["global_model_id"])
|
cache_service.on_model_changed(cache_info["provider_id"], cache_info["global_model_id"])
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
|
|
||||||
logger.info(f"删除模型成功: id={model_id}, provider_model_name={cache_info['provider_model_name']}, "
|
logger.info(f"删除模型成功: id={model_id}, provider_model_name={cache_info['provider_model_name']}, "
|
||||||
f"global_model_id={cache_info['global_model_id'][:8] if cache_info['global_model_id'] else 'None'}...")
|
f"global_model_id={cache_info['global_model_id'][:8] if cache_info['global_model_id'] else 'None'}...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -295,6 +306,9 @@ class ModelService:
|
|||||||
cache_service = get_cache_invalidation_service()
|
cache_service = get_cache_invalidation_service()
|
||||||
cache_service.on_model_changed(model.provider_id, model.global_model_id)
|
cache_service.on_model_changed(model.provider_id, model.global_model_id)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
|
|
||||||
status = "可用" if is_available else "不可用"
|
status = "可用" if is_available else "不可用"
|
||||||
logger.info(f"更新模型可用状态: id={model_id}, status={status}")
|
logger.info(f"更新模型可用状态: id={model_id}, status={status}")
|
||||||
return model
|
return model
|
||||||
@@ -358,6 +372,9 @@ class ModelService:
|
|||||||
for model in created_models:
|
for model in created_models:
|
||||||
db.refresh(model)
|
db.refresh(model)
|
||||||
logger.info(f"批量创建 {len(created_models)} 个模型成功")
|
logger.info(f"批量创建 {len(created_models)} 个模型成功")
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
asyncio.create_task(invalidate_models_list_cache())
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
logger.error(f"批量创建模型失败: {str(e)}")
|
logger.error(f"批量创建模型失败: {str(e)}")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from src.core.enums import APIFormat
|
|||||||
from src.core.exceptions import (
|
from src.core.exceptions import (
|
||||||
ConcurrencyLimitError,
|
ConcurrencyLimitError,
|
||||||
ProviderAuthException,
|
ProviderAuthException,
|
||||||
|
ProviderCompatibilityException,
|
||||||
ProviderException,
|
ProviderException,
|
||||||
ProviderNotAvailableException,
|
ProviderNotAvailableException,
|
||||||
ProviderRateLimitException,
|
ProviderRateLimitException,
|
||||||
@@ -81,7 +82,9 @@ class ErrorClassifier:
|
|||||||
"context_length_exceeded", # 上下文长度超限
|
"context_length_exceeded", # 上下文长度超限
|
||||||
"content_length_limit", # 请求内容长度超限 (Claude API)
|
"content_length_limit", # 请求内容长度超限 (Claude API)
|
||||||
"content_length_exceeds", # 内容长度超限变体 (AWS CodeWhisperer)
|
"content_length_exceeds", # 内容长度超限变体 (AWS CodeWhisperer)
|
||||||
"max_tokens", # token 数超限
|
# 注意:移除了 "max_tokens",因为 max_tokens 相关错误可能是 Provider 兼容性问题
|
||||||
|
# 如 "Unsupported parameter: 'max_tokens' is not supported with this model"
|
||||||
|
# 这类错误应由 COMPATIBILITY_ERROR_PATTERNS 处理
|
||||||
"invalid_prompt", # 无效的提示词
|
"invalid_prompt", # 无效的提示词
|
||||||
"content too long", # 内容过长
|
"content too long", # 内容过长
|
||||||
"input is too long", # 输入过长 (AWS)
|
"input is too long", # 输入过长 (AWS)
|
||||||
@@ -136,6 +139,19 @@ class ErrorClassifier:
|
|||||||
"CONTENT_POLICY_VIOLATION",
|
"CONTENT_POLICY_VIOLATION",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Provider 兼容性错误模式 - 这类错误应该触发故障转移
|
||||||
|
# 因为换一个 Provider 可能就能成功
|
||||||
|
COMPATIBILITY_ERROR_PATTERNS: Tuple[str, ...] = (
|
||||||
|
"unsupported parameter", # 不支持的参数
|
||||||
|
"unsupported model", # 不支持的模型
|
||||||
|
"unsupported feature", # 不支持的功能
|
||||||
|
"not supported with this model", # 此模型不支持
|
||||||
|
"model does not support", # 模型不支持
|
||||||
|
"parameter is not supported", # 参数不支持
|
||||||
|
"feature is not supported", # 功能不支持
|
||||||
|
"not available for this model", # 此模型不可用
|
||||||
|
)
|
||||||
|
|
||||||
def _parse_error_response(self, error_text: Optional[str]) -> Dict[str, Any]:
|
def _parse_error_response(self, error_text: Optional[str]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
解析错误响应为结构化数据
|
解析错误响应为结构化数据
|
||||||
@@ -261,6 +277,25 @@ class ErrorClassifier:
|
|||||||
search_text = f"{parsed['message']} {parsed['raw']}".lower()
|
search_text = f"{parsed['message']} {parsed['raw']}".lower()
|
||||||
return any(pattern.lower() in search_text for pattern in self.CLIENT_ERROR_PATTERNS)
|
return any(pattern.lower() in search_text for pattern in self.CLIENT_ERROR_PATTERNS)
|
||||||
|
|
||||||
|
def _is_compatibility_error(self, error_text: Optional[str]) -> bool:
|
||||||
|
"""
|
||||||
|
检测错误响应是否为 Provider 兼容性错误(应触发故障转移)
|
||||||
|
|
||||||
|
这类错误是因为 Provider 不支持某些参数或功能导致的,
|
||||||
|
换一个 Provider 可能就能成功。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_text: 错误响应文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
是否为兼容性错误
|
||||||
|
"""
|
||||||
|
if not error_text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
search_text = error_text.lower()
|
||||||
|
return any(pattern.lower() in search_text for pattern in self.COMPATIBILITY_ERROR_PATTERNS)
|
||||||
|
|
||||||
def _extract_error_message(self, error_text: Optional[str]) -> Optional[str]:
|
def _extract_error_message(self, error_text: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
从错误响应中提取错误消息
|
从错误响应中提取错误消息
|
||||||
@@ -425,6 +460,16 @@ class ErrorClassifier:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 400 错误:先检查是否为 Provider 兼容性错误(应触发故障转移)
|
||||||
|
if status == 400 and self._is_compatibility_error(error_response_text):
|
||||||
|
logger.info(f"检测到 Provider 兼容性错误,将触发故障转移: {extracted_message}")
|
||||||
|
return ProviderCompatibilityException(
|
||||||
|
message=extracted_message or "Provider 不支持此请求",
|
||||||
|
provider_name=provider_name,
|
||||||
|
status_code=400,
|
||||||
|
upstream_error=error_response_text,
|
||||||
|
)
|
||||||
|
|
||||||
# 400 错误:检查是否为客户端请求错误(不应重试)
|
# 400 错误:检查是否为客户端请求错误(不应重试)
|
||||||
if status == 400 and self._is_client_error(error_response_text):
|
if status == 400 and self._is_client_error(error_response_text):
|
||||||
logger.info(f"检测到客户端请求错误,不进行重试: {extracted_message}")
|
logger.info(f"检测到客户端请求错误,不进行重试: {extracted_message}")
|
||||||
|
|||||||
@@ -427,6 +427,9 @@ class FallbackOrchestrator:
|
|||||||
)
|
)
|
||||||
# str(cause) 可能为空(如 httpx 超时异常),使用 repr() 作为备用
|
# str(cause) 可能为空(如 httpx 超时异常),使用 repr() 作为备用
|
||||||
error_msg = str(cause) or repr(cause)
|
error_msg = str(cause) or repr(cause)
|
||||||
|
# 如果是 ProviderNotAvailableException,附加上游响应
|
||||||
|
if hasattr(cause, "upstream_response") and cause.upstream_response:
|
||||||
|
error_msg = f"{error_msg} | 上游响应: {cause.upstream_response[:500]}"
|
||||||
RequestCandidateService.mark_candidate_failed(
|
RequestCandidateService.mark_candidate_failed(
|
||||||
db=self.db,
|
db=self.db,
|
||||||
candidate_id=candidate_record_id,
|
candidate_id=candidate_record_id,
|
||||||
@@ -439,6 +442,9 @@ class FallbackOrchestrator:
|
|||||||
|
|
||||||
# 未知错误:记录失败并抛出
|
# 未知错误:记录失败并抛出
|
||||||
error_msg = str(cause) or repr(cause)
|
error_msg = str(cause) or repr(cause)
|
||||||
|
# 如果是 ProviderNotAvailableException,附加上游响应
|
||||||
|
if hasattr(cause, "upstream_response") and cause.upstream_response:
|
||||||
|
error_msg = f"{error_msg} | 上游响应: {cause.upstream_response[:500]}"
|
||||||
RequestCandidateService.mark_candidate_failed(
|
RequestCandidateService.mark_candidate_failed(
|
||||||
db=self.db,
|
db=self.db,
|
||||||
candidate_id=candidate_record_id,
|
candidate_id=candidate_record_id,
|
||||||
|
|||||||
@@ -289,11 +289,17 @@ class RequestResult:
|
|||||||
status_code = 500
|
status_code = 500
|
||||||
error_type = "internal_error"
|
error_type = "internal_error"
|
||||||
|
|
||||||
|
# 构建错误消息,包含上游响应信息
|
||||||
|
error_message = str(exception)
|
||||||
|
if isinstance(exception, ProviderNotAvailableException):
|
||||||
|
if exception.upstream_response:
|
||||||
|
error_message = f"{error_message} | 上游响应: {exception.upstream_response[:500]}"
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
status=RequestStatus.FAILED,
|
status=RequestStatus.FAILED,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
status_code=status_code,
|
status_code=status_code,
|
||||||
error_message=str(exception),
|
error_message=error_message,
|
||||||
error_type=error_type,
|
error_type=error_type,
|
||||||
response_time_ms=response_time_ms,
|
response_time_ms=response_time_ms,
|
||||||
is_stream=is_stream,
|
is_stream=is_stream,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from src.core.logger import logger
|
|||||||
from src.models.database import Provider, SystemConfig
|
from src.models.database import Provider, SystemConfig
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LogLevel(str, Enum):
|
class LogLevel(str, Enum):
|
||||||
"""日志记录级别"""
|
"""日志记录级别"""
|
||||||
|
|
||||||
@@ -94,6 +93,35 @@ class SystemConfigService:
|
|||||||
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_configs(cls, db: Session, keys: List[str]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
批量获取系统配置值
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: 数据库会话
|
||||||
|
keys: 配置键列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
配置键值字典
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# 一次查询获取所有配置
|
||||||
|
configs = db.query(SystemConfig).filter(SystemConfig.key.in_(keys)).all()
|
||||||
|
config_map = {c.key: c.value for c in configs}
|
||||||
|
|
||||||
|
# 填充结果,不存在的使用默认值
|
||||||
|
for key in keys:
|
||||||
|
if key in config_map:
|
||||||
|
result[key] = config_map[key]
|
||||||
|
elif key in cls.DEFAULT_CONFIGS:
|
||||||
|
result[key] = cls.DEFAULT_CONFIGS[key]["value"]
|
||||||
|
else:
|
||||||
|
result[key] = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def set_config(db: Session, key: str, value: Any, description: str = None) -> SystemConfig:
|
def set_config(db: Session, key: str, value: Any, description: str = None) -> SystemConfig:
|
||||||
"""设置系统配置值"""
|
"""设置系统配置值"""
|
||||||
@@ -111,6 +139,7 @@ class SystemConfigService:
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(config)
|
db.refresh(config)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -153,8 +182,8 @@ class SystemConfigService:
|
|||||||
for config in configs
|
for config in configs
|
||||||
]
|
]
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def delete_config(db: Session, key: str) -> bool:
|
def delete_config(cls, db: Session, key: str) -> bool:
|
||||||
"""删除系统配置"""
|
"""删除系统配置"""
|
||||||
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
config = db.query(SystemConfig).filter(SystemConfig.key == key).first()
|
||||||
if config:
|
if config:
|
||||||
|
|||||||
Reference in New Issue
Block a user