mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-05 09:12:27 +08:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1aed9976 | ||
|
|
e2e7996a54 | ||
|
|
df9f9a9f4f | ||
|
|
7553b0da80 | ||
|
|
8f30bf0bef | ||
|
|
8c12174521 | ||
|
|
6aa1876955 | ||
|
|
7f07122aea | ||
|
|
c2ddc6bd3c | ||
|
|
af476ff21e | ||
|
|
3bbc1c6b66 | ||
|
|
c69a0a8506 | ||
|
|
1fae202bde | ||
|
|
b9a26c4550 | ||
|
|
e42bd35d48 | ||
|
|
f22a073fd9 | ||
|
|
5c7ad089d2 | ||
|
|
97425ac68f | ||
|
|
912f6643e2 | ||
|
|
6c0373fda6 | ||
|
|
070121717d | ||
|
|
85fafeacb8 | ||
|
|
daf8b870f0 | ||
|
|
880fb61c66 | ||
|
|
7e792dabfc | ||
|
|
cd06169b2f | ||
|
|
50ffd47546 | ||
|
|
5f0c1fb347 | ||
|
|
7b932d7afb | ||
|
|
c7b971cfe7 | ||
|
|
293bb592dc | ||
|
|
3e50c157be | ||
|
|
21587449c8 | ||
|
|
3d0ab353d3 | ||
|
|
b2a857c164 | ||
|
|
4d1d863916 | ||
|
|
b579420690 |
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
|
||||
|
||||
# Redis 配置
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=your_redis_password_here
|
||||
|
||||
# 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
|
||||
BASE_IMAGE_NAME: fawney19/aether-base
|
||||
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:
|
||||
check-base-changes:
|
||||
@@ -23,8 +25,13 @@ jobs:
|
||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
fetch-depth: 2
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if base image needs rebuild
|
||||
id: check
|
||||
@@ -34,10 +41,26 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if base-related files changed
|
||||
if git diff --name-only HEAD~1 HEAD | grep -qE '^(Dockerfile\.base|pyproject\.toml|frontend/package.*\.json)$'; then
|
||||
# Calculate current hash of base-related files
|
||||
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
|
||||
else
|
||||
echo "Hash matches, no rebuild needed"
|
||||
echo "base_changed=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
@@ -61,6 +84,12 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
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
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -69,6 +98,8 @@ jobs:
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=
|
||||
labels: |
|
||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||
|
||||
- name: Build and push base image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -117,7 +148,7 @@ jobs:
|
||||
|
||||
- name: Update Dockerfile.app to use registry base image
|
||||
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
|
||||
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 .
|
||||
FROM aether-base:latest
|
||||
# 用于 GitHub Actions CI(官方源)
|
||||
FROM aether-base:latest AS builder
|
||||
|
||||
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 alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
# 构建前端(使用基础镜像中已安装的 node_modules)
|
||||
COPY frontend/ /tmp/frontend/
|
||||
RUN cd /tmp/frontend && npm run build && \
|
||||
cp -r dist/* /usr/share/nginx/html/ && \
|
||||
rm -rf /tmp/frontend
|
||||
# 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"]
|
||||
|
||||
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 构建(不使用国内镜像源)
|
||||
# 构建命令: docker build -f Dockerfile.base -t aether-base:latest .
|
||||
# 只在 pyproject.toml 或 frontend/package*.json 变化时需要重建
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 系统依赖
|
||||
# 构建工具
|
||||
RUN apt-get update && apt-get install -y \
|
||||
nginx \
|
||||
supervisor \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
curl \
|
||||
gettext-base \
|
||||
nodejs \
|
||||
npm \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Python 依赖(安装到系统,不用 -e 模式)
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
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/
|
||||
WORKDIR /tmp/frontend
|
||||
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"]
|
||||
# 前端依赖(只安装,不构建)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm ci
|
||||
|
||||
@@ -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
|
||||
|
||||
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 \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
curl \
|
||||
gettext-base \
|
||||
nodejs \
|
||||
npm \
|
||||
&& 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 镜像源
|
||||
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# Python 依赖(安装到系统,不用 -e 模式)
|
||||
# Python 依赖
|
||||
COPY pyproject.toml README.md ./
|
||||
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/
|
||||
WORKDIR /tmp/frontend
|
||||
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"]
|
||||
# 前端依赖(只安装,不构建,使用淘宝镜像源)
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm config set registry https://registry.npmmirror.com && npm ci
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""add proxy field to provider_endpoints
|
||||
|
||||
Revision ID: f30f9936f6a2
|
||||
Revises: 1cc6942cf06f
|
||||
Create Date: 2025-12-18 06:31:58.451112+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f30f9936f6a2'
|
||||
down_revision = '1cc6942cf06f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def get_column_type(table_name: str, column_name: str) -> str:
|
||||
"""获取列的类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col['name'] == column_name:
|
||||
return str(col['type']).upper()
|
||||
return ''
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""添加 proxy 字段到 provider_endpoints 表"""
|
||||
if not column_exists('provider_endpoints', 'proxy'):
|
||||
# 字段不存在,直接添加 JSONB 类型
|
||||
op.add_column('provider_endpoints', sa.Column('proxy', JSONB(), nullable=True))
|
||||
else:
|
||||
# 字段已存在,检查是否需要转换类型
|
||||
col_type = get_column_type('provider_endpoints', 'proxy')
|
||||
if 'JSONB' not in col_type:
|
||||
# 如果是 JSON 类型,转换为 JSONB
|
||||
op.execute(
|
||||
'ALTER TABLE provider_endpoints '
|
||||
'ALTER COLUMN proxy TYPE JSONB USING proxy::jsonb'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""移除 proxy 字段"""
|
||||
if column_exists('provider_endpoints', 'proxy'):
|
||||
op.drop_column('provider_endpoints', 'proxy')
|
||||
@@ -0,0 +1,86 @@
|
||||
"""add stats_daily_model table and rename provider_model_aliases
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: f30f9936f6a2
|
||||
Create Date: 2025-12-20 12:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'f30f9936f6a2'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def table_exists(table_name: str) -> bool:
|
||||
"""检查表是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def column_exists(table_name: str, column_name: str) -> bool:
|
||||
"""检查列是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""创建 stats_daily_model 表,重命名 provider_model_aliases 为 provider_model_mappings"""
|
||||
# 1. 创建 stats_daily_model 表
|
||||
if not table_exists('stats_daily_model'):
|
||||
op.create_table(
|
||||
'stats_daily_model',
|
||||
sa.Column('id', sa.String(36), primary_key=True),
|
||||
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('model', sa.String(100), nullable=False),
|
||||
sa.Column('total_requests', sa.Integer(), nullable=False, default=0),
|
||||
sa.Column('input_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('output_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False, default=0),
|
||||
sa.Column('total_cost', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('avg_response_time_ms', sa.Float(), nullable=False, default=0.0),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.UniqueConstraint('date', 'model', name='uq_stats_daily_model'),
|
||||
)
|
||||
|
||||
# 创建索引
|
||||
op.create_index('idx_stats_daily_model_date', 'stats_daily_model', ['date'])
|
||||
op.create_index('idx_stats_daily_model_date_model', 'stats_daily_model', ['date', 'model'])
|
||||
|
||||
# 2. 重命名 models 表的 provider_model_aliases 为 provider_model_mappings
|
||||
if column_exists('models', 'provider_model_aliases') and not column_exists('models', 'provider_model_mappings'):
|
||||
op.alter_column('models', 'provider_model_aliases', new_column_name='provider_model_mappings')
|
||||
|
||||
|
||||
def index_exists(table_name: str, index_name: str) -> bool:
|
||||
"""检查索引是否存在"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
indexes = [idx['name'] for idx in inspector.get_indexes(table_name)]
|
||||
return index_name in indexes
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""删除 stats_daily_model 表,恢复 provider_model_aliases 列名"""
|
||||
# 恢复列名
|
||||
if column_exists('models', 'provider_model_mappings') and not column_exists('models', 'provider_model_aliases'):
|
||||
op.alter_column('models', 'provider_model_mappings', new_column_name='provider_model_aliases')
|
||||
|
||||
# 删除表
|
||||
if table_exists('stats_daily_model'):
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date_model'):
|
||||
op.drop_index('idx_stats_daily_model_date_model', table_name='stats_daily_model')
|
||||
if index_exists('stats_daily_model', 'idx_stats_daily_model_date'):
|
||||
op.drop_index('idx_stats_daily_model_date', table_name='stats_daily_model')
|
||||
op.drop_table('stats_daily_model')
|
||||
15
deploy.sh
15
deploy.sh
@@ -21,9 +21,9 @@ HASH_FILE=".deps-hash"
|
||||
CODE_HASH_FILE=".code-hash"
|
||||
MIGRATION_HASH_FILE=".migration-hash"
|
||||
|
||||
# 计算依赖文件的哈希值
|
||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -162,25 +162,32 @@ git pull
|
||||
|
||||
# 标记是否需要重启
|
||||
NEED_RESTART=false
|
||||
BASE_REBUILT=false
|
||||
|
||||
# 检查基础镜像是否存在,或依赖是否变化
|
||||
if ! docker image inspect aether-base:latest >/dev/null 2>&1; then
|
||||
echo ">>> Base image not found, building..."
|
||||
build_base
|
||||
BASE_REBUILT=true
|
||||
NEED_RESTART=true
|
||||
elif check_deps_changed; then
|
||||
echo ">>> Dependencies changed, rebuilding base image..."
|
||||
build_base
|
||||
BASE_REBUILT=true
|
||||
NEED_RESTART=true
|
||||
else
|
||||
echo ">>> Dependencies unchanged."
|
||||
fi
|
||||
|
||||
# 检查代码是否变化
|
||||
# 检查代码是否变化,或者 base 重建了(app 依赖 base)
|
||||
if ! docker image inspect aether-app:latest >/dev/null 2>&1; then
|
||||
echo ">>> App image not found, building..."
|
||||
build_app
|
||||
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
|
||||
echo ">>> Code changed, rebuilding app image..."
|
||||
build_app
|
||||
|
||||
3
dev.sh
3
dev.sh
@@ -8,7 +8,8 @@ source .env
|
||||
set +a
|
||||
|
||||
# 构建 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(热重载模式)
|
||||
echo "🚀 启动本地开发服务器..."
|
||||
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.app
|
||||
dockerfile: Dockerfile.app.local
|
||||
image: aether-app:latest
|
||||
container_name: aether-app
|
||||
environment:
|
||||
|
||||
@@ -112,7 +112,7 @@ export interface KeyExport {
|
||||
export interface ModelExport {
|
||||
global_model_name: string | null
|
||||
provider_model_name: string
|
||||
provider_model_aliases?: any
|
||||
provider_model_mappings?: any
|
||||
price_per_request?: number | null
|
||||
tiered_pricing?: any
|
||||
supports_vision?: boolean | null
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface UserAffinity {
|
||||
key_name: string | null
|
||||
key_prefix: string | null // Provider Key 脱敏显示(前4...后4)
|
||||
rate_multiplier: number
|
||||
global_model_id: string | null // 原始的 global_model_id(用于删除)
|
||||
model_name: string | null // 模型名称(如 claude-haiku-4-5-20250514)
|
||||
model_display_name: string | null // 模型显示名称(如 Claude Haiku 4.5)
|
||||
api_format: string | null // API 格式 (claude/openai)
|
||||
@@ -119,6 +120,18 @@ export const cacheApi = {
|
||||
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}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import client from '../client'
|
||||
import type { ProviderEndpoint } from './types'
|
||||
import type { ProviderEndpoint, ProxyConfig } from './types'
|
||||
|
||||
/**
|
||||
* 获取指定 Provider 的所有 Endpoints
|
||||
@@ -38,6 +38,7 @@ export async function createEndpoint(
|
||||
rate_limit?: number
|
||||
is_active?: boolean
|
||||
config?: Record<string, any>
|
||||
proxy?: ProxyConfig | null
|
||||
}
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data)
|
||||
@@ -63,6 +64,7 @@ export async function updateEndpoint(
|
||||
rate_limit: number
|
||||
is_active: boolean
|
||||
config: Record<string, any>
|
||||
proxy: ProxyConfig | null
|
||||
}>
|
||||
): Promise<ProviderEndpoint> {
|
||||
const response = await client.put(`/api/admin/endpoints/${endpointId}`, data)
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ModelUpdate,
|
||||
ModelCatalogResponse,
|
||||
ProviderAvailableSourceModelsResponse,
|
||||
UpstreamModel,
|
||||
ImportFromUpstreamResponse,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
@@ -119,3 +121,40 @@ export async function batchAssignModelsToProvider(
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询提供商的上游模型列表
|
||||
*/
|
||||
export async function queryProviderUpstreamModels(
|
||||
providerId: string
|
||||
): Promise<{
|
||||
success: boolean
|
||||
data: {
|
||||
models: UpstreamModel[]
|
||||
error: string | null
|
||||
}
|
||||
provider: {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
}
|
||||
}> {
|
||||
const response = await client.post('/api/admin/provider-query/models', {
|
||||
provider_id: providerId,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上游提供商导入模型
|
||||
*/
|
||||
export async function importModelsFromUpstream(
|
||||
providerId: string,
|
||||
modelIds: string[]
|
||||
): Promise<ImportFromUpstreamResponse> {
|
||||
const response = await client.post(
|
||||
`/api/admin/providers/${providerId}/import-from-upstream`,
|
||||
{ model_ids: modelIds }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export const API_FORMAT_LABELS: Record<string, string> = {
|
||||
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理配置类型
|
||||
*/
|
||||
export interface ProxyConfig {
|
||||
url: string
|
||||
username?: string
|
||||
password?: string
|
||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||
}
|
||||
|
||||
export interface ProviderEndpoint {
|
||||
id: string
|
||||
provider_id: string
|
||||
@@ -41,6 +51,7 @@ export interface ProviderEndpoint {
|
||||
last_failure_at?: string
|
||||
is_active: boolean
|
||||
config?: Record<string, any>
|
||||
proxy?: ProxyConfig | null
|
||||
total_keys: number
|
||||
active_keys: number
|
||||
created_at: string
|
||||
@@ -233,18 +244,21 @@ export interface ConcurrencyStatus {
|
||||
key_max_concurrent?: number
|
||||
}
|
||||
|
||||
export interface ProviderModelAlias {
|
||||
export interface ProviderModelMapping {
|
||||
name: string
|
||||
priority: number // 优先级(数字越小优先级越高)
|
||||
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
|
||||
}
|
||||
|
||||
// 保留别名以保持向后兼容
|
||||
export type ProviderModelAlias = ProviderModelMapping
|
||||
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
global_model_id?: string // 关联的 GlobalModel ID
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number | null // 按次计费价格
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
@@ -274,7 +288,7 @@ export interface Model {
|
||||
|
||||
export interface ModelCreate {
|
||||
provider_model_name: string // Provider 侧的主模型名称
|
||||
provider_model_aliases?: ProviderModelAlias[] // 模型名称别名列表(带优先级)
|
||||
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
|
||||
global_model_id: string // 关联的 GlobalModel ID(必填)
|
||||
// 计费配置(可选,为空时使用 GlobalModel 默认值)
|
||||
price_per_request?: number // 按次计费价格
|
||||
@@ -291,7 +305,7 @@ export interface ModelCreate {
|
||||
|
||||
export interface ModelUpdate {
|
||||
provider_model_name?: string
|
||||
provider_model_aliases?: ProviderModelAlias[] | null // 模型名称别名列表(带优先级)
|
||||
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
|
||||
global_model_id?: string
|
||||
price_per_request?: number | null // 按次计费价格(null 表示清空/使用默认值)
|
||||
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
|
||||
@@ -484,3 +498,42 @@ export interface GlobalModelListResponse {
|
||||
models: GlobalModelResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// ==================== 上游模型导入相关 ====================
|
||||
|
||||
/**
|
||||
* 上游模型(从提供商 API 获取的原始模型)
|
||||
*/
|
||||
export interface UpstreamModel {
|
||||
id: string
|
||||
owned_by?: string
|
||||
display_name?: string
|
||||
api_format?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入成功的模型信息
|
||||
*/
|
||||
export interface ImportFromUpstreamSuccessItem {
|
||||
model_id: string
|
||||
global_model_id: string
|
||||
global_model_name: string
|
||||
provider_model_id: string
|
||||
created_global_model: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入失败的模型信息
|
||||
*/
|
||||
export interface ImportFromUpstreamErrorItem {
|
||||
model_id: string
|
||||
error: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从上游提供商导入模型响应
|
||||
*/
|
||||
export interface ImportFromUpstreamResponse {
|
||||
success: ImportFromUpstreamSuccessItem[]
|
||||
errors: ImportFromUpstreamErrorItem[]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0 pointer-events-none">
|
||||
<!-- 对话框内容 -->
|
||||
<Transition
|
||||
enter-active-class="duration-300 ease-out"
|
||||
@@ -92,6 +92,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots, type Component } from 'vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps<{
|
||||
@@ -157,4 +158,14 @@ const maxWidthClass = computed(() => {
|
||||
const containerZIndex = computed(() => props.zIndex || 60)
|
||||
const backdropZIndex = computed(() => props.zIndex || 60)
|
||||
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (isOpen.value) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</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
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="100"
|
||||
placeholder="留空不限制"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
@@ -376,7 +376,7 @@ const form = ref<StandaloneKeyFormData>({
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
@@ -389,7 +389,7 @@ function resetForm() {
|
||||
initial_balance_usd: 10,
|
||||
expire_days: undefined,
|
||||
never_expire: true,
|
||||
rate_limit: 100,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
allowed_providers: [],
|
||||
allowed_api_formats: [],
|
||||
@@ -408,7 +408,7 @@ function loadKeyData() {
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
expire_days: props.apiKey.expire_days,
|
||||
never_expire: props.apiKey.never_expire,
|
||||
rate_limit: props.apiKey.rate_limit || 100,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
allowed_providers: props.apiKey.allowed_providers || [],
|
||||
allowed_api_formats: props.apiKey.allowed_api_formats || [],
|
||||
|
||||
@@ -698,6 +698,7 @@ import {
|
||||
Layers,
|
||||
BarChart3
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -833,6 +834,16 @@ watch(() => props.open, (newOpen) => {
|
||||
detailTab.value = 'basic'
|
||||
}
|
||||
})
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -31,29 +31,46 @@
|
||||
|
||||
<!-- 左右对比布局 -->
|
||||
<div class="flex gap-2 items-stretch">
|
||||
<!-- 左侧:可添加的模型 -->
|
||||
<!-- 左侧:可添加的模型(分组折叠) -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
可添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="availableModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllLeft"
|
||||
>
|
||||
{{ isAllLeftSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-medium shrink-0">
|
||||
可添加
|
||||
</p>
|
||||
<div class="flex-1 relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索模型..."
|
||||
class="pl-7 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="刷新上游模型"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
@click="fetchUpstreamModels(true)"
|
||||
>
|
||||
{{ availableModels.length }} 个
|
||||
</Badge>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
@@ -63,7 +80,7 @@
|
||||
<Loader2 class="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="availableModels.length === 0"
|
||||
v-else-if="totalAvailableCount === 0 && !upstreamModelsLoaded"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||
@@ -73,37 +90,142 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="p-2 space-y-1"
|
||||
class="p-2 space-y-2"
|
||||
>
|
||||
<!-- 全局模型折叠组 -->
|
||||
<div
|
||||
v-for="model in availableModels"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors"
|
||||
:class="selectedLeftIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50 cursor-pointer'"
|
||||
@click="toggleLeftSelection(model.id)"
|
||||
v-if="availableGlobalModels.length > 0 || !upstreamModelsLoaded"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedLeftIds.includes(model.id)"
|
||||
@update:checked="toggleLeftSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||
@click="toggleGroupCollapse('global')"
|
||||
>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
全局模型
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({{ availableGlobalModels.length }})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="availableGlobalModels.length > 0"
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click.stop="selectAllGlobalModels"
|
||||
>
|
||||
{{ isAllGlobalModelsSelected ? '取消' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
<div
|
||||
v-show="!collapsedGroups.has('global')"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
<div
|
||||
v-if="availableGlobalModels.length === 0"
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
所有全局模型均已关联
|
||||
</div>
|
||||
<div
|
||||
v-for="model in availableGlobalModels"
|
||||
v-else
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedGlobalModelIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50'"
|
||||
@click="toggleGlobalModelSelection(model.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedGlobalModelIds.includes(model.id)"
|
||||
@update:checked="toggleGlobalModelSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.display_name }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.name }}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
:variant="model.is_active ? 'outline' : 'secondary'"
|
||||
:class="model.is_active ? 'text-green-600 border-green-500/60' : ''"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ model.is_active ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 从提供商获取的模型折叠组 -->
|
||||
<div
|
||||
v-for="group in upstreamModelGroups"
|
||||
:key="group.api_format"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||
@click="toggleGroupCollapse(group.api_format)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click.stop="selectAllUpstreamModels(group.api_format)"
|
||||
>
|
||||
{{ isUpstreamGroupAllSelected(group.api_format) ? '取消' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has(group.api_format)"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors cursor-pointer"
|
||||
:class="selectedUpstreamModelIds.includes(model.id)
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'hover:bg-muted/50'"
|
||||
@click="toggleUpstreamModelSelection(model.id)"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectedUpstreamModelIds.includes(model.id)"
|
||||
@update:checked="toggleUpstreamModelSelection(model.id)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.id }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.owned_by || model.id }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,8 +237,8 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="w-9 h-8"
|
||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||
:disabled="selectedLeftIds.length === 0 || submittingAdd"
|
||||
:class="totalSelectedCount > 0 && !submittingAdd ? 'border-primary' : ''"
|
||||
:disabled="totalSelectedCount === 0 || submittingAdd"
|
||||
title="添加选中"
|
||||
@click="batchAddSelected"
|
||||
>
|
||||
@@ -127,7 +249,7 @@
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="w-6 h-6 stroke-[3]"
|
||||
:class="selectedLeftIds.length > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||
:class="totalSelectedCount > 0 && !submittingAdd ? 'text-primary' : ''"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
@@ -154,26 +276,18 @@
|
||||
<!-- 右侧:已添加的模型 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium">
|
||||
已添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="existingModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllRight"
|
||||
>
|
||||
{{ isAllRightSelected ? '取消全选' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
<p class="text-sm font-medium">
|
||||
已添加
|
||||
</p>
|
||||
<Button
|
||||
v-if="existingModels.length > 0"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleSelectAllRight"
|
||||
>
|
||||
{{ existingModels.length }} 个
|
||||
</Badge>
|
||||
{{ isAllRightSelected ? '取消' : '全选' }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
@@ -238,11 +352,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Layers, Loader2, ChevronRight, ChevronLeft } from 'lucide-vue-next'
|
||||
import { Layers, Loader2, ChevronRight, ChevronLeft, ChevronDown, Zap, RefreshCw, Search } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
@@ -253,8 +368,13 @@ import {
|
||||
getProviderModels,
|
||||
batchAssignModelsToProvider,
|
||||
deleteModel,
|
||||
importModelsFromUpstream,
|
||||
API_FORMAT_LABELS,
|
||||
type Model
|
||||
} from '@/api/endpoints'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -274,17 +394,27 @@ const { error: showError, success } = useToast()
|
||||
const loadingGlobalModels = ref(false)
|
||||
const submittingAdd = ref(false)
|
||||
const submittingRemove = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 数据
|
||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const existingModels = ref<Model[]>([])
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 选择状态
|
||||
const selectedLeftIds = ref<string[]>([])
|
||||
const selectedGlobalModelIds = ref<string[]>([])
|
||||
const selectedUpstreamModelIds = ref<string[]>([])
|
||||
const selectedRightIds = ref<string[]>([])
|
||||
|
||||
// 计算可添加的模型(排除已关联的)
|
||||
const availableModels = computed(() => {
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 搜索状态
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 计算可添加的全局模型(排除已关联的)
|
||||
const availableGlobalModelsBase = computed(() => {
|
||||
const existingGlobalModelIds = new Set(
|
||||
existingModels.value
|
||||
.filter(m => m.global_model_id)
|
||||
@@ -293,31 +423,123 @@ const availableModels = computed(() => {
|
||||
return allGlobalModels.value.filter(m => !existingGlobalModelIds.has(m.id))
|
||||
})
|
||||
|
||||
// 全选状态
|
||||
const isAllLeftSelected = computed(() =>
|
||||
availableModels.value.length > 0 &&
|
||||
selectedLeftIds.value.length === availableModels.value.length
|
||||
)
|
||||
// 搜索过滤后的全局模型
|
||||
const availableGlobalModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return availableGlobalModelsBase.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return availableGlobalModelsBase.value.filter(m =>
|
||||
m.name.toLowerCase().includes(query) ||
|
||||
m.display_name.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
|
||||
// 计算可添加的上游模型(排除已关联的)
|
||||
const availableUpstreamModelsBase = computed(() => {
|
||||
const existingModelNames = new Set(
|
||||
existingModels.value.map(m => m.provider_model_name)
|
||||
)
|
||||
return upstreamModels.value.filter(m => !existingModelNames.has(m.id))
|
||||
})
|
||||
|
||||
// 搜索过滤后的上游模型
|
||||
const availableUpstreamModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return availableUpstreamModelsBase.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return availableUpstreamModelsBase.value.filter(m =>
|
||||
m.id.toLowerCase().includes(query) ||
|
||||
(m.owned_by && m.owned_by.toLowerCase().includes(query))
|
||||
)
|
||||
})
|
||||
|
||||
// 按 API 格式分组的上游模型
|
||||
const upstreamModelGroups = computed(() => {
|
||||
const groups: Record<string, UpstreamModel[]> = {}
|
||||
|
||||
for (const model of availableUpstreamModels.value) {
|
||||
const format = model.api_format || 'unknown'
|
||||
if (!groups[format]) {
|
||||
groups[format] = []
|
||||
}
|
||||
groups[format].push(model)
|
||||
}
|
||||
|
||||
// 按 API_FORMAT_LABELS 的顺序排序
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return Object.entries(groups)
|
||||
.map(([api_format, models]) => ({ api_format, models }))
|
||||
.sort((a, b) => {
|
||||
const aIndex = order.indexOf(a.api_format)
|
||||
const bIndex = order.indexOf(b.api_format)
|
||||
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||
if (aIndex === -1) return 1
|
||||
if (bIndex === -1) return -1
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 总可添加数量
|
||||
const totalAvailableCount = computed(() => {
|
||||
return availableGlobalModels.value.length + availableUpstreamModels.value.length
|
||||
})
|
||||
|
||||
// 总选中数量
|
||||
const totalSelectedCount = computed(() => {
|
||||
return selectedGlobalModelIds.value.length + selectedUpstreamModelIds.value.length
|
||||
})
|
||||
|
||||
// 全选状态
|
||||
const isAllRightSelected = computed(() =>
|
||||
existingModels.value.length > 0 &&
|
||||
selectedRightIds.value.length === existingModels.value.length
|
||||
)
|
||||
|
||||
// 全局模型是否全选
|
||||
const isAllGlobalModelsSelected = computed(() => {
|
||||
if (availableGlobalModels.value.length === 0) return false
|
||||
return availableGlobalModels.value.every(m => selectedGlobalModelIds.value.includes(m.id))
|
||||
})
|
||||
|
||||
// 检查某个上游组是否全选
|
||||
function isUpstreamGroupAllSelected(apiFormat: string): boolean {
|
||||
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||
if (!group || group.models.length === 0) return false
|
||||
return group.models.every(m => selectedUpstreamModelIds.value.includes(m.id))
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
// 重置状态
|
||||
selectedLeftIds.value = []
|
||||
selectedGlobalModelIds.value = []
|
||||
selectedUpstreamModelIds.value = []
|
||||
selectedRightIds.value = []
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||
// 默认折叠全局模型组
|
||||
collapsedGroups.value = new Set(['global'])
|
||||
|
||||
// 检查缓存,如果有缓存数据则直接使用
|
||||
const cachedModels = getCachedModels(props.providerId)
|
||||
if (cachedModels) {
|
||||
upstreamModels.value = cachedModels
|
||||
upstreamModelsLoaded.value = true
|
||||
// 折叠所有上游模型组
|
||||
for (const model of cachedModels) {
|
||||
if (model.api_format) {
|
||||
collapsedGroups.value.add(model.api_format)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载全局模型列表
|
||||
@@ -342,13 +564,91 @@ async function loadExistingModels() {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换左侧选择
|
||||
function toggleLeftSelection(id: string) {
|
||||
const index = selectedLeftIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedLeftIds.value.push(id)
|
||||
// 从提供商获取模型
|
||||
async function fetchUpstreamModels(forceRefresh = false) {
|
||||
if (forceRefresh) {
|
||||
clearCache(props.providerId)
|
||||
}
|
||||
|
||||
try {
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId, forceRefresh)
|
||||
if (result) {
|
||||
if (result.error) {
|
||||
showError(result.error, '错误')
|
||||
} else {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 折叠所有上游模型组
|
||||
const allGroups = new Set(collapsedGroups.value)
|
||||
for (const model of result.models) {
|
||||
if (model.api_format) {
|
||||
allGroups.add(model.api_format)
|
||||
}
|
||||
}
|
||||
collapsedGroups.value = allGroups
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
selectedLeftIds.value.splice(index, 1)
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
// 触发响应式更新
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 切换全局模型选择
|
||||
function toggleGlobalModelSelection(id: string) {
|
||||
const index = selectedGlobalModelIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedGlobalModelIds.value.push(id)
|
||||
} else {
|
||||
selectedGlobalModelIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换上游模型选择
|
||||
function toggleUpstreamModelSelection(id: string) {
|
||||
const index = selectedUpstreamModelIds.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedUpstreamModelIds.value.push(id)
|
||||
} else {
|
||||
selectedUpstreamModelIds.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选全局模型
|
||||
function selectAllGlobalModels() {
|
||||
const allIds = availableGlobalModels.value.map(m => m.id)
|
||||
const allSelected = allIds.every(id => selectedGlobalModelIds.value.includes(id))
|
||||
if (allSelected) {
|
||||
selectedGlobalModelIds.value = selectedGlobalModelIds.value.filter(id => !allIds.includes(id))
|
||||
} else {
|
||||
const newIds = allIds.filter(id => !selectedGlobalModelIds.value.includes(id))
|
||||
selectedGlobalModelIds.value.push(...newIds)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选某个 API 格式的上游模型
|
||||
function selectAllUpstreamModels(apiFormat: string) {
|
||||
const group = upstreamModelGroups.value.find(g => g.api_format === apiFormat)
|
||||
if (!group) return
|
||||
|
||||
const allIds = group.models.map(m => m.id)
|
||||
const allSelected = allIds.every(id => selectedUpstreamModelIds.value.includes(id))
|
||||
if (allSelected) {
|
||||
selectedUpstreamModelIds.value = selectedUpstreamModelIds.value.filter(id => !allIds.includes(id))
|
||||
} else {
|
||||
const newIds = allIds.filter(id => !selectedUpstreamModelIds.value.includes(id))
|
||||
selectedUpstreamModelIds.value.push(...newIds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,15 +662,6 @@ function toggleRightSelection(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选左侧
|
||||
function toggleSelectAllLeft() {
|
||||
if (isAllLeftSelected.value) {
|
||||
selectedLeftIds.value = []
|
||||
} else {
|
||||
selectedLeftIds.value = availableModels.value.map(m => m.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选右侧
|
||||
function toggleSelectAllRight() {
|
||||
if (isAllRightSelected.value) {
|
||||
@@ -382,22 +673,41 @@ function toggleSelectAllRight() {
|
||||
|
||||
// 批量添加选中的模型
|
||||
async function batchAddSelected() {
|
||||
if (selectedLeftIds.value.length === 0) return
|
||||
if (totalSelectedCount.value === 0) return
|
||||
|
||||
try {
|
||||
submittingAdd.value = true
|
||||
const result = await batchAssignModelsToProvider(props.providerId, selectedLeftIds.value)
|
||||
let totalSuccess = 0
|
||||
const allErrors: string[] = []
|
||||
|
||||
if (result.success.length > 0) {
|
||||
success(`成功添加 ${result.success.length} 个模型`)
|
||||
// 处理全局模型
|
||||
if (selectedGlobalModelIds.value.length > 0) {
|
||||
const result = await batchAssignModelsToProvider(props.providerId, selectedGlobalModelIds.value)
|
||||
totalSuccess += result.success.length
|
||||
if (result.errors.length > 0) {
|
||||
allErrors.push(...result.errors.map(e => e.error))
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
const errorMessages = result.errors.map(e => e.error).join(', ')
|
||||
showError(`部分模型添加失败: ${errorMessages}`, '警告')
|
||||
// 处理上游模型(调用 import-from-upstream API)
|
||||
if (selectedUpstreamModelIds.value.length > 0) {
|
||||
const result = await importModelsFromUpstream(props.providerId, selectedUpstreamModelIds.value)
|
||||
totalSuccess += result.success.length
|
||||
if (result.errors.length > 0) {
|
||||
allErrors.push(...result.errors.map(e => e.error))
|
||||
}
|
||||
}
|
||||
|
||||
selectedLeftIds.value = []
|
||||
if (totalSuccess > 0) {
|
||||
success(`成功添加 ${totalSuccess} 个模型`)
|
||||
}
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
showError(`部分模型添加失败: ${allErrors.slice(0, 3).join(', ')}${allErrors.length > 3 ? '...' : ''}`, '警告')
|
||||
}
|
||||
|
||||
selectedGlobalModelIds.value = []
|
||||
selectedUpstreamModelIds.value = []
|
||||
await loadExistingModels()
|
||||
emit('changed')
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
>
|
||||
<form
|
||||
class="space-y-6"
|
||||
@submit.prevent="handleSubmit"
|
||||
@submit.prevent="handleSubmit()"
|
||||
>
|
||||
<!-- API 配置 -->
|
||||
<div class="space-y-4">
|
||||
@@ -132,6 +132,79 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">
|
||||
代理配置
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="proxyEnabled" />
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="proxyEnabled"
|
||||
class="space-y-4 rounded-lg border p-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_url">代理 URL *</Label>
|
||||
<Input
|
||||
id="proxy_url"
|
||||
v-model="form.proxy_url"
|
||||
placeholder="http://host:port 或 socks5://host:port"
|
||||
required
|
||||
:class="proxyUrlError ? 'border-red-500' : ''"
|
||||
/>
|
||||
<p
|
||||
v-if="proxyUrlError"
|
||||
class="text-xs text-red-500"
|
||||
>
|
||||
{{ proxyUrlError }}
|
||||
</p>
|
||||
<p
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
支持 HTTP、HTTPS、SOCKS5 代理
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="proxy_user">用户名(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_user_${formId}`"
|
||||
:name="`proxy_user_${formId}`"
|
||||
v-model="form.proxy_username"
|
||||
placeholder="代理认证用户名"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label :for="`proxy_pass_${formId}`">密码(可选)</Label>
|
||||
<Input
|
||||
:id="`proxy_pass_${formId}`"
|
||||
:name="`proxy_pass_${formId}`"
|
||||
v-model="form.proxy_password"
|
||||
type="text"
|
||||
:placeholder="passwordPlaceholder"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:style="{ '-webkit-text-security': 'disc', 'text-security': 'disc' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
@@ -145,12 +218,24 @@
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="loading || !form.base_url || (!isEditMode && !form.api_format)"
|
||||
@click="handleSubmit"
|
||||
@click="handleSubmit()"
|
||||
>
|
||||
{{ loading ? (isEditMode ? '保存中...' : '创建中...') : (isEditMode ? '保存修改' : '创建') }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- 确认清空凭据对话框 -->
|
||||
<AlertDialog
|
||||
v-model="showClearCredentialsDialog"
|
||||
title="清空代理凭据"
|
||||
description="代理 URL 为空,但用户名和密码仍有值。是否清空这些凭据并继续保存?"
|
||||
type="warning"
|
||||
confirm-text="清空并保存"
|
||||
cancel-text="返回编辑"
|
||||
@confirm="confirmClearCredentials"
|
||||
@cancel="showClearCredentialsDialog = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -165,7 +250,9 @@ import {
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
Switch,
|
||||
} from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import { Link, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
@@ -194,6 +281,11 @@ const emit = defineEmits<{
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const selectOpen = ref(false)
|
||||
const proxyEnabled = ref(false)
|
||||
const showClearCredentialsDialog = ref(false) // 确认清空凭据对话框
|
||||
|
||||
// 生成随机 ID 防止浏览器自动填充
|
||||
const formId = Math.random().toString(36).substring(2, 10)
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -207,7 +299,11 @@ const form = ref({
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined as number | undefined,
|
||||
rate_limit: undefined as number | undefined,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
// 代理配置
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
})
|
||||
|
||||
// API 格式列表
|
||||
@@ -237,6 +333,53 @@ const defaultPathPlaceholder = computed(() => {
|
||||
return `留空使用默认路径:${defaultPath.value}`
|
||||
})
|
||||
|
||||
// 检查是否有已保存的密码(后端返回 *** 表示有密码)
|
||||
const hasExistingPassword = computed(() => {
|
||||
if (!props.endpoint?.proxy) return false
|
||||
const proxy = props.endpoint.proxy as { password?: string }
|
||||
return proxy?.password === MASKED_PASSWORD
|
||||
})
|
||||
|
||||
// 密码输入框的 placeholder
|
||||
const passwordPlaceholder = computed(() => {
|
||||
if (hasExistingPassword.value) {
|
||||
return '已保存密码,留空保持不变'
|
||||
}
|
||||
return '代理认证密码'
|
||||
})
|
||||
|
||||
// 代理 URL 验证
|
||||
const proxyUrlError = computed(() => {
|
||||
// 只有启用代理且填写了 URL 时才验证
|
||||
if (!proxyEnabled.value || !form.value.proxy_url) {
|
||||
return ''
|
||||
}
|
||||
const url = form.value.proxy_url.trim()
|
||||
|
||||
// 检查禁止的特殊字符
|
||||
if (/[\n\r]/.test(url)) {
|
||||
return '代理 URL 包含非法字符'
|
||||
}
|
||||
|
||||
// 验证协议(不支持 SOCKS4)
|
||||
if (!/^(http|https|socks5):\/\//i.test(url)) {
|
||||
return '代理 URL 必须以 http://, https:// 或 socks5:// 开头'
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
if (!parsed.host) {
|
||||
return '代理 URL 必须包含有效的 host'
|
||||
}
|
||||
// 禁止 URL 中内嵌认证信息
|
||||
if (parsed.username || parsed.password) {
|
||||
return '请勿在 URL 中包含用户名和密码,请使用独立的认证字段'
|
||||
}
|
||||
} catch {
|
||||
return '代理 URL 格式无效'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// 组件挂载时加载API格式
|
||||
onMounted(() => {
|
||||
loadApiFormats()
|
||||
@@ -252,14 +395,23 @@ function resetForm() {
|
||||
max_retries: 3,
|
||||
max_concurrent: undefined,
|
||||
rate_limit: undefined,
|
||||
is_active: true
|
||||
is_active: true,
|
||||
proxy_url: '',
|
||||
proxy_username: '',
|
||||
proxy_password: '',
|
||||
}
|
||||
proxyEnabled.value = false
|
||||
}
|
||||
|
||||
// 原始密码占位符(后端返回的脱敏标记)
|
||||
const MASKED_PASSWORD = '***'
|
||||
|
||||
// 加载端点数据(编辑模式)
|
||||
function loadEndpointData() {
|
||||
if (!props.endpoint) return
|
||||
|
||||
const proxy = props.endpoint.proxy as { url?: string; username?: string; password?: string; enabled?: boolean } | null
|
||||
|
||||
form.value = {
|
||||
api_format: props.endpoint.api_format,
|
||||
base_url: props.endpoint.base_url,
|
||||
@@ -268,8 +420,15 @@ function loadEndpointData() {
|
||||
max_retries: props.endpoint.max_retries,
|
||||
max_concurrent: props.endpoint.max_concurrent || undefined,
|
||||
rate_limit: props.endpoint.rate_limit || undefined,
|
||||
is_active: props.endpoint.is_active
|
||||
is_active: props.endpoint.is_active,
|
||||
proxy_url: proxy?.url || '',
|
||||
proxy_username: proxy?.username || '',
|
||||
// 如果密码是脱敏标记,显示为空(让用户知道有密码但看不到)
|
||||
proxy_password: proxy?.password === MASKED_PASSWORD ? '' : (proxy?.password || ''),
|
||||
}
|
||||
|
||||
// 根据 enabled 字段或 url 存在判断是否启用代理
|
||||
proxyEnabled.value = proxy?.enabled ?? !!proxy?.url
|
||||
}
|
||||
|
||||
// 使用 useFormDialog 统一处理对话框逻辑
|
||||
@@ -282,12 +441,47 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 构建代理配置
|
||||
// - 有 URL 时始终保存配置,通过 enabled 字段控制是否启用
|
||||
// - 无 URL 时返回 null
|
||||
function buildProxyConfig(): { url: string; username?: string; password?: string; enabled: boolean } | null {
|
||||
if (!form.value.proxy_url) {
|
||||
// 没填 URL,无代理配置
|
||||
return null
|
||||
}
|
||||
return {
|
||||
url: form.value.proxy_url,
|
||||
username: form.value.proxy_username || undefined,
|
||||
password: form.value.proxy_password || undefined,
|
||||
enabled: proxyEnabled.value, // 开关状态决定是否启用
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmit = async (skipCredentialCheck = false) => {
|
||||
if (!props.provider && !props.endpoint) return
|
||||
|
||||
// 只在开关开启且填写了 URL 时验证
|
||||
if (proxyEnabled.value && form.value.proxy_url && proxyUrlError.value) {
|
||||
showError(proxyUrlError.value, '代理配置错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查:开关开启但没有 URL,却有用户名或密码
|
||||
const hasOrphanedCredentials = proxyEnabled.value
|
||||
&& !form.value.proxy_url
|
||||
&& (form.value.proxy_username || form.value.proxy_password)
|
||||
|
||||
if (hasOrphanedCredentials && !skipCredentialCheck) {
|
||||
// 弹出确认对话框
|
||||
showClearCredentialsDialog.value = true
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const proxyConfig = buildProxyConfig()
|
||||
|
||||
if (isEditMode.value && props.endpoint) {
|
||||
// 更新端点
|
||||
await updateEndpoint(props.endpoint.id, {
|
||||
@@ -297,7 +491,8 @@ const handleSubmit = async () => {
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
|
||||
success('端点已更新', '保存成功')
|
||||
@@ -313,7 +508,8 @@ const handleSubmit = async () => {
|
||||
max_retries: form.value.max_retries,
|
||||
max_concurrent: form.value.max_concurrent,
|
||||
rate_limit: form.value.rate_limit,
|
||||
is_active: form.value.is_active
|
||||
is_active: form.value.is_active,
|
||||
proxy: proxyConfig,
|
||||
})
|
||||
|
||||
success('端点创建成功', '成功')
|
||||
@@ -329,4 +525,12 @@ const handleSubmit = async () => {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 确认清空凭据并继续保存
|
||||
const confirmClearCredentials = () => {
|
||||
form.value.proxy_username = ''
|
||||
form.value.proxy_password = ''
|
||||
showClearCredentialsDialog.value = false
|
||||
handleSubmit(true) // 跳过凭据检查,直接提交
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 别名列表 -->
|
||||
<!-- 映射列表 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-sm font-medium">名称映射</Label>
|
||||
@@ -92,7 +92,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 别名输入框 -->
|
||||
<!-- 映射输入框 -->
|
||||
<Input
|
||||
v-model="alias.name"
|
||||
placeholder="映射名称,如 Claude-Sonnet-4.5"
|
||||
@@ -184,9 +184,9 @@ const editingPriorityIndex = ref<number | null>(null)
|
||||
// 监听 open 变化
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen && props.model) {
|
||||
// 加载现有别名配置
|
||||
if (props.model.provider_model_aliases && Array.isArray(props.model.provider_model_aliases)) {
|
||||
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_aliases))
|
||||
// 加载现有映射配置
|
||||
if (props.model.provider_model_mappings && Array.isArray(props.model.provider_model_mappings)) {
|
||||
aliases.value = JSON.parse(JSON.stringify(props.model.provider_model_mappings))
|
||||
} else {
|
||||
aliases.value = []
|
||||
}
|
||||
@@ -197,16 +197,16 @@ watch(() => props.open, (newOpen) => {
|
||||
}
|
||||
})
|
||||
|
||||
// 添加别名
|
||||
// 添加映射
|
||||
function addAlias() {
|
||||
// 新别名优先级为当前最大优先级 + 1,或者默认为 1
|
||||
// 新映射优先级为当前最大优先级 + 1,或者默认为 1
|
||||
const maxPriority = aliases.value.length > 0
|
||||
? Math.max(...aliases.value.map(a => a.priority))
|
||||
: 0
|
||||
aliases.value.push({ name: '', priority: maxPriority + 1 })
|
||||
}
|
||||
|
||||
// 移除别名
|
||||
// 移除映射
|
||||
function removeAlias(index: number) {
|
||||
aliases.value.splice(index, 1)
|
||||
}
|
||||
@@ -244,7 +244,7 @@ function handleDrop(targetIndex: number) {
|
||||
const items = [...aliases.value]
|
||||
const draggedItem = items[dragIndex]
|
||||
|
||||
// 记录每个别名的原始优先级(在修改前)
|
||||
// 记录每个映射的原始优先级(在修改前)
|
||||
const originalPriorityMap = new Map<number, number>()
|
||||
items.forEach((alias, idx) => {
|
||||
originalPriorityMap.set(idx, alias.priority)
|
||||
@@ -255,7 +255,7 @@ function handleDrop(targetIndex: number) {
|
||||
items.splice(targetIndex, 0, draggedItem)
|
||||
|
||||
// 按新顺序为每个组分配新的优先级
|
||||
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
|
||||
// 同组的映射保持相同的优先级(被拖动的映射单独成组)
|
||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||
let currentPriority = 1
|
||||
|
||||
@@ -263,12 +263,12 @@ function handleDrop(targetIndex: number) {
|
||||
const draggedOriginalPriority = originalPriorityMap.get(dragIndex)!
|
||||
|
||||
items.forEach((alias, newIdx) => {
|
||||
// 找到这个别名在原数组中的索引
|
||||
// 找到这个映射在原数组中的索引
|
||||
const originalIdx = aliases.value.findIndex(a => a === alias)
|
||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||
|
||||
if (alias === draggedItem) {
|
||||
// 被拖动的别名是独立的新组,获得当前优先级
|
||||
// 被拖动的映射是独立的新组,获得当前优先级
|
||||
alias.priority = currentPriority
|
||||
currentPriority++
|
||||
} else {
|
||||
@@ -318,11 +318,11 @@ async function handleSubmit() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// 过滤掉空的别名
|
||||
// 过滤掉空的映射
|
||||
const validAliases = aliases.value.filter(a => a.name.trim())
|
||||
|
||||
await updateModel(props.providerId, props.model.id, {
|
||||
provider_model_aliases: validAliases.length > 0 ? validAliases : null
|
||||
provider_model_mappings: validAliases.length > 0 ? validAliases : null
|
||||
})
|
||||
|
||||
showSuccess('映射配置已保存')
|
||||
|
||||
@@ -0,0 +1,777 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="editingGroup ? '编辑模型映射' : '添加模型映射'"
|
||||
:description="editingGroup ? '修改映射配置' : '为模型添加新的名称映射'"
|
||||
:icon="Tag"
|
||||
size="4xl"
|
||||
@update:model-value="$emit('update:open', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- 第一行:目标模型 | 作用域 -->
|
||||
<div class="flex gap-4">
|
||||
<!-- 目标模型 -->
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<Label class="text-xs">目标模型</Label>
|
||||
<Select
|
||||
v-model:open="modelSelectOpen"
|
||||
:model-value="formData.modelId"
|
||||
:disabled="!!editingGroup"
|
||||
@update:model-value="formData.modelId = $event"
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.global_model_display_name || model.provider_model_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 作用域 -->
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
|
||||
<div
|
||||
v-if="providerApiFormats.length > 0"
|
||||
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
|
||||
>
|
||||
<button
|
||||
v-for="format in providerApiFormats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
formData.apiFormats.includes(format)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background border border-border hover:bg-muted'
|
||||
]"
|
||||
@click="toggleApiFormat(format)"
|
||||
>
|
||||
{{ API_FORMAT_LABELS[format] || format }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-9 flex items-center text-xs text-muted-foreground"
|
||||
>
|
||||
无可用格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:两栏布局 -->
|
||||
<div class="flex gap-4 items-stretch">
|
||||
<!-- 左侧:上游模型列表 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-medium shrink-0">
|
||||
上游模型
|
||||
</span>
|
||||
<div class="flex-1 relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="upstreamModelSearch"
|
||||
placeholder="搜索模型..."
|
||||
class="pl-7 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="刷新列表"
|
||||
:disabled="refreshingUpstreamModels"
|
||||
@click="refreshUpstreamModels"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': refreshingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="获取上游模型列表"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<template v-if="upstreamModelsLoaded">
|
||||
<div
|
||||
v-if="groupedAvailableUpstreamModels.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">
|
||||
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="p-2 space-y-2"
|
||||
>
|
||||
<!-- 按分组显示(可折叠) -->
|
||||
<div
|
||||
v-for="group in groupedAvailableUpstreamModels"
|
||||
:key="group.api_format"
|
||||
class="border rounded-lg overflow-hidden"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 flex-1 hover:bg-muted/50 -mx-1 px-1 rounded transition-colors"
|
||||
@click="toggleGroupCollapse(group.api_format)"
|
||||
>
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has(group.api_format) ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">
|
||||
{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}
|
||||
</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
({{ group.models.length }})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has(group.api_format)"
|
||||
class="p-2 space-y-1 border-t"
|
||||
>
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
|
||||
:title="model.id"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-sm truncate">
|
||||
{{ model.id }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground truncate font-mono">
|
||||
{{ model.owned_by || model.id }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-primary/10 rounded transition-colors shrink-0"
|
||||
title="添加到映射"
|
||||
@click="addUpstreamModel(model.id)"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4 text-muted-foreground hover:text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未加载状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Zap class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">
|
||||
点击右上角按钮
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
从上游获取可用模型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:映射名称列表 -->
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium">
|
||||
映射名称
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 hover:bg-muted rounded-md transition-colors"
|
||||
title="手动添加"
|
||||
@click="addAliasItem"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="border rounded-lg h-80 overflow-y-auto">
|
||||
<div
|
||||
v-if="formData.aliases.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground"
|
||||
>
|
||||
<Tag class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">
|
||||
从左侧选择模型
|
||||
</p>
|
||||
<p class="text-xs mt-1">
|
||||
或点击上方"手动添加"
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="p-2 space-y-1"
|
||||
>
|
||||
<div
|
||||
v-for="(alias, index) in formData.aliases"
|
||||
:key="`alias-${index}`"
|
||||
class="group flex items-center gap-2 p-2 rounded-lg border transition-colors hover:bg-muted/30"
|
||||
:class="[
|
||||
draggedIndex === index ? 'bg-primary/5' : '',
|
||||
dragOverIndex === index ? 'bg-primary/10 border-primary' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover.prevent="handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop(index)"
|
||||
>
|
||||
<!-- 删除按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:bg-destructive/10 rounded transition-colors shrink-0"
|
||||
title="移除"
|
||||
@click="removeAliasItem(index)"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
|
||||
<!-- 优先级 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="editingPriorityIndex === index"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="alias.priority"
|
||||
class="w-7 h-6 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
autofocus
|
||||
@blur="finishEditPriority(index, $event)"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.escape="cancelEditPriority"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-6 h-6 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||
title="点击编辑优先级"
|
||||
@click.stop="startEditPriority(index)"
|
||||
>
|
||||
{{ alias.priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称显示/编辑 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<Input
|
||||
v-if="alias.isEditing"
|
||||
v-model="alias.name"
|
||||
placeholder="输入映射名称"
|
||||
class="h-7 text-xs"
|
||||
autofocus
|
||||
@blur="alias.isEditing = false"
|
||||
@keydown.enter="alias.isEditing = false"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="font-medium text-sm truncate cursor-pointer hover:text-primary"
|
||||
title="点击编辑"
|
||||
@click="alias.isEditing = true"
|
||||
>
|
||||
{{ alias.name || '点击输入名称' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 拖拽提示 -->
|
||||
<div
|
||||
v-if="formData.aliases.length > 1"
|
||||
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center"
|
||||
>
|
||||
拖拽调整优先级顺序
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="$emit('update:open', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
{{ editingGroup ? '保存' : '添加' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Tag, Loader2, GripVertical, Zap, Search, RefreshCw, ChevronDown, ChevronRight, ChevronLeft, Plus } from 'lucide-vue-next'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Dialog,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
API_FORMAT_LABELS,
|
||||
type Model,
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
interface FormAlias {
|
||||
name: string
|
||||
priority: number
|
||||
isEditing?: boolean
|
||||
}
|
||||
|
||||
export interface AliasGroup {
|
||||
model: Model
|
||||
apiFormatsKey: string
|
||||
apiFormats: string[]
|
||||
aliases: ProviderModelAlias[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
providerApiFormats: string[]
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { fetchModels: fetchCachedModels, clearCache, getCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const modelSelectOpen = ref(false)
|
||||
|
||||
// 拖拽状态
|
||||
const draggedIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
|
||||
// 优先级编辑状态
|
||||
const editingPriorityIndex = ref<number | null>(null)
|
||||
|
||||
// 快速添加(上游模型)状态
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const refreshingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
const upstreamModelSearch = ref('')
|
||||
|
||||
// 分组折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
modelId: string
|
||||
apiFormats: string[]
|
||||
aliases: FormAlias[]
|
||||
}>({
|
||||
modelId: '',
|
||||
apiFormats: [],
|
||||
aliases: []
|
||||
})
|
||||
|
||||
// 检查是否有有效的映射
|
||||
const hasValidAliases = computed(() => {
|
||||
return formData.value.aliases.some(a => a.name.trim())
|
||||
})
|
||||
|
||||
// 过滤和排序后的上游模型列表
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
const searchText = upstreamModelSearch.value.toLowerCase().trim()
|
||||
let result = [...upstreamModels.value]
|
||||
|
||||
result.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
if (searchText) {
|
||||
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(m => {
|
||||
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 按 API 格式分组的上游模型列表
|
||||
interface UpstreamModelGroup {
|
||||
api_format: string
|
||||
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
||||
}
|
||||
|
||||
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||
|
||||
const groups = new Map<string, UpstreamModelGroup>()
|
||||
|
||||
for (const model of availableModels) {
|
||||
const format = model.api_format || 'UNKNOWN'
|
||||
if (!groups.has(format)) {
|
||||
groups.set(format, { api_format: format, models: [] })
|
||||
}
|
||||
groups.get(format)!.models.push(model)
|
||||
}
|
||||
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
const aIndex = order.indexOf(a.api_format)
|
||||
const bIndex = order.indexOf(b.api_format)
|
||||
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||
if (aIndex === -1) return 1
|
||||
if (bIndex === -1) return -1
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化表单
|
||||
function initForm() {
|
||||
if (props.editingGroup) {
|
||||
formData.value = {
|
||||
modelId: props.editingGroup.model.id,
|
||||
apiFormats: [...props.editingGroup.apiFormats],
|
||||
aliases: props.editingGroup.aliases.map(a => ({ name: a.name, priority: a.priority }))
|
||||
}
|
||||
} else {
|
||||
formData.value = {
|
||||
modelId: '',
|
||||
apiFormats: [],
|
||||
aliases: []
|
||||
}
|
||||
}
|
||||
// 重置状态
|
||||
editingPriorityIndex.value = null
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
upstreamModelSearch.value = ''
|
||||
collapsedGroups.value = new Set()
|
||||
|
||||
// 检查缓存,如果有缓存数据则直接使用
|
||||
const cachedModels = getCachedModels(props.providerId)
|
||||
if (cachedModels) {
|
||||
upstreamModels.value = cachedModels
|
||||
upstreamModelsLoaded.value = true
|
||||
// 默认折叠所有分组
|
||||
for (const model of cachedModels) {
|
||||
if (model.api_format) {
|
||||
collapsedGroups.value.add(model.api_format)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
upstreamModelsLoaded.value = false
|
||||
upstreamModels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 切换 API 格式
|
||||
function toggleApiFormat(format: string) {
|
||||
const index = formData.value.apiFormats.indexOf(format)
|
||||
if (index >= 0) {
|
||||
formData.value.apiFormats.splice(index, 1)
|
||||
} else {
|
||||
formData.value.apiFormats.push(format)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换分组折叠状态
|
||||
function toggleGroupCollapse(apiFormat: string) {
|
||||
if (collapsedGroups.value.has(apiFormat)) {
|
||||
collapsedGroups.value.delete(apiFormat)
|
||||
} else {
|
||||
collapsedGroups.value.add(apiFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加映射项
|
||||
function addAliasItem() {
|
||||
const maxPriority = formData.value.aliases.length > 0
|
||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||
: 0
|
||||
formData.value.aliases.push({ name: '', priority: maxPriority + 1, isEditing: true })
|
||||
}
|
||||
|
||||
// 删除映射项
|
||||
function removeAliasItem(index: number) {
|
||||
formData.value.aliases.splice(index, 1)
|
||||
}
|
||||
|
||||
// ===== 拖拽排序 =====
|
||||
function handleDragStart(index: number, event: DragEvent) {
|
||||
draggedIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDragOver(index: number) {
|
||||
if (draggedIndex.value !== null && draggedIndex.value !== index) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDrop(targetIndex: number) {
|
||||
const dragIndex = draggedIndex.value
|
||||
if (dragIndex === null || dragIndex === targetIndex) {
|
||||
dragOverIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const items = [...formData.value.aliases]
|
||||
const draggedItem = items[dragIndex]
|
||||
|
||||
const originalPriorityMap = new Map<number, number>()
|
||||
items.forEach((alias, idx) => {
|
||||
originalPriorityMap.set(idx, alias.priority)
|
||||
})
|
||||
|
||||
items.splice(dragIndex, 1)
|
||||
items.splice(targetIndex, 0, draggedItem)
|
||||
|
||||
const groupNewPriority = new Map<number, number>()
|
||||
let currentPriority = 1
|
||||
|
||||
items.forEach((alias) => {
|
||||
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
|
||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||
|
||||
if (alias === draggedItem) {
|
||||
alias.priority = currentPriority
|
||||
currentPriority++
|
||||
} else {
|
||||
if (groupNewPriority.has(originalPriority)) {
|
||||
alias.priority = groupNewPriority.get(originalPriority)!
|
||||
} else {
|
||||
groupNewPriority.set(originalPriority, currentPriority)
|
||||
alias.priority = currentPriority
|
||||
currentPriority++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
formData.value.aliases = items
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// ===== 优先级编辑 =====
|
||||
function startEditPriority(index: number) {
|
||||
editingPriorityIndex.value = index
|
||||
}
|
||||
|
||||
function finishEditPriority(index: number, event: FocusEvent) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const newPriority = parseInt(input.value) || 1
|
||||
formData.value.aliases[index].priority = Math.max(1, newPriority)
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
function cancelEditPriority() {
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
// ===== 快速添加(上游模型)=====
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId) return
|
||||
|
||||
upstreamModelSearch.value = ''
|
||||
fetchingUpstreamModels.value = true
|
||||
|
||||
try {
|
||||
const result = await fetchCachedModels(props.providerId)
|
||||
if (result) {
|
||||
if (result.error) {
|
||||
showError(result.error, '错误')
|
||||
} else {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 默认折叠所有分组
|
||||
for (const model of result.models) {
|
||||
if (model.api_format) {
|
||||
collapsedGroups.value.add(model.api_format)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addUpstreamModel(modelId: string) {
|
||||
if (formData.value.aliases.some(a => a.name === modelId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxPriority = formData.value.aliases.length > 0
|
||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||
: 0
|
||||
|
||||
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
|
||||
}
|
||||
|
||||
async function refreshUpstreamModels() {
|
||||
if (!props.providerId || refreshingUpstreamModels.value) return
|
||||
|
||||
refreshingUpstreamModels.value = true
|
||||
clearCache(props.providerId)
|
||||
|
||||
try {
|
||||
const result = await fetchCachedModels(props.providerId, true)
|
||||
if (result) {
|
||||
if (result.error) {
|
||||
showError(result.error, '错误')
|
||||
} else {
|
||||
upstreamModels.value = result.models
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
refreshingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
return [...formats].sort().join(',')
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!formData.value.modelId || formData.value.aliases.length === 0) return
|
||||
|
||||
const validAliases = formData.value.aliases.filter(a => a.name.trim())
|
||||
if (validAliases.length === 0) {
|
||||
showError('请至少添加一个有效的映射名称', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const targetModel = props.models.find(m => m.id === formData.value.modelId)
|
||||
if (!targetModel) {
|
||||
showError('模型不存在', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
const currentAliases = targetModel.provider_model_mappings || []
|
||||
let newAliases: ProviderModelAlias[]
|
||||
|
||||
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
|
||||
name: a.name.trim(),
|
||||
priority: a.priority,
|
||||
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
|
||||
})
|
||||
|
||||
if (props.editingGroup) {
|
||||
const oldApiFormatsKey = props.editingGroup.apiFormatsKey
|
||||
const oldAliasNames = new Set(props.editingGroup.aliases.map(a => a.name))
|
||||
|
||||
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
|
||||
})
|
||||
|
||||
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
|
||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||
return
|
||||
}
|
||||
|
||||
newAliases = [
|
||||
...filteredAliases,
|
||||
...validAliases.map(buildAlias)
|
||||
]
|
||||
} else {
|
||||
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
|
||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||
return
|
||||
}
|
||||
newAliases = [
|
||||
...currentAliases,
|
||||
...validAliases.map(buildAlias)
|
||||
]
|
||||
}
|
||||
|
||||
await updateModel(props.providerId, targetModel.id, {
|
||||
provider_model_mappings: newAliases
|
||||
})
|
||||
|
||||
showSuccess(props.editingGroup ? '映射组已更新' : '映射已添加')
|
||||
emit('update:open', false)
|
||||
emit('saved')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -655,6 +655,7 @@ import {
|
||||
GripVertical,
|
||||
Copy
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
@@ -1296,6 +1297,16 @@ async function loadEndpoints() {
|
||||
showError(err.response?.data?.detail || '加载端点失败', '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -101,24 +101,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开的别名列表 -->
|
||||
<!-- 展开的映射列表 -->
|
||||
<div
|
||||
v-show="expandedAliasGroups.has(`${group.model.id}-${group.apiFormatsKey}`)"
|
||||
class="bg-muted/30 border-t border-border/30"
|
||||
>
|
||||
<div class="px-4 py-2 space-y-1">
|
||||
<div
|
||||
v-for="alias in group.aliases"
|
||||
:key="alias.name"
|
||||
v-for="mapping in group.aliases"
|
||||
:key="mapping.name"
|
||||
class="flex items-center gap-2 py-1"
|
||||
>
|
||||
<!-- 优先级标签 -->
|
||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded bg-background border text-xs font-medium shrink-0">
|
||||
{{ alias.priority }}
|
||||
{{ mapping.priority }}
|
||||
</span>
|
||||
<!-- 别名名称 -->
|
||||
<!-- 映射名称 -->
|
||||
<span class="font-mono text-sm truncate">
|
||||
{{ alias.name }}
|
||||
{{ mapping.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,330 +142,14 @@
|
||||
</Card>
|
||||
|
||||
<!-- 添加/编辑映射对话框 -->
|
||||
<Dialog
|
||||
v-model="dialogOpen"
|
||||
:title="editingItem ? '编辑模型映射' : '添加模型映射'"
|
||||
:description="editingItem ? '修改映射配置' : '为模型添加新的名称映射'"
|
||||
:icon="Tag"
|
||||
size="xl"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- 第一行:目标模型 | 作用域 -->
|
||||
<div class="flex gap-4">
|
||||
<!-- 目标模型 -->
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<Label class="text-xs">目标模型</Label>
|
||||
<Select
|
||||
v-model:open="modelSelectOpen"
|
||||
:model-value="formData.modelId"
|
||||
:disabled="!!editingItem"
|
||||
@update:model-value="formData.modelId = $event"
|
||||
>
|
||||
<SelectTrigger class="h-9">
|
||||
<SelectValue placeholder="请选择模型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="model in models"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.global_model_display_name || model.provider_model_name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 作用域 -->
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<Label class="text-xs">作用域 <span class="text-muted-foreground font-normal">(不选则适用全部)</span></Label>
|
||||
<div
|
||||
v-if="providerApiFormats.length > 0"
|
||||
class="flex flex-wrap gap-1.5 p-2 rounded-md border bg-muted/30 min-h-[36px]"
|
||||
>
|
||||
<button
|
||||
v-for="format in providerApiFormats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="px-2.5 py-0.5 rounded text-xs font-medium transition-colors"
|
||||
:class="[
|
||||
formData.apiFormats.includes(format)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-background border border-border hover:bg-muted'
|
||||
]"
|
||||
@click="toggleApiFormat(format)"
|
||||
>
|
||||
{{ API_FORMAT_LABELS[format] || format }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-9 flex items-center text-xs text-muted-foreground"
|
||||
>
|
||||
无可用格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行:上游模型 | 映射名称 -->
|
||||
<div class="flex gap-4 h-[340px]">
|
||||
<!-- 左侧:上游模型列表 -->
|
||||
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
|
||||
<!-- 左侧头部:标题 + 搜索 + 操作按钮 -->
|
||||
<div class="px-3 py-2 bg-muted/50 border-b flex items-center gap-2 shrink-0">
|
||||
<span class="text-xs font-medium shrink-0">上游模型</span>
|
||||
<!-- 搜索框 -->
|
||||
<div class="flex-1 relative">
|
||||
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="upstreamModelSearch"
|
||||
placeholder="搜索模型..."
|
||||
class="pl-7 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<!-- 操作按钮 -->
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
class="p-1.5 rounded hover:bg-muted transition-colors shrink-0"
|
||||
title="刷新列表"
|
||||
:disabled="refreshingUpstreamModels"
|
||||
@click="refreshUpstreamModels"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': refreshingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
class="p-1.5 rounded hover:bg-muted transition-colors shrink-0"
|
||||
title="获取上游模型列表"
|
||||
@click="fetchUpstreamModels"
|
||||
>
|
||||
<Zap class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-3.5 h-3.5 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<template v-if="upstreamModelsLoaded">
|
||||
<!-- 按分组显示(可折叠) -->
|
||||
<div
|
||||
v-for="group in groupedAvailableUpstreamModels"
|
||||
:key="group.api_format"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 z-10 px-3 py-1.5 bg-muted/80 backdrop-blur-sm border-b flex items-center justify-between cursor-pointer hover:bg-muted/90 transition-colors"
|
||||
@click="toggleGroupCollapse(group.api_format)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ChevronRight
|
||||
class="w-3.5 h-3.5 transition-transform"
|
||||
:class="{ 'rotate-90': !collapsedGroups.has(group.api_format) }"
|
||||
/>
|
||||
<span class="text-xs font-medium">{{ API_FORMAT_LABELS[group.api_format] || group.api_format }}</span>
|
||||
<span class="text-xs text-muted-foreground">({{ group.models.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-primary hover:underline"
|
||||
@click.stop="addAllFromGroup(group.api_format)"
|
||||
>
|
||||
全部添加
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="!collapsedGroups.has(group.api_format)">
|
||||
<div
|
||||
v-for="model in group.models"
|
||||
:key="model.id"
|
||||
class="group flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
:title="model.id"
|
||||
@click="addUpstreamModel(model.id)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-mono text-xs truncate">
|
||||
{{ model.id }}
|
||||
</div>
|
||||
<div
|
||||
v-if="model.owned_by"
|
||||
class="text-xs text-muted-foreground truncate"
|
||||
>
|
||||
{{ model.owned_by }}
|
||||
</div>
|
||||
</div>
|
||||
<Plus class="w-3.5 h-3.5 text-muted-foreground/50 group-hover:text-primary transition-colors shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="groupedAvailableUpstreamModels.length === 0"
|
||||
class="flex items-center justify-center h-full text-muted-foreground text-xs p-4"
|
||||
>
|
||||
{{ upstreamModelSearch ? '没有匹配的模型' : '所有模型已添加' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未加载状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground p-4"
|
||||
>
|
||||
<Zap class="w-8 h-8 mb-2 opacity-30" />
|
||||
<p class="text-xs text-center">
|
||||
点击右上角按钮<br>从上游获取可用模型
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:映射模型(编辑模式下全宽) -->
|
||||
<div class="flex-1 flex flex-col border rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 bg-primary/5 border-b flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium">映射名称</span>
|
||||
<Badge
|
||||
v-if="formData.aliases.length > 0"
|
||||
variant="secondary"
|
||||
class="text-xs h-5"
|
||||
>
|
||||
{{ formData.aliases.length }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-if="formData.aliases.length > 0"
|
||||
class="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-destructive transition-colors"
|
||||
title="清空"
|
||||
@click="formData.aliases = []"
|
||||
>
|
||||
<Eraser class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded hover:bg-muted transition-colors"
|
||||
title="手动添加"
|
||||
@click="addAliasItem"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选列表 -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="formData.aliases.length > 0"
|
||||
class="divide-y divide-border/30"
|
||||
>
|
||||
<div
|
||||
v-for="(alias, index) in formData.aliases"
|
||||
:key="`alias-${index}`"
|
||||
class="group flex items-center gap-1.5 px-2 py-1.5 hover:bg-muted/30 transition-colors"
|
||||
:class="[
|
||||
draggedIndex === index ? 'bg-primary/5' : '',
|
||||
dragOverIndex === index ? 'bg-primary/10' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart(index, $event)"
|
||||
@dragend="handleDragEnd"
|
||||
@dragover.prevent="handleDragOver(index)"
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="handleDrop(index)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground shrink-0">
|
||||
<GripVertical class="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
<!-- 优先级 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="editingPriorityIndex === index"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="alias.priority"
|
||||
class="w-6 h-5 rounded bg-background border border-primary text-xs text-center focus:outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
autofocus
|
||||
@blur="finishEditPriority(index, $event)"
|
||||
@keydown.enter="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.escape="cancelEditPriority"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-xs text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||
title="点击编辑优先级"
|
||||
@click.stop="startEditPriority(index)"
|
||||
>
|
||||
{{ alias.priority }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 名称输入 -->
|
||||
<Input
|
||||
v-model="alias.name"
|
||||
placeholder="映射名称"
|
||||
class="flex-1 h-6 text-xs px-2"
|
||||
/>
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="shrink-0 text-muted-foreground hover:text-destructive h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="removeAliasItem(index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center h-full text-muted-foreground p-4"
|
||||
>
|
||||
<Tag class="w-8 h-8 mb-2 opacity-30" />
|
||||
<p class="text-xs text-center">
|
||||
从左侧选择模型<br>或手动添加映射
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 拖拽提示 -->
|
||||
<div
|
||||
v-if="formData.aliases.length > 1"
|
||||
class="px-3 py-1.5 bg-muted/30 border-t text-xs text-muted-foreground text-center shrink-0"
|
||||
>
|
||||
拖拽调整优先级顺序
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="dialogOpen = false"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="submitting || !formData.modelId || formData.aliases.length === 0 || !hasValidAliases"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<Loader2
|
||||
v-if="submitting"
|
||||
class="w-4 h-4 mr-2 animate-spin"
|
||||
/>
|
||||
{{ editingItem ? '保存' : '添加' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
<ModelMappingDialog
|
||||
v-model:open="dialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:provider-api-formats="providerApiFormats"
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
@saved="onDialogSaved"
|
||||
/>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<AlertDialog
|
||||
@@ -482,21 +166,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Tag, Plus, Edit, Trash2, Loader2, GripVertical, X, Zap, Search, RefreshCw, ChevronRight, Eraser } from 'lucide-vue-next'
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Dialog,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { Tag, Plus, Edit, Trash2, ChevronRight } from 'lucide-vue-next'
|
||||
import { Card, Button, Badge } from '@/components/ui'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import ModelMappingDialog, { type AliasGroup } from '../ModelMappingDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getProviderModels,
|
||||
@@ -505,17 +178,6 @@ import {
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
interface AliasItem {
|
||||
model: Model
|
||||
alias: ProviderModelAlias
|
||||
}
|
||||
|
||||
interface FormAlias {
|
||||
name: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
@@ -532,131 +194,22 @@ const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
const dialogOpen = ref(false)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editingItem = ref<AliasItem | null>(null)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const modelSelectOpen = ref(false)
|
||||
|
||||
// 拖拽状态
|
||||
const draggedIndex = ref<number | null>(null)
|
||||
const dragOverIndex = ref<number | null>(null)
|
||||
|
||||
// 优先级编辑状态
|
||||
const editingPriorityIndex = ref<number | null>(null)
|
||||
|
||||
// 快速添加(上游模型)状态
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const refreshingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
const upstreamModels = ref<Array<{ id: string; owned_by?: string; api_format?: string }>>([])
|
||||
const upstreamModelSearch = ref('')
|
||||
|
||||
// 分组折叠状态(上游模型列表)
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 列表展开状态(映射组列表)
|
||||
// 列表展开状态
|
||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 上游模型缓存(按 Provider ID)
|
||||
const upstreamModelsCache = ref<Map<string, {
|
||||
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
||||
timestamp: number
|
||||
}>>(new Map())
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5 分钟缓存
|
||||
|
||||
// 过滤和排序后的上游模型列表
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
const searchText = upstreamModelSearch.value.toLowerCase().trim()
|
||||
let result = [...upstreamModels.value]
|
||||
|
||||
// 按名称排序
|
||||
result.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
// 搜索过滤(支持空格分隔的多关键词 AND 搜索)
|
||||
if (searchText) {
|
||||
const keywords = searchText.split(/\s+/).filter(k => k.length > 0)
|
||||
result = result.filter(m => {
|
||||
const searchableText = `${m.id} ${m.owned_by || ''} ${m.api_format || ''}`.toLowerCase()
|
||||
return keywords.every(keyword => searchableText.includes(keyword))
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 按 API 格式分组的上游模型列表
|
||||
interface UpstreamModelGroup {
|
||||
api_format: string
|
||||
models: Array<{ id: string; owned_by?: string; api_format?: string }>
|
||||
}
|
||||
|
||||
// 可添加的上游模型(排除已添加的)按分组显示
|
||||
const groupedAvailableUpstreamModels = computed<UpstreamModelGroup[]>(() => {
|
||||
// 获取已添加的映射名称集合
|
||||
const addedNames = new Set(formData.value.aliases.map(a => a.name.trim()))
|
||||
|
||||
// 过滤掉已添加的模型
|
||||
const availableModels = filteredUpstreamModels.value.filter(m => !addedNames.has(m.id))
|
||||
|
||||
// 按 API 格式分组
|
||||
const groups = new Map<string, UpstreamModelGroup>()
|
||||
|
||||
for (const model of availableModels) {
|
||||
const format = model.api_format || 'UNKNOWN'
|
||||
if (!groups.has(format)) {
|
||||
groups.set(format, { api_format: format, models: [] })
|
||||
}
|
||||
groups.get(format)!.models.push(model)
|
||||
}
|
||||
|
||||
// 按 API_FORMAT_LABELS 的键顺序排序
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return Array.from(groups.values()).sort((a, b) => {
|
||||
const aIndex = order.indexOf(a.api_format)
|
||||
const bIndex = order.indexOf(b.api_format)
|
||||
// 未知格式排最后
|
||||
if (aIndex === -1 && bIndex === -1) return a.api_format.localeCompare(b.api_format)
|
||||
if (aIndex === -1) return 1
|
||||
if (bIndex === -1) return -1
|
||||
return aIndex - bIndex
|
||||
})
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
modelId: string
|
||||
apiFormats: string[]
|
||||
aliases: FormAlias[]
|
||||
}>({
|
||||
modelId: '',
|
||||
apiFormats: [],
|
||||
aliases: []
|
||||
})
|
||||
|
||||
// 检查是否有有效的别名
|
||||
const hasValidAliases = computed(() => {
|
||||
return formData.value.aliases.some(a => a.name.trim())
|
||||
})
|
||||
|
||||
// 获取 Provider 支持的 API 格式(按 API_FORMATS 定义的顺序排序)
|
||||
// 获取 Provider 支持的 API 格式
|
||||
const providerApiFormats = computed(() => {
|
||||
const formats = props.provider?.api_formats
|
||||
if (Array.isArray(formats) && formats.length > 0) {
|
||||
// 按 API_FORMAT_LABELS 中的键顺序排序
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 分组数据结构
|
||||
interface AliasGroup {
|
||||
model: Model
|
||||
apiFormatsKey: string // 作用域的唯一标识(排序后的格式数组 JSON)
|
||||
apiFormats: string[] // 作用域
|
||||
aliases: ProviderModelAlias[] // 该组的所有映射
|
||||
}
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
@@ -669,9 +222,9 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
const groupMap = new Map<string, AliasGroup>()
|
||||
|
||||
for (const model of models.value) {
|
||||
if (!model.provider_model_aliases || !Array.isArray(model.provider_model_aliases)) continue
|
||||
if (!model.provider_model_mappings || !Array.isArray(model.provider_model_mappings)) continue
|
||||
|
||||
for (const alias of model.provider_model_aliases) {
|
||||
for (const alias of model.provider_model_mappings) {
|
||||
const apiFormatsKey = getApiFormatsKey(alias.api_formats)
|
||||
const groupKey = `${model.id}|${apiFormatsKey}`
|
||||
|
||||
@@ -689,12 +242,10 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 对每个组内的别名按优先级排序
|
||||
for (const group of groups) {
|
||||
group.aliases.sort((a, b) => a.priority - b.priority)
|
||||
}
|
||||
|
||||
// 按模型名排序,同模型内按作用域排序
|
||||
return groups.sort((a, b) => {
|
||||
const nameA = (a.model.global_model_display_name || a.model.provider_model_name || '').toLowerCase()
|
||||
const nameB = (b.model.global_model_display_name || b.model.provider_model_name || '').toLowerCase()
|
||||
@@ -703,9 +254,6 @@ const aliasGroups = computed<AliasGroup[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 当前编辑的分组
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
|
||||
// 加载模型
|
||||
async function loadModels() {
|
||||
try {
|
||||
@@ -728,25 +276,6 @@ const deleteConfirmDescription = computed(() => {
|
||||
return `确定要删除模型「${modelName}」在作用域「${scopeText}」下的 ${aliases.length} 个映射吗?\n\n映射名称:${aliasNames}`
|
||||
})
|
||||
|
||||
// 切换 API 格式
|
||||
function toggleApiFormat(format: string) {
|
||||
const index = formData.value.apiFormats.indexOf(format)
|
||||
if (index >= 0) {
|
||||
formData.value.apiFormats.splice(index, 1)
|
||||
} else {
|
||||
formData.value.apiFormats.push(format)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换分组折叠状态(上游模型列表)
|
||||
function toggleGroupCollapse(apiFormat: string) {
|
||||
if (collapsedGroups.value.has(apiFormat)) {
|
||||
collapsedGroups.value.delete(apiFormat)
|
||||
} else {
|
||||
collapsedGroups.value.add(apiFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换映射组展开状态
|
||||
function toggleAliasGroupExpand(groupKey: string) {
|
||||
if (expandedAliasGroups.value.has(groupKey)) {
|
||||
@@ -756,147 +285,15 @@ function toggleAliasGroupExpand(groupKey: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加别名项
|
||||
function addAliasItem() {
|
||||
const maxPriority = formData.value.aliases.length > 0
|
||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||
: 0
|
||||
formData.value.aliases.push({ name: '', priority: maxPriority + 1 })
|
||||
}
|
||||
|
||||
// 删除别名项
|
||||
function removeAliasItem(index: number) {
|
||||
formData.value.aliases.splice(index, 1)
|
||||
}
|
||||
|
||||
// ===== 拖拽排序 =====
|
||||
function handleDragStart(index: number, event: DragEvent) {
|
||||
draggedIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDragOver(index: number) {
|
||||
if (draggedIndex.value !== null && draggedIndex.value !== index) {
|
||||
dragOverIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
function handleDrop(targetIndex: number) {
|
||||
const dragIndex = draggedIndex.value
|
||||
if (dragIndex === null || dragIndex === targetIndex) {
|
||||
dragOverIndex.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const items = [...formData.value.aliases]
|
||||
const draggedItem = items[dragIndex]
|
||||
|
||||
// 记录每个别名的原始优先级(在修改前)
|
||||
const originalPriorityMap = new Map<number, number>()
|
||||
items.forEach((alias, idx) => {
|
||||
originalPriorityMap.set(idx, alias.priority)
|
||||
})
|
||||
|
||||
// 重排数组
|
||||
items.splice(dragIndex, 1)
|
||||
items.splice(targetIndex, 0, draggedItem)
|
||||
|
||||
// 按新顺序为每个组分配新的优先级
|
||||
// 同组的别名保持相同的优先级(被拖动的别名单独成组)
|
||||
const groupNewPriority = new Map<number, number>() // 原优先级 -> 新优先级
|
||||
let currentPriority = 1
|
||||
|
||||
items.forEach((alias) => {
|
||||
// 找到这个别名在原数组中的索引
|
||||
const originalIdx = formData.value.aliases.findIndex(a => a === alias)
|
||||
const originalPriority = originalIdx >= 0 ? originalPriorityMap.get(originalIdx)! : alias.priority
|
||||
|
||||
if (alias === draggedItem) {
|
||||
// 被拖动的别名是独立的新组,获得当前优先级
|
||||
alias.priority = currentPriority
|
||||
currentPriority++
|
||||
} else {
|
||||
if (groupNewPriority.has(originalPriority)) {
|
||||
// 这个组已经分配过优先级,使用相同的值
|
||||
alias.priority = groupNewPriority.get(originalPriority)!
|
||||
} else {
|
||||
// 这个组第一次出现,分配新优先级
|
||||
groupNewPriority.set(originalPriority, currentPriority)
|
||||
alias.priority = currentPriority
|
||||
currentPriority++
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
formData.value.aliases = items
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
}
|
||||
|
||||
// ===== 优先级编辑 =====
|
||||
function startEditPriority(index: number) {
|
||||
editingPriorityIndex.value = index
|
||||
}
|
||||
|
||||
function finishEditPriority(index: number, event: FocusEvent) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const newPriority = parseInt(input.value) || 1
|
||||
formData.value.aliases[index].priority = Math.max(1, newPriority)
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
function cancelEditPriority() {
|
||||
editingPriorityIndex.value = null
|
||||
}
|
||||
|
||||
// 打开添加对话框
|
||||
function openAddDialog() {
|
||||
editingItem.value = null
|
||||
editingGroup.value = null
|
||||
formData.value = {
|
||||
modelId: '',
|
||||
apiFormats: [],
|
||||
aliases: []
|
||||
}
|
||||
// 重置状态
|
||||
editingPriorityIndex.value = null
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
// 重置上游模型状态
|
||||
upstreamModelsLoaded.value = false
|
||||
upstreamModels.value = []
|
||||
upstreamModelSearch.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
// 编辑分组
|
||||
function editGroup(group: AliasGroup) {
|
||||
editingGroup.value = group
|
||||
editingItem.value = { model: group.model, alias: group.aliases[0] } // 保持兼容
|
||||
formData.value = {
|
||||
modelId: group.model.id,
|
||||
apiFormats: [...group.apiFormats],
|
||||
aliases: group.aliases.map(a => ({ name: a.name, priority: a.priority }))
|
||||
}
|
||||
// 重置状态
|
||||
editingPriorityIndex.value = null
|
||||
draggedIndex.value = null
|
||||
dragOverIndex.value = null
|
||||
// 重置上游模型状态
|
||||
upstreamModelsLoaded.value = false
|
||||
upstreamModels.value = []
|
||||
upstreamModelSearch.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -913,17 +310,15 @@ async function confirmDelete() {
|
||||
const { model, aliases, apiFormatsKey } = deletingGroup.value
|
||||
|
||||
try {
|
||||
// 从模型的别名列表中移除该分组的所有别名
|
||||
const currentAliases = model.provider_model_aliases || []
|
||||
const currentAliases = model.provider_model_mappings || []
|
||||
const aliasNamesToRemove = new Set(aliases.map(a => a.name))
|
||||
const newAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
// 只移除同一作用域的别名
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
return !(currentKey === apiFormatsKey && aliasNamesToRemove.has(a.name))
|
||||
})
|
||||
|
||||
await updateModel(props.provider.id, model.id, {
|
||||
provider_model_aliases: newAliases.length > 0 ? newAliases : null
|
||||
provider_model_mappings: newAliases.length > 0 ? newAliases : null
|
||||
})
|
||||
|
||||
showSuccess('映射组已删除')
|
||||
@@ -936,89 +331,10 @@ async function confirmDelete() {
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
async function handleSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!formData.value.modelId || formData.value.aliases.length === 0) return
|
||||
|
||||
// 过滤有效的别名
|
||||
const validAliases = formData.value.aliases.filter(a => a.name.trim())
|
||||
if (validAliases.length === 0) {
|
||||
showError('请至少添加一个有效的映射名称', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const targetModel = models.value.find(m => m.id === formData.value.modelId)
|
||||
if (!targetModel) {
|
||||
showError('模型不存在', '错误')
|
||||
return
|
||||
}
|
||||
|
||||
const currentAliases = targetModel.provider_model_aliases || []
|
||||
let newAliases: ProviderModelAlias[]
|
||||
|
||||
// 构建新的别名对象(带作用域)
|
||||
const buildAlias = (a: FormAlias): ProviderModelAlias => ({
|
||||
name: a.name.trim(),
|
||||
priority: a.priority,
|
||||
...(formData.value.apiFormats.length > 0 ? { api_formats: formData.value.apiFormats } : {})
|
||||
})
|
||||
|
||||
if (editingGroup.value) {
|
||||
// 编辑分组模式:替换该分组的所有别名
|
||||
const oldApiFormatsKey = editingGroup.value.apiFormatsKey
|
||||
const oldAliasNames = new Set(editingGroup.value.aliases.map(a => a.name))
|
||||
|
||||
// 移除旧分组的所有别名
|
||||
const filteredAliases = currentAliases.filter((a: ProviderModelAlias) => {
|
||||
const currentKey = getApiFormatsKey(a.api_formats)
|
||||
return !(currentKey === oldApiFormatsKey && oldAliasNames.has(a.name))
|
||||
})
|
||||
|
||||
// 检查新别名是否与其他分组的别名重复
|
||||
const existingNames = new Set(filteredAliases.map((a: ProviderModelAlias) => a.name))
|
||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新的别名
|
||||
newAliases = [
|
||||
...filteredAliases,
|
||||
...validAliases.map(buildAlias)
|
||||
]
|
||||
} else {
|
||||
// 添加模式:检查是否重复并批量添加
|
||||
const existingNames = new Set(currentAliases.map((a: ProviderModelAlias) => a.name))
|
||||
const duplicates = validAliases.filter(a => existingNames.has(a.name.trim()))
|
||||
if (duplicates.length > 0) {
|
||||
showError(`以下映射名称已存在:${duplicates.map(d => d.name).join(', ')}`, '错误')
|
||||
return
|
||||
}
|
||||
newAliases = [
|
||||
...currentAliases,
|
||||
...validAliases.map(buildAlias)
|
||||
]
|
||||
}
|
||||
|
||||
await updateModel(props.provider.id, targetModel.id, {
|
||||
provider_model_aliases: newAliases
|
||||
})
|
||||
|
||||
showSuccess(editingGroup.value ? '映射组已更新' : '映射已添加')
|
||||
dialogOpen.value = false
|
||||
editingGroup.value = null
|
||||
editingItem.value = null
|
||||
await loadModels()
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '操作失败', '错误')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
// 对话框保存后回调
|
||||
async function onDialogSaved() {
|
||||
await loadModels()
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
// 监听 provider 变化
|
||||
@@ -1033,103 +349,4 @@ onMounted(() => {
|
||||
loadModels()
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 快速添加(上游模型)=====
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.provider?.id) return
|
||||
|
||||
const providerId = props.provider.id
|
||||
upstreamModelSearch.value = ''
|
||||
|
||||
// 检查缓存
|
||||
const cached = upstreamModelsCache.value.get(providerId)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
upstreamModels.value = cached.models
|
||||
upstreamModelsLoaded.value = true
|
||||
return
|
||||
}
|
||||
|
||||
fetchingUpstreamModels.value = true
|
||||
upstreamModels.value = []
|
||||
|
||||
try {
|
||||
const response = await adminApi.queryProviderModels(providerId)
|
||||
if (response.success && response.data?.models) {
|
||||
upstreamModels.value = response.data.models
|
||||
// 写入缓存
|
||||
upstreamModelsCache.value.set(providerId, {
|
||||
models: response.data.models,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
upstreamModelsLoaded.value = true
|
||||
} else {
|
||||
showError(response.data?.error || '获取模型列表失败', '错误')
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '获取模型列表失败', '错误')
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加单个上游模型
|
||||
function addUpstreamModel(modelId: string) {
|
||||
// 检查是否已存在
|
||||
if (formData.value.aliases.some(a => a.name === modelId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxPriority = formData.value.aliases.length > 0
|
||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||
: 0
|
||||
|
||||
formData.value.aliases.push({ name: modelId, priority: maxPriority + 1 })
|
||||
}
|
||||
|
||||
// 添加某个分组的所有模型
|
||||
function addAllFromGroup(apiFormat: string) {
|
||||
const group = groupedAvailableUpstreamModels.value.find(g => g.api_format === apiFormat)
|
||||
if (!group) return
|
||||
|
||||
let maxPriority = formData.value.aliases.length > 0
|
||||
? Math.max(...formData.value.aliases.map(a => a.priority))
|
||||
: 0
|
||||
|
||||
for (const model of group.models) {
|
||||
// 检查是否已存在
|
||||
if (!formData.value.aliases.some(a => a.name === model.id)) {
|
||||
maxPriority++
|
||||
formData.value.aliases.push({ name: model.id, priority: maxPriority })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新上游模型列表(清除缓存并重新获取)
|
||||
async function refreshUpstreamModels() {
|
||||
if (!props.provider?.id || refreshingUpstreamModels.value) return
|
||||
|
||||
const providerId = props.provider.id
|
||||
refreshingUpstreamModels.value = true
|
||||
|
||||
// 清除缓存
|
||||
upstreamModelsCache.value.delete(providerId)
|
||||
|
||||
try {
|
||||
const response = await adminApi.queryProviderModels(providerId)
|
||||
if (response.success && response.data?.models) {
|
||||
upstreamModels.value = response.data.models
|
||||
// 写入缓存
|
||||
upstreamModelsCache.value.set(providerId, {
|
||||
models: response.data.models,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
} else {
|
||||
showError(response.data?.error || '刷新失败', '错误')
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || '刷新失败', '错误')
|
||||
} finally {
|
||||
refreshingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 上游模型缓存 - 共享缓存,避免重复请求
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import type { UpstreamModel } from '@/api/endpoints/types'
|
||||
|
||||
// 扩展类型,包含可能的额外字段
|
||||
export type { UpstreamModel }
|
||||
|
||||
interface CacheEntry {
|
||||
models: UpstreamModel[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type FetchResult = { models: UpstreamModel[]; error?: string }
|
||||
|
||||
// 全局缓存(模块级别,所有组件共享)
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5分钟
|
||||
|
||||
// 进行中的请求(用于去重并发请求)
|
||||
const pendingRequests = new Map<string, Promise<FetchResult>>()
|
||||
|
||||
// 请求状态
|
||||
const loadingMap = ref<Map<string, boolean>>(new Map())
|
||||
|
||||
export function useUpstreamModelsCache() {
|
||||
/**
|
||||
* 获取上游模型列表
|
||||
* @param providerId 提供商ID
|
||||
* @param forceRefresh 是否强制刷新
|
||||
* @returns 模型列表或 null(如果请求失败)
|
||||
*/
|
||||
async function fetchModels(
|
||||
providerId: string,
|
||||
forceRefresh = false
|
||||
): Promise<FetchResult> {
|
||||
// 检查缓存
|
||||
if (!forceRefresh) {
|
||||
const cached = cache.get(providerId)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return { models: cached.models }
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有进行中的请求(非强制刷新时复用)
|
||||
if (!forceRefresh && pendingRequests.has(providerId)) {
|
||||
return pendingRequests.get(providerId)!
|
||||
}
|
||||
|
||||
// 创建新请求
|
||||
const requestPromise = (async (): Promise<FetchResult> => {
|
||||
try {
|
||||
loadingMap.value.set(providerId, true)
|
||||
const response = await adminApi.queryProviderModels(providerId)
|
||||
|
||||
if (response.success && response.data?.models) {
|
||||
// 存入缓存
|
||||
cache.set(providerId, {
|
||||
models: response.data.models,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
return { models: response.data.models }
|
||||
} else {
|
||||
return { models: [], error: response.data?.error || '获取上游模型失败' }
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { models: [], error: err.response?.data?.detail || '获取上游模型失败' }
|
||||
} finally {
|
||||
loadingMap.value.set(providerId, false)
|
||||
pendingRequests.delete(providerId)
|
||||
}
|
||||
})()
|
||||
|
||||
pendingRequests.set(providerId, requestPromise)
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的模型(不发起请求)
|
||||
*/
|
||||
function getCachedModels(providerId: string): UpstreamModel[] | null {
|
||||
const cached = cache.get(providerId)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||
return cached.models
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定提供商的缓存
|
||||
*/
|
||||
function clearCache(providerId: string) {
|
||||
cache.delete(providerId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否正在加载
|
||||
*/
|
||||
function isLoading(providerId: string): boolean {
|
||||
return loadingMap.value.get(providerId) || false
|
||||
}
|
||||
|
||||
return {
|
||||
fetchModels,
|
||||
getCachedModels,
|
||||
clearCache,
|
||||
isLoading,
|
||||
loadingMap
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
<span>{{ detail?.model || '-' }}</span>
|
||||
<template v-if="detail?.target_model">
|
||||
<template v-if="detail?.target_model && detail.target_model !== detail.model">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
@@ -472,6 +472,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Separator from '@/components/ui/separator.vue'
|
||||
@@ -897,6 +898,16 @@ const providerHeadersWithDiff = computed(() => {
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.isOpen) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -136,11 +136,20 @@
|
||||
<!-- 分隔线 -->
|
||||
<div class="hidden sm:block h-4 w-px bg-border" />
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="$emit('refresh')"
|
||||
/>
|
||||
<!-- 自动刷新按钮 -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
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>
|
||||
|
||||
<Table>
|
||||
@@ -408,6 +417,7 @@ import { ref, computed, onUnmounted, watch } from 'vue'
|
||||
import {
|
||||
TableCard,
|
||||
Badge,
|
||||
Button,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
@@ -420,8 +430,8 @@ import {
|
||||
TableHead,
|
||||
TableCell,
|
||||
Pagination,
|
||||
RefreshButton,
|
||||
} from '@/components/ui'
|
||||
import { RefreshCcw } from 'lucide-vue-next'
|
||||
import { formatTokens, formatCurrency } from '@/utils/format'
|
||||
import { formatDateTime } from '../composables'
|
||||
import { useRowClick } from '@/composables/useRowClick'
|
||||
@@ -453,6 +463,8 @@ const props = defineProps<{
|
||||
pageSize: number
|
||||
totalRecords: number
|
||||
pageSizeOptions: number[]
|
||||
// 自动刷新
|
||||
autoRefresh: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -463,6 +475,7 @@ const emit = defineEmits<{
|
||||
'update:filterStatus': [value: string]
|
||||
'update:currentPage': [value: number]
|
||||
'update:pageSize': [value: number]
|
||||
'update:autoRefresh': [value: boolean]
|
||||
'refresh': []
|
||||
'showDetail': [id: string]
|
||||
}>()
|
||||
|
||||
@@ -403,7 +403,7 @@ function getUsageRecords() {
|
||||
return cachedUsageRecords
|
||||
}
|
||||
|
||||
// Mock 别名数据
|
||||
// Mock 映射数据
|
||||
const MOCK_ALIASES = [
|
||||
{ id: 'alias-001', source_model: 'claude-4-sonnet', target_global_model_id: 'gm-001', target_global_model_name: 'claude-sonnet-4-20250514', target_global_model_display_name: 'Claude Sonnet 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'alias-002', source_model: 'claude-4-opus', target_global_model_id: 'gm-002', target_global_model_name: 'claude-opus-4-20250514', target_global_model_display_name: 'Claude Opus 4', provider_id: null, provider_name: null, scope: 'global', mapping_type: 'alias', is_active: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
||||
@@ -1682,7 +1682,7 @@ registerDynamicRoute('GET', '/api/admin/models/mappings/:mappingId', async (_con
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse(alias)
|
||||
})
|
||||
@@ -1693,7 +1693,7 @@ registerDynamicRoute('PATCH', '/api/admin/models/mappings/:mappingId', async (co
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
const body = JSON.parse(config.data || '{}')
|
||||
return createMockResponse({ ...alias, ...body, updated_at: new Date().toISOString() })
|
||||
@@ -1705,7 +1705,7 @@ registerDynamicRoute('DELETE', '/api/admin/models/mappings/:mappingId', async (_
|
||||
requireAdmin()
|
||||
const alias = MOCK_ALIASES.find(a => a.id === params.mappingId)
|
||||
if (!alias) {
|
||||
throw { response: createMockResponse({ detail: '别名不存在' }, 404) }
|
||||
throw { response: createMockResponse({ detail: '映射不存在' }, 404) }
|
||||
}
|
||||
return createMockResponse({ message: '删除成功(演示模式)' })
|
||||
})
|
||||
|
||||
@@ -142,32 +142,37 @@ async function resetAffinitySearch() {
|
||||
await fetchAffinityList()
|
||||
}
|
||||
|
||||
async function clearUserCache(identifier: string, displayName?: string) {
|
||||
const target = identifier?.trim()
|
||||
if (!target) {
|
||||
showError('无法识别标识符')
|
||||
async function clearSingleAffinity(item: UserAffinity) {
|
||||
const affinityKey = item.affinity_key?.trim()
|
||||
const endpointId = item.endpoint_id?.trim()
|
||||
const modelId = item.global_model_id?.trim()
|
||||
const apiFormat = item.api_format?.trim()
|
||||
|
||||
if (!affinityKey || !endpointId || !modelId || !apiFormat) {
|
||||
showError('缓存记录信息不完整,无法删除')
|
||||
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({
|
||||
title: '确认清除',
|
||||
message: `确定要清除 ${label} 的缓存吗?`,
|
||||
message: `确定要清除 ${label} 在模型 ${modelLabel} 上的缓存亲和性吗?`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'destructive'
|
||||
})
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
clearingRowAffinityKey.value = target
|
||||
clearingRowAffinityKey.value = affinityKey
|
||||
try {
|
||||
await cacheApi.clearUserCache(target)
|
||||
await cacheApi.clearSingleAffinity(affinityKey, endpointId, modelId, apiFormat)
|
||||
showSuccess('清除成功')
|
||||
await fetchCacheStats()
|
||||
await fetchAffinityList(tableKeyword.value.trim() || undefined)
|
||||
} catch (error) {
|
||||
showError('清除失败')
|
||||
log.error('清除用户缓存失败', error)
|
||||
log.error('清除单条缓存失败', error)
|
||||
} finally {
|
||||
clearingRowAffinityKey.value = null
|
||||
}
|
||||
@@ -618,7 +623,7 @@ onBeforeUnmount(() => {
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive"
|
||||
:disabled="clearingRowAffinityKey === item.affinity_key"
|
||||
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" />
|
||||
</Button>
|
||||
@@ -668,7 +673,7 @@ onBeforeUnmount(() => {
|
||||
variant="ghost"
|
||||
class="h-7 w-7 text-muted-foreground/70 hover:text-destructive shrink-0"
|
||||
: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" />
|
||||
</Button>
|
||||
|
||||
@@ -185,32 +185,13 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- API Key 管理配置 -->
|
||||
<!-- 独立余额 Key 过期管理 -->
|
||||
<CardSection
|
||||
title="API Key 管理"
|
||||
description="API Key 相关配置"
|
||||
title="独立余额 Key 过期管理"
|
||||
description="独立余额 Key 的过期处理策略(普通用户 Key 不会过期)"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="api-key-expire"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
API密钥过期天数
|
||||
</Label>
|
||||
<Input
|
||||
id="api-key-expire"
|
||||
v-model.number="systemConfig.api_key_expire_days"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
0 表示永不过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full pt-6">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-delete-expired-keys"
|
||||
@@ -224,7 +205,7 @@
|
||||
自动删除过期 Key
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
关闭时仅禁用过期 Key
|
||||
关闭时仅禁用过期 Key,不会物理删除
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,6 +429,25 @@
|
||||
避免单次操作过大影响性能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="audit-log-retention-days"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
审计日志保留天数
|
||||
</Label>
|
||||
<Input
|
||||
id="audit-log-retention-days"
|
||||
v-model.number="systemConfig.audit_log_retention_days"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
超过后删除审计日志记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 清理策略说明 -->
|
||||
@@ -460,9 +460,11 @@
|
||||
<p>2. <strong>压缩日志阶段</strong>: body 字段被压缩存储,节省空间</p>
|
||||
<p>3. <strong>统计阶段</strong>: 仅保留 tokens、成本等统计信息</p>
|
||||
<p>4. <strong>归档删除</strong>: 超过保留期限后完全删除记录</p>
|
||||
<p>5. <strong>审计日志</strong>: 独立清理,记录用户登录、操作等安全事件</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 导入配置对话框 -->
|
||||
@@ -796,8 +798,7 @@ interface SystemConfig {
|
||||
// 用户注册
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
// API Key 管理
|
||||
api_key_expire_days: number
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 日志记录
|
||||
request_log_level: string
|
||||
@@ -811,6 +812,7 @@ interface SystemConfig {
|
||||
header_retention_days: number
|
||||
log_retention_days: number
|
||||
cleanup_batch_size: number
|
||||
audit_log_retention_days: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -845,8 +847,7 @@ const systemConfig = ref<SystemConfig>({
|
||||
// 用户注册
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
// API Key 管理
|
||||
api_key_expire_days: 0,
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 日志记录
|
||||
request_log_level: 'basic',
|
||||
@@ -860,6 +861,7 @@ const systemConfig = ref<SystemConfig>({
|
||||
header_retention_days: 90,
|
||||
log_retention_days: 365,
|
||||
cleanup_batch_size: 1000,
|
||||
audit_log_retention_days: 30,
|
||||
})
|
||||
|
||||
// 计算属性:KB 和 字节 之间的转换
|
||||
@@ -901,8 +903,7 @@ async function loadSystemConfig() {
|
||||
// 用户注册
|
||||
'enable_registration',
|
||||
'require_email_verification',
|
||||
// API Key 管理
|
||||
'api_key_expire_days',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 日志记录
|
||||
'request_log_level',
|
||||
@@ -916,6 +917,7 @@ async function loadSystemConfig() {
|
||||
'header_retention_days',
|
||||
'log_retention_days',
|
||||
'cleanup_batch_size',
|
||||
'audit_log_retention_days',
|
||||
]
|
||||
|
||||
for (const key of configs) {
|
||||
@@ -960,12 +962,7 @@ async function saveSystemConfig() {
|
||||
value: systemConfig.value.require_email_verification,
|
||||
description: '是否需要邮箱验证'
|
||||
},
|
||||
// API Key 管理
|
||||
{
|
||||
key: 'api_key_expire_days',
|
||||
value: systemConfig.value.api_key_expire_days,
|
||||
description: 'API密钥过期天数'
|
||||
},
|
||||
// 独立余额 Key 过期管理
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
value: systemConfig.value.auto_delete_expired_keys,
|
||||
@@ -1023,6 +1020,11 @@ async function saveSystemConfig() {
|
||||
value: systemConfig.value.cleanup_batch_size,
|
||||
description: '每批次清理的记录数'
|
||||
},
|
||||
{
|
||||
key: 'audit_log_retention_days',
|
||||
value: systemConfig.value.audit_log_retention_days,
|
||||
description: '审计日志保留天数'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
:page-size="pageSize"
|
||||
:total-records="totalRecords"
|
||||
:page-size-options="pageSizeOptions"
|
||||
:auto-refresh="globalAutoRefresh"
|
||||
@update:selected-period="handlePeriodChange"
|
||||
@update:filter-user="handleFilterUserChange"
|
||||
@update:filter-model="handleFilterModelChange"
|
||||
@@ -72,6 +73,7 @@
|
||||
@update:filter-status="handleFilterStatusChange"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:page-size="handlePageSizeChange"
|
||||
@update:auto-refresh="handleAutoRefreshChange"
|
||||
@refresh="refreshData"
|
||||
@export="exportData"
|
||||
@show-detail="showRequestDetail"
|
||||
@@ -214,7 +216,10 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
|
||||
|
||||
// 自动刷新定时器
|
||||
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() {
|
||||
@@ -278,9 +283,34 @@ watch(hasActiveRequests, (hasActive) => {
|
||||
}
|
||||
}, { 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(() => {
|
||||
stopAutoRefresh()
|
||||
stopGlobalAutoRefresh()
|
||||
})
|
||||
|
||||
// 用户页面的前端分页
|
||||
|
||||
@@ -350,6 +350,7 @@ import {
|
||||
Layers,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-vue-next'
|
||||
import { useEscapeKey } from '@/composables/useEscapeKey'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
@@ -453,6 +454,16 @@ function getFirst1hCachePrice(tieredPricing: TieredPricingConfig | undefined | n
|
||||
if (!tieredPricing?.tiers?.length) return '-'
|
||||
return get1hCachePrice(tieredPricing.tiers[0])
|
||||
}
|
||||
|
||||
// 添加 ESC 键监听
|
||||
useEscapeKey(() => {
|
||||
if (props.open) {
|
||||
handleClose()
|
||||
}
|
||||
}, {
|
||||
disableOnInput: true,
|
||||
once: false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
A proxy server that enables AI models to work with multiple API providers.
|
||||
"""
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
# 注意: dotenv 加载已统一移至 src/config/settings.py
|
||||
# 不要在此处重复加载
|
||||
|
||||
try:
|
||||
from src._version import __version__
|
||||
|
||||
@@ -223,7 +223,7 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||
allowed_providers=self.key_data.allowed_providers,
|
||||
allowed_api_formats=self.key_data.allowed_api_formats,
|
||||
allowed_models=self.key_data.allowed_models,
|
||||
rate_limit=self.key_data.rate_limit or 100,
|
||||
rate_limit=self.key_data.rate_limit, # None 表示不限制
|
||||
expire_days=self.key_data.expire_days,
|
||||
initial_balance_usd=self.key_data.initial_balance_usd,
|
||||
is_standalone=True, # 标记为独立Key
|
||||
|
||||
@@ -5,7 +5,7 @@ ProviderEndpoint CRUD 管理 API
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import and_, func
|
||||
@@ -27,6 +27,16 @@ router = APIRouter(tags=["Endpoint Management"])
|
||||
pipeline = ApiRequestPipeline()
|
||||
|
||||
|
||||
def mask_proxy_password(proxy_config: Optional[dict]) -> Optional[dict]:
|
||||
"""对代理配置中的密码进行脱敏处理"""
|
||||
if not proxy_config:
|
||||
return None
|
||||
masked = dict(proxy_config)
|
||||
if masked.get("password"):
|
||||
masked["password"] = "***"
|
||||
return masked
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/endpoints", response_model=List[ProviderEndpointResponse])
|
||||
async def list_provider_endpoints(
|
||||
provider_id: str,
|
||||
@@ -153,6 +163,7 @@ class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
||||
"api_format": endpoint.api_format,
|
||||
"total_keys": total_keys_map.get(endpoint.id, 0),
|
||||
"active_keys": active_keys_map.get(endpoint.id, 0),
|
||||
"proxy": mask_proxy_password(endpoint.proxy),
|
||||
}
|
||||
endpoint_dict.pop("_sa_instance_state", None)
|
||||
result.append(ProviderEndpointResponse(**endpoint_dict))
|
||||
@@ -202,6 +213,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
rate_limit=self.endpoint_data.rate_limit,
|
||||
is_active=True,
|
||||
config=self.endpoint_data.config,
|
||||
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -215,12 +227,13 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in new_endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=new_endpoint.api_format,
|
||||
proxy=mask_proxy_password(new_endpoint.proxy),
|
||||
total_keys=0,
|
||||
active_keys=0,
|
||||
)
|
||||
@@ -259,12 +272,13 @@ class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint_obj.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name,
|
||||
api_format=endpoint_obj.api_format,
|
||||
proxy=mask_proxy_password(endpoint_obj.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
@@ -284,6 +298,17 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||
|
||||
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||
# 把 proxy 转换为 dict 存储,支持显式设置为 None 清除代理
|
||||
if "proxy" in update_data:
|
||||
if update_data["proxy"] is not None:
|
||||
new_proxy = dict(update_data["proxy"])
|
||||
# 只有当密码字段未提供时才保留原密码(空字符串视为显式清除)
|
||||
if "password" not in new_proxy and endpoint.proxy:
|
||||
old_password = endpoint.proxy.get("password")
|
||||
if old_password:
|
||||
new_proxy["password"] = old_password
|
||||
update_data["proxy"] = new_proxy
|
||||
# proxy 为 None 时保留,用于清除代理配置
|
||||
for field, value in update_data.items():
|
||||
setattr(endpoint, field, value)
|
||||
endpoint.updated_at = datetime.now(timezone.utc)
|
||||
@@ -311,12 +336,13 @@ class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||
endpoint_dict = {
|
||||
k: v
|
||||
for k, v in endpoint.__dict__.items()
|
||||
if k not in {"api_format", "_sa_instance_state"}
|
||||
if k not in {"api_format", "_sa_instance_state", "proxy"}
|
||||
}
|
||||
return ProviderEndpointResponse(
|
||||
**endpoint_dict,
|
||||
provider_name=provider.name if provider else "Unknown",
|
||||
api_format=endpoint.api_format,
|
||||
proxy=mask_proxy_password(endpoint.proxy),
|
||||
total_keys=total_keys,
|
||||
active_keys=active_keys,
|
||||
)
|
||||
|
||||
@@ -186,6 +186,30 @@ async def clear_user_cache(
|
||||
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("")
|
||||
async def clear_all_cache(
|
||||
request: Request,
|
||||
@@ -655,6 +679,7 @@ class AdminListAffinitiesAdapter(AdminApiAdapter):
|
||||
"key_name": key.name if key else None,
|
||||
"key_prefix": provider_key_masked,
|
||||
"rate_multiplier": key.rate_multiplier if key else 1.0,
|
||||
"global_model_id": affinity.get("model_name"), # 原始的 global_model_id
|
||||
"model_name": (
|
||||
global_model_map.get(affinity.get("model_name")).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}")
|
||||
|
||||
|
||||
@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):
|
||||
async def handle(self, context: ApiRequestContext) -> Dict[str, Any]: # type: ignore[override]
|
||||
try:
|
||||
@@ -1083,14 +1167,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
||||
provider.display_name or provider.name
|
||||
)
|
||||
continue
|
||||
# 检查是否在别名列表中
|
||||
if model.provider_model_aliases:
|
||||
alias_names = [
|
||||
# 检查是否在映射列表中
|
||||
if model.provider_model_mappings:
|
||||
mapping_list = [
|
||||
a.get("name")
|
||||
for a in model.provider_model_aliases
|
||||
for a in model.provider_model_mappings
|
||||
if isinstance(a, dict)
|
||||
]
|
||||
if mapping_name in alias_names:
|
||||
if mapping_name in mapping_list:
|
||||
provider_names.append(
|
||||
provider.display_name or provider.name
|
||||
)
|
||||
@@ -1152,19 +1236,19 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
||||
try:
|
||||
cached_data = json.loads(cached_str)
|
||||
provider_model_name = cached_data.get("provider_model_name")
|
||||
provider_model_aliases = cached_data.get("provider_model_aliases", [])
|
||||
provider_model_mappings = cached_data.get("provider_model_mappings", [])
|
||||
|
||||
# 获取 Provider 和 GlobalModel 信息
|
||||
provider = provider_map.get(provider_id)
|
||||
global_model = global_model_map.get(global_model_id)
|
||||
|
||||
if provider and global_model:
|
||||
# 提取别名名称
|
||||
alias_names = []
|
||||
if provider_model_aliases:
|
||||
for alias_entry in provider_model_aliases:
|
||||
if isinstance(alias_entry, dict) and alias_entry.get("name"):
|
||||
alias_names.append(alias_entry["name"])
|
||||
# 提取映射名称
|
||||
mapping_names = []
|
||||
if provider_model_mappings:
|
||||
for mapping_entry in provider_model_mappings:
|
||||
if isinstance(mapping_entry, dict) and mapping_entry.get("name"):
|
||||
mapping_names.append(mapping_entry["name"])
|
||||
|
||||
# provider_model_name 为空时跳过
|
||||
if not provider_model_name:
|
||||
@@ -1172,14 +1256,14 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
||||
|
||||
# 只显示有实际映射的条目:
|
||||
# 1. 全局模型名 != Provider 模型名(模型名称映射)
|
||||
# 2. 或者有别名配置
|
||||
# 2. 或者有映射配置
|
||||
has_name_mapping = global_model.name != provider_model_name
|
||||
has_aliases = len(alias_names) > 0
|
||||
has_mappings = len(mapping_names) > 0
|
||||
|
||||
if has_name_mapping or has_aliases:
|
||||
# 构建用于展示的别名列表
|
||||
# 如果只有名称映射没有别名,则用 global_model_name 作为"请求名称"
|
||||
display_aliases = alias_names if alias_names else [global_model.name]
|
||||
if has_name_mapping or has_mappings:
|
||||
# 构建用于展示的映射列表
|
||||
# 如果只有名称映射没有额外映射,则用 global_model_name 作为"请求名称"
|
||||
display_mappings = mapping_names if mapping_names else [global_model.name]
|
||||
|
||||
provider_model_mappings.append({
|
||||
"provider_id": provider_id,
|
||||
@@ -1188,7 +1272,7 @@ class AdminModelMappingCacheStatsAdapter(AdminApiAdapter):
|
||||
"global_model_name": global_model.name,
|
||||
"global_model_display_name": global_model.display_name,
|
||||
"provider_model_name": provider_model_name,
|
||||
"aliases": display_aliases,
|
||||
"aliases": display_mappings,
|
||||
"ttl": ttl if ttl > 0 else None,
|
||||
"hit_count": hit_count,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.api.base.admin_adapter import AdminApiAdapter
|
||||
@@ -52,8 +52,7 @@ class CandidateResponse(BaseModel):
|
||||
started_at: Optional[datetime] = None
|
||||
finished_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RequestTraceResponse(BaseModel):
|
||||
|
||||
@@ -11,6 +11,8 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from src.api.handlers.base.chat_adapter_base import get_adapter_class
|
||||
from src.api.handlers.base.cli_adapter_base import get_cli_adapter_class
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.database.database import get_db
|
||||
@@ -33,142 +35,19 @@ class ModelsQueryRequest(BaseModel):
|
||||
# ============ API Endpoints ============
|
||||
|
||||
|
||||
async def _fetch_openai_models(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
api_format: str,
|
||||
extra_headers: Optional[dict] = None,
|
||||
) -> tuple[list, Optional[str]]:
|
||||
"""获取 OpenAI 格式的模型列表
|
||||
def _get_adapter_for_format(api_format: str):
|
||||
"""根据 API 格式获取对应的 Adapter 类"""
|
||||
# 先检查 Chat Adapter 注册表
|
||||
adapter_class = get_adapter_class(api_format)
|
||||
if adapter_class:
|
||||
return adapter_class
|
||||
|
||||
Returns:
|
||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
if extra_headers:
|
||||
# 防止 extra_headers 覆盖 Authorization
|
||||
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
|
||||
headers.update(safe_headers)
|
||||
# 再检查 CLI Adapter 注册表
|
||||
cli_adapter_class = get_cli_adapter_class(api_format)
|
||||
if cli_adapter_class:
|
||||
return cli_adapter_class
|
||||
|
||||
# 构建 /v1/models URL
|
||||
if base_url.endswith("/v1"):
|
||||
models_url = f"{base_url}/models"
|
||||
else:
|
||||
models_url = f"{base_url}/v1/models"
|
||||
|
||||
try:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
logger.debug(f"OpenAI models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = []
|
||||
if "data" in data:
|
||||
models = data["data"]
|
||||
elif isinstance(data, list):
|
||||
models = data
|
||||
# 为每个模型添加 api_format 字段
|
||||
for m in models:
|
||||
m["api_format"] = api_format
|
||||
return models, None
|
||||
else:
|
||||
# 记录详细的错误信息
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"OpenAI models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
|
||||
async def _fetch_claude_models(
|
||||
client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
|
||||
) -> tuple[list, Optional[str]]:
|
||||
"""获取 Claude 格式的模型列表
|
||||
|
||||
Returns:
|
||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
||||
"""
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
# 构建 /v1/models URL
|
||||
if base_url.endswith("/v1"):
|
||||
models_url = f"{base_url}/models"
|
||||
else:
|
||||
models_url = f"{base_url}/v1/models"
|
||||
|
||||
try:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
logger.debug(f"Claude models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = []
|
||||
if "data" in data:
|
||||
models = data["data"]
|
||||
elif isinstance(data, list):
|
||||
models = data
|
||||
# 为每个模型添加 api_format 字段
|
||||
for m in models:
|
||||
m["api_format"] = api_format
|
||||
return models, None
|
||||
else:
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
|
||||
async def _fetch_gemini_models(
|
||||
client: httpx.AsyncClient, base_url: str, api_key: str, api_format: str
|
||||
) -> tuple[list, Optional[str]]:
|
||||
"""获取 Gemini 格式的模型列表
|
||||
|
||||
Returns:
|
||||
tuple[list, Optional[str]]: (模型列表, 错误信息)
|
||||
"""
|
||||
# 兼容 base_url 已包含 /v1beta 的情况
|
||||
base_url_clean = base_url.rstrip("/")
|
||||
if base_url_clean.endswith("/v1beta"):
|
||||
models_url = f"{base_url_clean}/models?key={api_key}"
|
||||
else:
|
||||
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
|
||||
|
||||
try:
|
||||
response = await client.get(models_url)
|
||||
logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "models" in data:
|
||||
# 转换为统一格式
|
||||
return [
|
||||
{
|
||||
"id": m.get("name", "").replace("models/", ""),
|
||||
"owned_by": "google",
|
||||
"display_name": m.get("displayName", ""),
|
||||
"api_format": api_format,
|
||||
}
|
||||
for m in data["models"]
|
||||
], None
|
||||
return [], None
|
||||
else:
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"Gemini models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch Gemini models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/models")
|
||||
@@ -180,10 +59,10 @@ async def query_available_models(
|
||||
"""
|
||||
查询提供商可用模型
|
||||
|
||||
遍历所有活跃端点,根据端点的 API 格式选择正确的请求方式:
|
||||
- OPENAI/OPENAI_CLI: /v1/models (Bearer token)
|
||||
- CLAUDE/CLAUDE_CLI: /v1/models (x-api-key)
|
||||
- GEMINI/GEMINI_CLI: /v1beta/models (URL key parameter)
|
||||
遍历所有活跃端点,根据端点的 API 格式选择正确的 Adapter 进行请求:
|
||||
- OPENAI/OPENAI_CLI: 使用 OpenAIChatAdapter.fetch_models
|
||||
- CLAUDE/CLAUDE_CLI: 使用 ClaudeChatAdapter.fetch_models
|
||||
- GEMINI/GEMINI_CLI: 使用 GeminiChatAdapter.fetch_models
|
||||
|
||||
Args:
|
||||
request: 查询请求
|
||||
@@ -265,37 +144,53 @@ async def query_available_models(
|
||||
base_url = base_url.rstrip("/")
|
||||
api_format = config["api_format"]
|
||||
api_key_value = config["api_key"]
|
||||
extra_headers = config["extra_headers"]
|
||||
extra_headers = config.get("extra_headers")
|
||||
|
||||
try:
|
||||
if api_format in ["CLAUDE", "CLAUDE_CLI"]:
|
||||
return await _fetch_claude_models(client, base_url, api_key_value, api_format)
|
||||
elif api_format in ["GEMINI", "GEMINI_CLI"]:
|
||||
return await _fetch_gemini_models(client, base_url, api_key_value, api_format)
|
||||
else:
|
||||
return await _fetch_openai_models(
|
||||
client, base_url, api_key_value, api_format, extra_headers
|
||||
)
|
||||
# 获取对应的 Adapter 类并调用 fetch_models
|
||||
adapter_class = _get_adapter_for_format(api_format)
|
||||
if not adapter_class:
|
||||
return [], f"Unknown API format: {api_format}"
|
||||
models, error = await adapter_class.fetch_models(
|
||||
client, base_url, api_key_value, extra_headers
|
||||
)
|
||||
# 确保所有模型都有 api_format 字段
|
||||
for m in models:
|
||||
if "api_format" not in m:
|
||||
m["api_format"] = api_format
|
||||
return models, error
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching models from {api_format} endpoint: {e}")
|
||||
return [], f"{api_format}: {str(e)}"
|
||||
|
||||
# 限制并发请求数量,避免触发上游速率限制
|
||||
MAX_CONCURRENT_REQUESTS = 5
|
||||
semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
||||
|
||||
async def fetch_with_semaphore(
|
||||
client: httpx.AsyncClient, config: dict
|
||||
) -> tuple[list, Optional[str]]:
|
||||
async with semaphore:
|
||||
return await fetch_endpoint_models(client, config)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
results = await asyncio.gather(
|
||||
*[fetch_endpoint_models(client, c) for c in endpoint_configs]
|
||||
*[fetch_with_semaphore(client, c) for c in endpoint_configs]
|
||||
)
|
||||
for models, error in results:
|
||||
all_models.extend(models)
|
||||
if error:
|
||||
errors.append(error)
|
||||
|
||||
# 按 model id 去重(保留第一个)
|
||||
seen_ids: set[str] = set()
|
||||
# 按 model id + api_format 去重(保留第一个)
|
||||
seen_keys: set[str] = set()
|
||||
unique_models: list = []
|
||||
for model in all_models:
|
||||
model_id = model.get("id")
|
||||
if model_id and model_id not in seen_ids:
|
||||
seen_ids.add(model_id)
|
||||
api_format = model.get("api_format", "")
|
||||
unique_key = f"{model_id}:{api_format}"
|
||||
if model_id and unique_key not in seen_keys:
|
||||
seen_keys.add(unique_key)
|
||||
unique_models.append(model)
|
||||
|
||||
error = "; ".join(errors) if errors else None
|
||||
|
||||
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
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.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
@@ -21,16 +22,18 @@ from src.models.api import (
|
||||
from src.models.pydantic_models import (
|
||||
BatchAssignModelsToProviderRequest,
|
||||
BatchAssignModelsToProviderResponse,
|
||||
ImportFromUpstreamRequest,
|
||||
ImportFromUpstreamResponse,
|
||||
ImportFromUpstreamSuccessItem,
|
||||
ImportFromUpstreamErrorItem,
|
||||
ProviderAvailableSourceModel,
|
||||
ProviderAvailableSourceModelsResponse,
|
||||
)
|
||||
from src.models.database import (
|
||||
GlobalModel,
|
||||
Model,
|
||||
Provider,
|
||||
)
|
||||
from src.models.pydantic_models import (
|
||||
ProviderAvailableSourceModel,
|
||||
ProviderAvailableSourceModelsResponse,
|
||||
)
|
||||
from src.services.model.service import ModelService
|
||||
|
||||
router = APIRouter(tags=["Model Management"])
|
||||
@@ -157,6 +160,28 @@ async def batch_assign_global_models_to_provider(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{provider_id}/import-from-upstream",
|
||||
response_model=ImportFromUpstreamResponse,
|
||||
)
|
||||
async def import_models_from_upstream(
|
||||
provider_id: str,
|
||||
payload: ImportFromUpstreamRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ImportFromUpstreamResponse:
|
||||
"""
|
||||
从上游提供商导入模型
|
||||
|
||||
流程:
|
||||
1. 根据 model_ids 检查全局模型是否存在(按 name 匹配)
|
||||
2. 如不存在,自动创建新的 GlobalModel(使用默认配置)
|
||||
3. 创建 Model 关联到当前 Provider
|
||||
"""
|
||||
adapter = AdminImportFromUpstreamAdapter(provider_id=provider_id, payload=payload)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- Adapters --------
|
||||
|
||||
|
||||
@@ -419,4 +444,135 @@ class AdminBatchAssignModelsToProviderAdapter(AdminApiAdapter):
|
||||
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)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminImportFromUpstreamAdapter(AdminApiAdapter):
|
||||
"""从上游提供商导入模型"""
|
||||
|
||||
provider_id: str
|
||||
payload: ImportFromUpstreamRequest
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider not found", "provider")
|
||||
|
||||
success: list[ImportFromUpstreamSuccessItem] = []
|
||||
errors: list[ImportFromUpstreamErrorItem] = []
|
||||
|
||||
# 默认阶梯计费配置(免费)
|
||||
default_tiered_pricing = {
|
||||
"tiers": [
|
||||
{
|
||||
"up_to": None,
|
||||
"input_price_per_1m": 0.0,
|
||||
"output_price_per_1m": 0.0,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
for model_id in self.payload.model_ids:
|
||||
# 输入验证:检查 model_id 长度
|
||||
if not model_id or len(model_id) > 100:
|
||||
errors.append(
|
||||
ImportFromUpstreamErrorItem(
|
||||
model_id=model_id[:50] + "..." if model_id and len(model_id) > 50 else model_id or "<empty>",
|
||||
error="Invalid model_id: must be 1-100 characters",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
# 使用 savepoint 确保单个模型导入的原子性
|
||||
savepoint = db.begin_nested()
|
||||
try:
|
||||
# 1. 检查是否已存在同名的 GlobalModel
|
||||
global_model = (
|
||||
db.query(GlobalModel).filter(GlobalModel.name == model_id).first()
|
||||
)
|
||||
created_global_model = False
|
||||
|
||||
if not global_model:
|
||||
# 2. 创建新的 GlobalModel
|
||||
global_model = GlobalModel(
|
||||
name=model_id,
|
||||
display_name=model_id,
|
||||
default_tiered_pricing=default_tiered_pricing,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(global_model)
|
||||
db.flush()
|
||||
created_global_model = True
|
||||
logger.info(
|
||||
f"Created new GlobalModel: {model_id} during upstream import"
|
||||
)
|
||||
|
||||
# 3. 检查是否已存在关联
|
||||
existing = (
|
||||
db.query(Model)
|
||||
.filter(
|
||||
Model.provider_id == self.provider_id,
|
||||
Model.global_model_id == global_model.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
# 已存在关联,提交 savepoint 并记录成功
|
||||
savepoint.commit()
|
||||
success.append(
|
||||
ImportFromUpstreamSuccessItem(
|
||||
model_id=model_id,
|
||||
global_model_id=global_model.id,
|
||||
global_model_name=global_model.name,
|
||||
provider_model_id=existing.id,
|
||||
created_global_model=created_global_model,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# 4. 创建新的 Model 记录
|
||||
new_model = Model(
|
||||
provider_id=self.provider_id,
|
||||
global_model_id=global_model.id,
|
||||
provider_model_name=global_model.name,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_model)
|
||||
db.flush()
|
||||
|
||||
# 提交 savepoint
|
||||
savepoint.commit()
|
||||
success.append(
|
||||
ImportFromUpstreamSuccessItem(
|
||||
model_id=model_id,
|
||||
global_model_id=global_model.id,
|
||||
global_model_name=global_model.name,
|
||||
provider_model_id=new_model.id,
|
||||
created_global_model=created_global_model,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# 回滚到 savepoint
|
||||
savepoint.rollback()
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing model {model_id}: {e}")
|
||||
errors.append(ImportFromUpstreamErrorItem(model_id=model_id, error=str(e)))
|
||||
|
||||
db.commit()
|
||||
logger.info(
|
||||
f"Imported {len(success)} models from upstream to provider {provider.name} by {context.user.username}"
|
||||
)
|
||||
|
||||
# 清除 /v1/models 列表缓存
|
||||
if success:
|
||||
await invalidate_models_list_cache()
|
||||
|
||||
return ImportFromUpstreamResponse(success=success, errors=errors)
|
||||
|
||||
@@ -436,7 +436,7 @@ class AdminExportConfigAdapter(AdminApiAdapter):
|
||||
{
|
||||
"global_model_name": global_model.name if global_model else None,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"provider_model_aliases": model.provider_model_aliases,
|
||||
"provider_model_mappings": model.provider_model_mappings,
|
||||
"price_per_request": model.price_per_request,
|
||||
"tiered_pricing": model.tiered_pricing,
|
||||
"supports_vision": model.supports_vision,
|
||||
@@ -790,8 +790,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
)
|
||||
elif merge_mode == "overwrite":
|
||||
existing_model.global_model_id = global_model_id
|
||||
existing_model.provider_model_aliases = model_data.get(
|
||||
"provider_model_aliases"
|
||||
existing_model.provider_model_mappings = model_data.get(
|
||||
"provider_model_mappings"
|
||||
)
|
||||
existing_model.price_per_request = model_data.get(
|
||||
"price_per_request"
|
||||
@@ -824,8 +824,8 @@ class AdminImportConfigAdapter(AdminApiAdapter):
|
||||
provider_id=provider_id,
|
||||
global_model_id=global_model_id,
|
||||
provider_model_name=model_data["provider_model_name"],
|
||||
provider_model_aliases=model_data.get(
|
||||
"provider_model_aliases"
|
||||
provider_model_mappings=model_data.get(
|
||||
"provider_model_mappings"
|
||||
),
|
||||
price_per_request=model_data.get("price_per_request"),
|
||||
tiered_pricing=model_data.get("tiered_pricing"),
|
||||
|
||||
@@ -140,9 +140,9 @@ class AnnouncementOptionalAuthAdapter(ApiAdapter):
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
return None
|
||||
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
token = authorization[7:].strip()
|
||||
try:
|
||||
payload = await AuthService.verify_token(token)
|
||||
payload = await AuthService.verify_token(token, token_type="access")
|
||||
user_id = payload.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
@@ -211,7 +211,7 @@ class AuthRefreshAdapter(AuthPublicAdapter):
|
||||
|
||||
class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
from ..models.database import SystemConfig
|
||||
from src.models.database import SystemConfig
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
@@ -55,6 +55,23 @@ async def _set_cached_models(api_formats: list[str], models: list["ModelInfo"])
|
||||
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
|
||||
class ModelInfo:
|
||||
"""统一的模型信息结构"""
|
||||
|
||||
@@ -5,13 +5,12 @@ from enum import Enum
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.exceptions import QuotaExceededException
|
||||
from src.core.logger import logger
|
||||
from src.models.database import ApiKey, AuditEventType, User, UserRole
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.cache.user_cache import UserCacheService
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.usage.service import UsageService
|
||||
|
||||
@@ -178,9 +177,9 @@ class ApiRequestPipeline:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少管理员凭证")
|
||||
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
token = authorization[7:].strip()
|
||||
try:
|
||||
payload = await self.auth_service.verify_token(token)
|
||||
payload = await self.auth_service.verify_token(token, token_type="access")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -191,8 +190,8 @@ class ApiRequestPipeline:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的管理员令牌")
|
||||
|
||||
# 使用缓存查询用户
|
||||
user = await UserCacheService.get_user_by_id(db, user_id)
|
||||
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
|
||||
|
||||
@@ -205,9 +204,9 @@ class ApiRequestPipeline:
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="缺少用户凭证")
|
||||
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
token = authorization[7:].strip()
|
||||
try:
|
||||
payload = await self.auth_service.verify_token(token)
|
||||
payload = await self.auth_service.verify_token(token, token_type="access")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -218,8 +217,8 @@ class ApiRequestPipeline:
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的用户令牌")
|
||||
|
||||
# 使用缓存查询用户
|
||||
user = await UserCacheService.get_user_by_id(db, user_id)
|
||||
# 直接查询数据库,确保返回的是当前 Session 绑定的对象
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户不存在或已禁用")
|
||||
|
||||
@@ -242,11 +241,15 @@ class ApiRequestPipeline:
|
||||
status_code: Optional[int] = None,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录审计事件
|
||||
|
||||
事务策略:复用请求级 Session,不单独提交。
|
||||
审计记录随主事务一起提交,由中间件统一管理。
|
||||
"""
|
||||
if not getattr(adapter, "audit_log_enabled", True):
|
||||
return
|
||||
|
||||
bind = context.db.get_bind()
|
||||
if bind is None:
|
||||
if context.db is None:
|
||||
return
|
||||
|
||||
event_type = adapter.audit_success_event if success else adapter.audit_failure_event
|
||||
@@ -266,11 +269,11 @@ class ApiRequestPipeline:
|
||||
error=error,
|
||||
)
|
||||
|
||||
SessionMaker = sessionmaker(bind=bind)
|
||||
audit_session = SessionMaker()
|
||||
try:
|
||||
# 复用请求级 Session,不创建新的连接
|
||||
# 审计记录随主事务一起提交,由中间件统一管理
|
||||
self.audit_service.log_event(
|
||||
db=audit_session,
|
||||
db=context.db,
|
||||
event_type=event_type,
|
||||
description=f"{context.request.method} {context.request.url.path} via {adapter.name}",
|
||||
user_id=context.user.id if context.user else None,
|
||||
@@ -282,12 +285,9 @@ class ApiRequestPipeline:
|
||||
error_message=error,
|
||||
metadata=metadata,
|
||||
)
|
||||
audit_session.commit()
|
||||
except Exception as exc:
|
||||
audit_session.rollback()
|
||||
# 审计失败不应影响主请求,仅记录警告
|
||||
logger.warning(f"[Audit] Failed to record event for adapter={adapter.name}: {exc}")
|
||||
finally:
|
||||
audit_session.close()
|
||||
|
||||
def _build_audit_metadata(
|
||||
self,
|
||||
|
||||
@@ -13,7 +13,7 @@ from src.api.base.admin_adapter import AdminApiAdapter
|
||||
from src.api.base.pipeline import ApiRequestPipeline
|
||||
from src.core.enums import UserRole
|
||||
from src.database import get_db
|
||||
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, Usage
|
||||
from src.models.database import ApiKey, Provider, RequestCandidate, StatsDaily, StatsDailyModel, Usage
|
||||
from src.models.database import User as DBUser
|
||||
from src.services.system.stats_aggregator import StatsAggregatorService
|
||||
from src.utils.cache_decorator import cache_result
|
||||
@@ -893,69 +893,172 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
# ==================== 模型统计(仍需实时查询)====================
|
||||
model_query = db.query(Usage)
|
||||
if not is_admin:
|
||||
model_query = model_query.filter(Usage.user_id == user.id)
|
||||
model_query = model_query.filter(
|
||||
and_(Usage.created_at >= start_date, Usage.created_at <= end_date)
|
||||
)
|
||||
|
||||
model_stats = (
|
||||
model_query.with_entities(
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||
# ==================== 模型统计 ====================
|
||||
if is_admin:
|
||||
# 管理员:使用预聚合数据 + 今日实时数据
|
||||
# 历史数据从 stats_daily_model 获取
|
||||
historical_model_stats = (
|
||||
db.query(StatsDailyModel)
|
||||
.filter(and_(StatsDailyModel.date >= start_date, StatsDailyModel.date < today))
|
||||
.all()
|
||||
)
|
||||
.group_by(Usage.model)
|
||||
.order_by(func.sum(Usage.total_cost_usd).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
model_summary = [
|
||||
{
|
||||
"model": stat.model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
"avg_response_time": (
|
||||
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
|
||||
),
|
||||
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
|
||||
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
|
||||
}
|
||||
for stat in model_stats
|
||||
]
|
||||
# 按模型汇总历史数据
|
||||
model_agg: dict = {}
|
||||
daily_breakdown: dict = {}
|
||||
|
||||
daily_model_stats = (
|
||||
model_query.with_entities(
|
||||
func.date(Usage.created_at).label("date"),
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
for stat in historical_model_stats:
|
||||
model = stat.model
|
||||
if model not in model_agg:
|
||||
model_agg[model] = {
|
||||
"requests": 0, "tokens": 0, "cost": 0.0,
|
||||
"total_response_time": 0.0, "response_count": 0
|
||||
}
|
||||
model_agg[model]["requests"] += stat.total_requests
|
||||
tokens = (stat.input_tokens + stat.output_tokens +
|
||||
stat.cache_creation_tokens + stat.cache_read_tokens)
|
||||
model_agg[model]["tokens"] += tokens
|
||||
model_agg[model]["cost"] += stat.total_cost
|
||||
if stat.avg_response_time_ms is not None:
|
||||
model_agg[model]["total_response_time"] += stat.avg_response_time_ms * stat.total_requests
|
||||
model_agg[model]["response_count"] += stat.total_requests
|
||||
|
||||
# 按日期分组
|
||||
if stat.date.tzinfo is None:
|
||||
date_utc = stat.date.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
date_utc = stat.date.astimezone(timezone.utc)
|
||||
date_str = date_utc.astimezone(app_tz).date().isoformat()
|
||||
|
||||
daily_breakdown.setdefault(date_str, []).append({
|
||||
"model": model,
|
||||
"requests": stat.total_requests,
|
||||
"tokens": tokens,
|
||||
"cost": stat.total_cost,
|
||||
})
|
||||
|
||||
# 今日实时模型统计
|
||||
today_model_stats = (
|
||||
db.query(
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||
)
|
||||
.filter(Usage.created_at >= today)
|
||||
.group_by(Usage.model)
|
||||
.all()
|
||||
)
|
||||
.group_by(func.date(Usage.created_at), Usage.model)
|
||||
.order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
breakdown = {}
|
||||
for stat in daily_model_stats:
|
||||
date_str = stat.date.isoformat()
|
||||
breakdown.setdefault(date_str, []).append(
|
||||
today_str = today_local.date().isoformat()
|
||||
for stat in today_model_stats:
|
||||
model = stat.model
|
||||
if model not in model_agg:
|
||||
model_agg[model] = {
|
||||
"requests": 0, "tokens": 0, "cost": 0.0,
|
||||
"total_response_time": 0.0, "response_count": 0
|
||||
}
|
||||
model_agg[model]["requests"] += stat.requests or 0
|
||||
model_agg[model]["tokens"] += int(stat.tokens or 0)
|
||||
model_agg[model]["cost"] += float(stat.cost or 0)
|
||||
if stat.avg_response_time is not None:
|
||||
model_agg[model]["total_response_time"] += float(stat.avg_response_time) * (stat.requests or 0)
|
||||
model_agg[model]["response_count"] += stat.requests or 0
|
||||
|
||||
# 今日 breakdown
|
||||
daily_breakdown.setdefault(today_str, []).append({
|
||||
"model": model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
})
|
||||
|
||||
# 构建 model_summary
|
||||
model_summary = []
|
||||
for model, agg in model_agg.items():
|
||||
avg_rt = (agg["total_response_time"] / agg["response_count"] / 1000.0
|
||||
if agg["response_count"] > 0 else 0)
|
||||
model_summary.append({
|
||||
"model": model,
|
||||
"requests": agg["requests"],
|
||||
"tokens": agg["tokens"],
|
||||
"cost": agg["cost"],
|
||||
"avg_response_time": avg_rt,
|
||||
"cost_per_request": agg["cost"] / max(agg["requests"], 1),
|
||||
"tokens_per_request": agg["tokens"] / max(agg["requests"], 1),
|
||||
})
|
||||
model_summary.sort(key=lambda x: x["cost"], reverse=True)
|
||||
|
||||
# 填充 model_breakdown
|
||||
for item in formatted:
|
||||
item["model_breakdown"] = daily_breakdown.get(item["date"], [])
|
||||
|
||||
else:
|
||||
# 普通用户:实时查询(数据量较小)
|
||||
model_query = db.query(Usage).filter(
|
||||
and_(
|
||||
Usage.user_id == user.id,
|
||||
Usage.created_at >= start_date,
|
||||
Usage.created_at <= end_date
|
||||
)
|
||||
)
|
||||
|
||||
model_stats = (
|
||||
model_query.with_entities(
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
func.avg(Usage.response_time_ms).label("avg_response_time"),
|
||||
)
|
||||
.group_by(Usage.model)
|
||||
.order_by(func.sum(Usage.total_cost_usd).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
model_summary = [
|
||||
{
|
||||
"model": stat.model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
"avg_response_time": (
|
||||
float(stat.avg_response_time or 0) / 1000.0 if stat.avg_response_time else 0
|
||||
),
|
||||
"cost_per_request": float(stat.cost or 0) / max(stat.requests or 1, 1),
|
||||
"tokens_per_request": int(stat.tokens or 0) / max(stat.requests or 1, 1),
|
||||
}
|
||||
for stat in model_stats
|
||||
]
|
||||
|
||||
daily_model_stats = (
|
||||
model_query.with_entities(
|
||||
func.date(Usage.created_at).label("date"),
|
||||
Usage.model,
|
||||
func.count(Usage.id).label("requests"),
|
||||
func.sum(Usage.total_tokens).label("tokens"),
|
||||
func.sum(Usage.total_cost_usd).label("cost"),
|
||||
)
|
||||
.group_by(func.date(Usage.created_at), Usage.model)
|
||||
.order_by(func.date(Usage.created_at).desc(), func.sum(Usage.total_cost_usd).desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
for item in formatted:
|
||||
item["model_breakdown"] = breakdown.get(item["date"], [])
|
||||
breakdown = {}
|
||||
for stat in daily_model_stats:
|
||||
date_str = stat.date.isoformat()
|
||||
breakdown.setdefault(date_str, []).append(
|
||||
{
|
||||
"model": stat.model,
|
||||
"requests": stat.requests or 0,
|
||||
"tokens": int(stat.tokens or 0),
|
||||
"cost": float(stat.cost or 0),
|
||||
}
|
||||
)
|
||||
|
||||
for item in formatted:
|
||||
item["model_breakdown"] = breakdown.get(item["date"], [])
|
||||
|
||||
return {
|
||||
"daily_stats": formatted,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
@@ -43,6 +43,9 @@ from src.services.provider.format import normalize_api_format
|
||||
from src.services.system.audit import audit_service
|
||||
from src.services.usage.service import UsageService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.api.handlers.base.stream_context import StreamContext
|
||||
|
||||
|
||||
|
||||
class MessageTelemetry:
|
||||
@@ -399,6 +402,41 @@ class BaseMessageHandler:
|
||||
# 创建后台任务,不阻塞当前流
|
||||
asyncio.create_task(_do_update())
|
||||
|
||||
def _update_usage_to_streaming_with_ctx(self, ctx: "StreamContext") -> None:
|
||||
"""更新 Usage 状态为 streaming,同时更新 provider 和 target_model
|
||||
|
||||
使用 asyncio 后台任务执行数据库更新,避免阻塞流式传输
|
||||
|
||||
Args:
|
||||
ctx: 流式上下文,包含 provider_name 和 mapped_model
|
||||
"""
|
||||
import asyncio
|
||||
from src.database.database import get_db
|
||||
|
||||
target_request_id = self.request_id
|
||||
provider = ctx.provider_name
|
||||
target_model = ctx.mapped_model
|
||||
|
||||
async def _do_update() -> None:
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
UsageService.update_usage_status(
|
||||
db=db,
|
||||
request_id=target_request_id,
|
||||
status="streaming",
|
||||
provider=provider,
|
||||
target_model=target_model,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"[{target_request_id}] 更新 Usage 状态为 streaming 失败: {e}")
|
||||
|
||||
# 创建后台任务,不阻塞当前流
|
||||
asyncio.create_task(_do_update())
|
||||
|
||||
def _log_request_error(self, message: str, error: Exception) -> None:
|
||||
"""记录请求错误日志,对业务异常不打印堆栈
|
||||
|
||||
@@ -411,9 +449,10 @@ class BaseMessageHandler:
|
||||
QuotaExceededException,
|
||||
RateLimitException,
|
||||
ModelNotSupportedException,
|
||||
UpstreamClientException,
|
||||
)
|
||||
|
||||
if isinstance(error, (ProviderException, QuotaExceededException, RateLimitException, ModelNotSupportedException)):
|
||||
if isinstance(error, (ProviderException, QuotaExceededException, RateLimitException, ModelNotSupportedException, UpstreamClientException)):
|
||||
# 业务异常:简洁日志,不打印堆栈
|
||||
logger.error(f"{message}: [{type(error).__name__}] {error}")
|
||||
else:
|
||||
|
||||
@@ -19,8 +19,9 @@ Chat Adapter 通用基类
|
||||
import time
|
||||
import traceback
|
||||
from abc import abstractmethod
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -64,18 +65,6 @@ class ChatAdapterBase(ApiAdapter):
|
||||
|
||||
def __init__(self, allowed_api_formats: Optional[list[str]] = None):
|
||||
self.allowed_api_formats = allowed_api_formats or [self.FORMAT_ID]
|
||||
self.response_normalizer = None
|
||||
# 可选启用响应规范化
|
||||
self._init_response_normalizer()
|
||||
|
||||
def _init_response_normalizer(self):
|
||||
"""初始化响应规范化器 - 子类可覆盖"""
|
||||
try:
|
||||
from src.services.provider.response_normalizer import ResponseNormalizer
|
||||
|
||||
self.response_normalizer = ResponseNormalizer()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
async def handle(self, context: ApiRequestContext):
|
||||
"""处理 Chat API 请求"""
|
||||
@@ -228,8 +217,6 @@ class ChatAdapterBase(ApiAdapter):
|
||||
user_agent=user_agent,
|
||||
start_time=start_time,
|
||||
allowed_api_formats=self.allowed_api_formats,
|
||||
response_normalizer=self.response_normalizer,
|
||||
enable_response_normalization=self.response_normalizer is not None,
|
||||
adapter_detector=self.detect_capability_requirements,
|
||||
)
|
||||
|
||||
@@ -634,6 +621,39 @@ class ChatAdapterBase(ApiAdapter):
|
||||
# 如果所有阶梯都有上限且都超过了,返回最后一个阶梯
|
||||
return tiers[-1] if tiers else None
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询 - 子类应覆盖此方法
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""
|
||||
查询上游 API 支持的模型列表
|
||||
|
||||
这是 Aether 内部发起的请求(非用户透传),用于:
|
||||
- 管理后台查询提供商支持的模型
|
||||
- 自动发现可用模型
|
||||
|
||||
Args:
|
||||
client: httpx 异步客户端
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥(已解密)
|
||||
extra_headers: 端点配置的额外请求头
|
||||
|
||||
Returns:
|
||||
(models, error): 模型列表和错误信息
|
||||
- models: 模型信息列表,每个模型至少包含 id 字段
|
||||
- error: 错误信息,成功时为 None
|
||||
"""
|
||||
# 默认实现返回空列表,子类应覆盖
|
||||
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Adapter 注册表 - 用于根据 API format 获取 Adapter 实例
|
||||
|
||||
@@ -88,8 +88,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
user_agent: str,
|
||||
start_time: float,
|
||||
allowed_api_formats: Optional[list] = None,
|
||||
response_normalizer: Optional[Any] = None,
|
||||
enable_response_normalization: bool = False,
|
||||
adapter_detector: Optional[Callable[[Dict[str, str], Optional[Dict[str, Any]]], Dict[str, bool]]] = None,
|
||||
):
|
||||
allowed = allowed_api_formats or [self.FORMAT_ID]
|
||||
@@ -106,8 +104,6 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
)
|
||||
self._parser: Optional[ResponseParser] = None
|
||||
self._request_builder = PassthroughRequestBuilder()
|
||||
self.response_normalizer = response_normalizer
|
||||
self.enable_response_normalization = enable_response_normalization
|
||||
|
||||
@property
|
||||
def parser(self) -> ResponseParser:
|
||||
@@ -264,9 +260,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
mapping = await mapper.get_mapping(source_model, provider_id)
|
||||
|
||||
if mapping and mapping.model:
|
||||
# 使用 select_provider_model_name 支持别名功能
|
||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名
|
||||
# 传入 api_format 用于过滤适用的别名作用域
|
||||
# 使用 select_provider_model_name 支持映射功能
|
||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射
|
||||
# 传入 api_format 用于过滤适用的映射作用域
|
||||
affinity_key = self.api_key.id if self.api_key else None
|
||||
mapped_name = mapping.model.select_provider_model_name(
|
||||
affinity_key, api_format=self.FORMAT_ID
|
||||
@@ -297,11 +293,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
# 创建类型安全的流式上下文
|
||||
ctx = StreamContext(model=model, api_format=api_format)
|
||||
|
||||
# 创建更新状态的回调闭包(可以访问 ctx)
|
||||
def update_streaming_status() -> None:
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
|
||||
# 创建流处理器
|
||||
stream_processor = StreamProcessor(
|
||||
request_id=self.request_id,
|
||||
default_parser=self.parser,
|
||||
on_streaming_start=self._update_usage_to_streaming,
|
||||
on_streaming_start=update_streaming_status,
|
||||
)
|
||||
|
||||
# 定义请求函数
|
||||
@@ -466,7 +466,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
pool=config.http_pool_timeout,
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient(timeout=timeout_config, follow_redirects=True)
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
http_client = HTTPClientPool.create_client_with_proxy(
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=timeout_config,
|
||||
)
|
||||
try:
|
||||
response_ctx = http_client.stream(
|
||||
"POST", url, json=provider_payload, headers=provider_headers
|
||||
@@ -633,11 +639,17 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
|
||||
logger.info(f" [{self.request_id}] 发送非流式请求: Provider={provider.name}, "
|
||||
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')}")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=float(endpoint.timeout),
|
||||
follow_redirects=True,
|
||||
) as http_client:
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
http_client = HTTPClientPool.create_client_with_proxy(
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=httpx.Timeout(float(endpoint.timeout)),
|
||||
)
|
||||
async with http_client:
|
||||
resp = await http_client.post(url, json=provider_payload, headers=provider_hdrs)
|
||||
|
||||
status_code = resp.status_code
|
||||
@@ -652,10 +664,32 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
response_headers=response_headers,
|
||||
)
|
||||
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(
|
||||
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()
|
||||
|
||||
@@ -17,8 +17,9 @@ CLI Adapter 通用基类
|
||||
|
||||
import time
|
||||
import traceback
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -580,6 +581,39 @@ class CliAdapterBase(ApiAdapter):
|
||||
|
||||
return tiers[-1] if tiers else None
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询 - 子类应覆盖此方法
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""
|
||||
查询上游 API 支持的模型列表
|
||||
|
||||
这是 Aether 内部发起的请求(非用户透传),用于:
|
||||
- 管理后台查询提供商支持的模型
|
||||
- 自动发现可用模型
|
||||
|
||||
Args:
|
||||
client: httpx 异步客户端
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥(已解密)
|
||||
extra_headers: 端点配置的额外请求头
|
||||
|
||||
Returns:
|
||||
(models, error): 模型列表和错误信息
|
||||
- models: 模型信息列表,每个模型至少包含 id 字段
|
||||
- error: 错误信息,成功时为 None
|
||||
"""
|
||||
# 默认实现返回空列表,子类应覆盖
|
||||
return [], f"{cls.FORMAT_ID} adapter does not implement fetch_models"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# CLI Adapter 注册表 - 用于根据 API format 获取 CLI Adapter 实例
|
||||
|
||||
@@ -136,7 +136,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
查找逻辑:
|
||||
1. 直接通过 GlobalModel.name 匹配
|
||||
2. 查找该 Provider 的 Model 实现
|
||||
3. 使用 provider_model_name / provider_model_aliases 选择最终名称
|
||||
3. 使用 provider_model_name / provider_model_mappings 选择最终名称
|
||||
|
||||
Args:
|
||||
source_model: 用户请求的模型名(必须是 GlobalModel.name)
|
||||
@@ -153,9 +153,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
logger.debug(f"[CLI] _get_mapped_model: source={source_model}, provider={provider_id[:8]}..., mapping={mapping}")
|
||||
|
||||
if mapping and mapping.model:
|
||||
# 使用 select_provider_model_name 支持别名功能
|
||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一别名
|
||||
# 传入 api_format 用于过滤适用的别名作用域
|
||||
# 使用 select_provider_model_name 支持模型映射功能
|
||||
# 传入 api_key.id 作为 affinity_key,实现相同用户稳定选择同一映射
|
||||
# 传入 api_format 用于过滤适用的映射作用域
|
||||
affinity_key = self.api_key.id if self.api_key else None
|
||||
mapped_name = mapping.model.select_provider_model_name(
|
||||
affinity_key, api_format=self.FORMAT_ID
|
||||
@@ -400,7 +400,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
ctx.provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||
ctx.client_api_format = ctx.api_format # 已在 process_stream 中设置
|
||||
|
||||
# 获取模型映射(别名/映射 → 实际模型名)
|
||||
# 获取模型映射(映射名称 → 实际模型名)
|
||||
mapped_model = await self._get_mapped_model(
|
||||
source_model=ctx.model,
|
||||
provider_id=str(provider.id),
|
||||
@@ -454,7 +454,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
f"Key=***{key.api_key[-4:]}, "
|
||||
f"原始模型={ctx.model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
||||
|
||||
http_client = httpx.AsyncClient(timeout=timeout_config, follow_redirects=True)
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
http_client = HTTPClientPool.create_client_with_proxy(
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=timeout_config,
|
||||
)
|
||||
try:
|
||||
response_ctx = http_client.stream(
|
||||
"POST", url, json=provider_payload, headers=provider_headers
|
||||
@@ -526,7 +532,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
async for chunk in stream_response.aiter_raw():
|
||||
# 在第一次输出数据前更新状态为 streaming
|
||||
if not streaming_status_updated:
|
||||
self._update_usage_to_streaming(ctx.request_id)
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
streaming_status_updated = True
|
||||
|
||||
buffer += chunk
|
||||
@@ -810,7 +816,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
|
||||
# 在第一次输出数据前更新状态为 streaming
|
||||
if prefetched_chunks:
|
||||
self._update_usage_to_streaming(ctx.request_id)
|
||||
self._update_usage_to_streaming_with_ctx(ctx)
|
||||
|
||||
# 先处理预读的字节块
|
||||
for chunk in prefetched_chunks:
|
||||
@@ -1108,8 +1114,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
async for chunk in stream_generator:
|
||||
yield chunk
|
||||
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
|
||||
except httpx.TimeoutException as e:
|
||||
ctx.status_code = 504
|
||||
@@ -1374,7 +1382,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
provider_name = str(provider.name)
|
||||
provider_api_format = str(endpoint.api_format) if endpoint.api_format else ""
|
||||
|
||||
# 获取模型映射(别名/映射 → 实际模型名)
|
||||
# 获取模型映射(映射名称 → 实际模型名)
|
||||
mapped_model = await self._get_mapped_model(
|
||||
source_model=model,
|
||||
provider_id=str(provider.id),
|
||||
@@ -1419,10 +1427,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
f"Key=***{key.api_key[-4:]}, "
|
||||
f"原始模型={model}, 映射后={mapped_model or '无映射'}, URL模型={url_model}")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=float(endpoint.timeout),
|
||||
follow_redirects=True,
|
||||
) as http_client:
|
||||
# 创建 HTTP 客户端(支持代理配置)
|
||||
from src.clients.http_client import HTTPClientPool
|
||||
|
||||
http_client = HTTPClientPool.create_client_with_proxy(
|
||||
proxy_config=endpoint.proxy,
|
||||
timeout=httpx.Timeout(float(endpoint.timeout)),
|
||||
)
|
||||
async with http_client:
|
||||
resp = await http_client.post(url, json=provider_payload, headers=provider_headers)
|
||||
|
||||
status_code = resp.status_code
|
||||
|
||||
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. 响应流生成
|
||||
3. 预读和嵌套错误检测
|
||||
4. 客户端断开检测
|
||||
5. 流式平滑输出
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import codecs
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
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.response_parser import ResponseParser
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSmoothingConfig:
|
||||
"""流式平滑输出配置"""
|
||||
|
||||
enabled: bool = False
|
||||
chunk_size: int = 20
|
||||
delay_ms: int = 8
|
||||
|
||||
|
||||
class StreamProcessor:
|
||||
"""
|
||||
流式响应处理器
|
||||
|
||||
负责处理 SSE 流的解析、错误检测和响应生成。
|
||||
负责处理 SSE 流的解析、错误检测、响应生成和平滑输出。
|
||||
从 ChatHandlerBase 中提取,使其职责更加单一。
|
||||
"""
|
||||
|
||||
@@ -40,6 +55,7 @@ class StreamProcessor:
|
||||
on_streaming_start: Optional[Callable[[], None]] = None,
|
||||
*,
|
||||
collect_text: bool = False,
|
||||
smoothing_config: Optional[StreamSmoothingConfig] = None,
|
||||
):
|
||||
"""
|
||||
初始化流处理器
|
||||
@@ -48,11 +64,17 @@ class StreamProcessor:
|
||||
request_id: 请求 ID(用于日志)
|
||||
default_parser: 默认响应解析器
|
||||
on_streaming_start: 流开始时的回调(用于更新状态)
|
||||
collect_text: 是否收集文本内容
|
||||
smoothing_config: 流式平滑输出配置
|
||||
"""
|
||||
self.request_id = request_id
|
||||
self.default_parser = default_parser
|
||||
self.on_streaming_start = on_streaming_start
|
||||
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:
|
||||
"""
|
||||
@@ -127,6 +149,13 @@ class StreamProcessor:
|
||||
if event_type in ("response.completed", "message_stop"):
|
||||
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(
|
||||
self,
|
||||
byte_iterator: Any,
|
||||
@@ -369,7 +398,7 @@ class StreamProcessor:
|
||||
sse_parser: SSE 解析器
|
||||
line: 原始行数据
|
||||
"""
|
||||
# SSEEventParser 以“去掉换行符”的单行文本作为输入;这里统一剔除 CR/LF,
|
||||
# SSEEventParser 以"去掉换行符"的单行文本作为输入;这里统一剔除 CR/LF,
|
||||
# 避免把空行误判成 "\n" 并导致事件边界解析错误。
|
||||
normalized_line = line.rstrip("\r\n")
|
||||
events = sse_parser.feed_line(normalized_line)
|
||||
@@ -400,32 +429,201 @@ class StreamProcessor:
|
||||
响应数据块
|
||||
"""
|
||||
try:
|
||||
# 断连检查频率:每次 await 都会引入调度开销,过于频繁会让流式"发一段停一段"
|
||||
# 这里按时间间隔节流,兼顾及时停止上游读取与吞吐平滑性。
|
||||
next_disconnect_check_at = 0.0
|
||||
disconnect_check_interval_s = 0.25
|
||||
# 使用后台任务检查断连,完全不阻塞流式传输
|
||||
disconnected = False
|
||||
|
||||
async for chunk in stream_generator:
|
||||
now = time.monotonic()
|
||||
if now >= next_disconnect_check_at:
|
||||
next_disconnect_check_at = now + disconnect_check_interval_s
|
||||
async def check_disconnect_background() -> None:
|
||||
nonlocal disconnected
|
||||
while not disconnected and not ctx.has_completion:
|
||||
await asyncio.sleep(0.5)
|
||||
if await is_disconnected():
|
||||
logger.warning(f"ID:{self.request_id} | Client disconnected")
|
||||
ctx.status_code = 499 # Client Closed Request
|
||||
ctx.error_message = "client_disconnected"
|
||||
|
||||
disconnected = True
|
||||
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
|
||||
except Exception as e:
|
||||
ctx.status_code = 500
|
||||
ctx.error_message = str(e)
|
||||
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(
|
||||
self,
|
||||
response_ctx: Any,
|
||||
@@ -440,3 +638,128 @@ class StreamProcessor:
|
||||
await http_client.aclose()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
@@ -4,8 +4,9 @@ Claude Chat Adapter - 基于 ChatAdapterBase 的 Claude Chat API 适配器
|
||||
处理 /v1/messages 端点的 Claude Chat 格式请求。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -155,6 +156,59 @@ class ClaudeChatAdapter(ChatAdapterBase):
|
||||
"thinking_enabled": bool(request_obj.thinking),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 Claude API 支持的模型列表"""
|
||||
headers = {
|
||||
"x-api-key": api_key,
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
if extra_headers:
|
||||
# 防止 extra_headers 覆盖认证头
|
||||
safe_headers = {
|
||||
k: v for k, v in extra_headers.items()
|
||||
if k.lower() not in ("x-api-key", "authorization", "anthropic-version")
|
||||
}
|
||||
headers.update(safe_headers)
|
||||
|
||||
# 构建 /v1/models URL
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
models_url = f"{base_url}/models"
|
||||
else:
|
||||
models_url = f"{base_url}/v1/models"
|
||||
|
||||
try:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
logger.debug(f"Claude models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = []
|
||||
if "data" in data:
|
||||
models = data["data"]
|
||||
elif isinstance(data, list):
|
||||
models = data
|
||||
# 为每个模型添加 api_format 字段
|
||||
for m in models:
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, None
|
||||
else:
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"Claude models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch Claude models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
|
||||
def build_claude_adapter(x_app_header: Optional[str]):
|
||||
"""根据 x-app 头部构造 Chat 或 Claude Code 适配器。"""
|
||||
|
||||
@@ -131,10 +131,5 @@ class ClaudeChatHandler(ChatHandlerBase):
|
||||
Returns:
|
||||
规范化后的响应
|
||||
"""
|
||||
if self.response_normalizer and self.response_normalizer.should_normalize(response):
|
||||
result: Dict[str, Any] = self.response_normalizer.normalize_claude_response(
|
||||
response_data=response,
|
||||
request_id=self.request_id,
|
||||
)
|
||||
return result
|
||||
# 作为中转站,直接透传响应,不做标准化处理
|
||||
return response
|
||||
|
||||
@@ -4,13 +4,15 @@ Claude CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
|
||||
继承 CliAdapterBase,只需配置 FORMAT_ID 和 HANDLER_CLASS。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
|
||||
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
|
||||
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
|
||||
from src.api.handlers.claude.adapter import ClaudeCapabilityDetector
|
||||
from src.api.handlers.claude.adapter import ClaudeCapabilityDetector, ClaudeChatAdapter
|
||||
from src.config.settings import config
|
||||
|
||||
|
||||
@register_cli_adapter
|
||||
@@ -99,5 +101,30 @@ class ClaudeCliAdapter(CliAdapterBase):
|
||||
"system_present": bool(payload.get("system")),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 Claude API 支持的模型列表(带 CLI User-Agent)"""
|
||||
# 复用 ClaudeChatAdapter 的实现,添加 CLI User-Agent
|
||||
cli_headers = {"User-Agent": config.internal_user_agent_claude}
|
||||
if extra_headers:
|
||||
cli_headers.update(extra_headers)
|
||||
models, error = await ClaudeChatAdapter.fetch_models(
|
||||
client, base_url, api_key, cli_headers
|
||||
)
|
||||
# 更新 api_format 为 CLI 格式
|
||||
for m in models:
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
|
||||
__all__ = ["ClaudeCliAdapter"]
|
||||
|
||||
@@ -4,8 +4,9 @@ Gemini Chat Adapter
|
||||
处理 Gemini API 格式的请求适配
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -151,6 +152,53 @@ class GeminiChatAdapter(ChatAdapterBase):
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 Gemini API 支持的模型列表"""
|
||||
# 兼容 base_url 已包含 /v1beta 的情况
|
||||
base_url_clean = base_url.rstrip("/")
|
||||
if base_url_clean.endswith("/v1beta"):
|
||||
models_url = f"{base_url_clean}/models?key={api_key}"
|
||||
else:
|
||||
models_url = f"{base_url_clean}/v1beta/models?key={api_key}"
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
try:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
logger.debug(f"Gemini models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if "models" in data:
|
||||
# 转换为统一格式
|
||||
return [
|
||||
{
|
||||
"id": m.get("name", "").replace("models/", ""),
|
||||
"owned_by": "google",
|
||||
"display_name": m.get("displayName", ""),
|
||||
"api_format": cls.FORMAT_ID,
|
||||
}
|
||||
for m in data["models"]
|
||||
], None
|
||||
return [], None
|
||||
else:
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"Gemini models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch Gemini models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
|
||||
def build_gemini_adapter(x_app_header: str = "") -> GeminiChatAdapter:
|
||||
"""
|
||||
|
||||
@@ -148,17 +148,6 @@ class GeminiChatHandler(ChatHandlerBase):
|
||||
|
||||
Returns:
|
||||
规范化后的响应
|
||||
|
||||
TODO: 如果需要,实现响应规范化逻辑
|
||||
"""
|
||||
# 可选:使用 response_normalizer 进行规范化
|
||||
# if (
|
||||
# self.response_normalizer
|
||||
# and self.response_normalizer.should_normalize(response)
|
||||
# ):
|
||||
# return self.response_normalizer.normalize_gemini_response(
|
||||
# response_data=response,
|
||||
# request_id=self.request_id,
|
||||
# strict=False,
|
||||
# )
|
||||
# 作为中转站,直接透传响应,不做标准化处理
|
||||
return response
|
||||
|
||||
@@ -4,12 +4,15 @@ Gemini CLI Adapter - 基于通用 CLI Adapter 基类的实现
|
||||
继承 CliAdapterBase,处理 Gemini CLI 格式的请求。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
|
||||
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
|
||||
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
|
||||
from src.api.handlers.gemini.adapter import GeminiChatAdapter
|
||||
from src.config.settings import config
|
||||
|
||||
|
||||
@register_cli_adapter
|
||||
@@ -95,6 +98,31 @@ class GeminiCliAdapter(CliAdapterBase):
|
||||
"safety_settings_count": len(payload.get("safety_settings") or []),
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 Gemini API 支持的模型列表(带 CLI User-Agent)"""
|
||||
# 复用 GeminiChatAdapter 的实现,添加 CLI User-Agent
|
||||
cli_headers = {"User-Agent": config.internal_user_agent_gemini}
|
||||
if extra_headers:
|
||||
cli_headers.update(extra_headers)
|
||||
models, error = await GeminiChatAdapter.fetch_models(
|
||||
client, base_url, api_key, cli_headers
|
||||
)
|
||||
# 更新 api_format 为 CLI 格式
|
||||
for m in models:
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
|
||||
def build_gemini_cli_adapter(x_app_header: str = "") -> GeminiCliAdapter:
|
||||
"""
|
||||
|
||||
@@ -4,8 +4,9 @@ OpenAI Chat Adapter - 基于 ChatAdapterBase 的 OpenAI Chat API 适配器
|
||||
处理 /v1/chat/completions 端点的 OpenAI Chat 格式请求。
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional, Type
|
||||
from typing import Any, Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -105,5 +106,53 @@ class OpenAIChatAdapter(ChatAdapterBase):
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 OpenAI 兼容 API 支持的模型列表"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
if extra_headers:
|
||||
# 防止 extra_headers 覆盖 Authorization
|
||||
safe_headers = {k: v for k, v in extra_headers.items() if k.lower() != "authorization"}
|
||||
headers.update(safe_headers)
|
||||
|
||||
# 构建 /v1/models URL
|
||||
base_url = base_url.rstrip("/")
|
||||
if base_url.endswith("/v1"):
|
||||
models_url = f"{base_url}/models"
|
||||
else:
|
||||
models_url = f"{base_url}/v1/models"
|
||||
|
||||
try:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
logger.debug(f"OpenAI models request to {models_url}: status={response.status_code}")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = []
|
||||
if "data" in data:
|
||||
models = data["data"]
|
||||
elif isinstance(data, list):
|
||||
models = data
|
||||
# 为每个模型添加 api_format 字段
|
||||
for m in models:
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, None
|
||||
else:
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
error_msg = f"HTTP {response.status_code}: {error_body}"
|
||||
logger.warning(f"OpenAI models request to {models_url} failed: {error_msg}")
|
||||
return [], error_msg
|
||||
except Exception as e:
|
||||
error_msg = f"Request error: {str(e)}"
|
||||
logger.warning(f"Failed to fetch models from {models_url}: {e}")
|
||||
return [], error_msg
|
||||
|
||||
|
||||
__all__ = ["OpenAIChatAdapter"]
|
||||
|
||||
@@ -128,10 +128,5 @@ class OpenAIChatHandler(ChatHandlerBase):
|
||||
Returns:
|
||||
规范化后的响应
|
||||
"""
|
||||
if self.response_normalizer and self.response_normalizer.should_normalize(response):
|
||||
return self.response_normalizer.normalize_openai_response(
|
||||
response_data=response,
|
||||
request_id=self.request_id,
|
||||
strict=False,
|
||||
)
|
||||
# 作为中转站,直接透传响应,不做标准化处理
|
||||
return response
|
||||
|
||||
@@ -4,12 +4,15 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
|
||||
继承 CliAdapterBase,只需配置 FORMAT_ID 和 HANDLER_CLASS。
|
||||
"""
|
||||
|
||||
from typing import Optional, Type
|
||||
from typing import Dict, Optional, Tuple, Type
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
|
||||
from src.api.handlers.base.cli_adapter_base import CliAdapterBase, register_cli_adapter
|
||||
from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
|
||||
from src.api.handlers.openai.adapter import OpenAIChatAdapter
|
||||
from src.config.settings import config
|
||||
|
||||
|
||||
@register_cli_adapter
|
||||
@@ -40,5 +43,30 @@ class OpenAICliAdapter(CliAdapterBase):
|
||||
return authorization.replace("Bearer ", "")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# 模型列表查询
|
||||
# =========================================================================
|
||||
|
||||
@classmethod
|
||||
async def fetch_models(
|
||||
cls,
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[list, Optional[str]]:
|
||||
"""查询 OpenAI 兼容 API 支持的模型列表(带 CLI User-Agent)"""
|
||||
# 复用 OpenAIChatAdapter 的实现,添加 CLI User-Agent
|
||||
cli_headers = {"User-Agent": config.internal_user_agent_openai}
|
||||
if extra_headers:
|
||||
cli_headers.update(extra_headers)
|
||||
models, error = await OpenAIChatAdapter.fetch_models(
|
||||
client, base_url, api_key, cli_headers
|
||||
)
|
||||
# 更新 api_format 为 CLI 格式
|
||||
for m in models:
|
||||
m["api_format"] = cls.FORMAT_ID
|
||||
return models, error
|
||||
|
||||
|
||||
__all__ = ["OpenAICliAdapter"]
|
||||
|
||||
@@ -5,12 +5,55 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
def build_proxy_url(proxy_config: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
根据代理配置构建完整的代理 URL
|
||||
|
||||
Args:
|
||||
proxy_config: 代理配置字典,包含 url, username, password, enabled
|
||||
|
||||
Returns:
|
||||
完整的代理 URL,如 socks5://user:pass@host:port
|
||||
如果 enabled=False 或无配置,返回 None
|
||||
"""
|
||||
if not proxy_config:
|
||||
return None
|
||||
|
||||
# 检查 enabled 字段,默认为 True(兼容旧数据)
|
||||
if not proxy_config.get("enabled", True):
|
||||
return None
|
||||
|
||||
proxy_url = proxy_config.get("url")
|
||||
if not proxy_url:
|
||||
return None
|
||||
|
||||
username = proxy_config.get("username")
|
||||
password = proxy_config.get("password")
|
||||
|
||||
# 只要有用户名就添加认证信息(密码可以为空)
|
||||
if username:
|
||||
parsed = urlparse(proxy_url)
|
||||
# URL 编码用户名和密码,处理特殊字符(如 @, :, /)
|
||||
encoded_username = quote(username, safe="")
|
||||
encoded_password = quote(password, safe="") if password else ""
|
||||
# 重新构建带认证的代理 URL
|
||||
if encoded_password:
|
||||
auth_proxy = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{parsed.netloc}"
|
||||
else:
|
||||
auth_proxy = f"{parsed.scheme}://{encoded_username}@{parsed.netloc}"
|
||||
if parsed.path:
|
||||
auth_proxy += parsed.path
|
||||
return auth_proxy
|
||||
|
||||
return proxy_url
|
||||
|
||||
|
||||
class HTTPClientPool:
|
||||
"""
|
||||
@@ -121,6 +164,44 @@ class HTTPClientPool:
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
@classmethod
|
||||
def create_client_with_proxy(
|
||||
cls,
|
||||
proxy_config: Optional[Dict[str, Any]] = None,
|
||||
timeout: Optional[httpx.Timeout] = None,
|
||||
**kwargs: Any,
|
||||
) -> httpx.AsyncClient:
|
||||
"""
|
||||
创建带代理配置的HTTP客户端
|
||||
|
||||
Args:
|
||||
proxy_config: 代理配置字典,包含 url, username, password
|
||||
timeout: 超时配置
|
||||
**kwargs: 其他 httpx.AsyncClient 配置参数
|
||||
|
||||
Returns:
|
||||
配置好的 httpx.AsyncClient 实例
|
||||
"""
|
||||
config: Dict[str, Any] = {
|
||||
"http2": False,
|
||||
"verify": True,
|
||||
"follow_redirects": True,
|
||||
}
|
||||
|
||||
if timeout:
|
||||
config["timeout"] = timeout
|
||||
else:
|
||||
config["timeout"] = httpx.Timeout(10.0, read=300.0)
|
||||
|
||||
# 添加代理配置
|
||||
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
|
||||
if proxy_url:
|
||||
config["proxy"] = proxy_url
|
||||
logger.debug(f"创建带代理的HTTP客户端: {proxy_config.get('url', 'unknown')}")
|
||||
|
||||
config.update(kwargs)
|
||||
return httpx.AsyncClient(**config)
|
||||
|
||||
|
||||
# 便捷访问函数
|
||||
def get_http_client() -> httpx.AsyncClient:
|
||||
|
||||
@@ -120,7 +120,7 @@ class RedisClientManager:
|
||||
if self._circuit_open_until and time.time() < self._circuit_open_until:
|
||||
remaining = self._circuit_open_until - time.time()
|
||||
logger.warning(
|
||||
"Redis 客户端处于熔断状态,跳过初始化,剩余 %.1f 秒 (last_error: %s)",
|
||||
"Redis 客户端处于熔断状态,跳过初始化,剩余 {:.1f} 秒 (last_error: {})",
|
||||
remaining,
|
||||
self._last_error,
|
||||
)
|
||||
@@ -200,7 +200,7 @@ class RedisClientManager:
|
||||
if self._consecutive_failures >= self._circuit_threshold:
|
||||
self._circuit_open_until = time.time() + self._circuit_reset_seconds
|
||||
logger.warning(
|
||||
"Redis 初始化连续失败 %s 次,开启熔断 %s 秒。"
|
||||
"Redis 初始化连续失败 {} 次,开启熔断 {} 秒。"
|
||||
"熔断期间以下功能将降级: 缓存亲和性、分布式并发控制、RPM限流。"
|
||||
"可通过管理 API /api/admin/system/redis/reset-circuit 手动重置。",
|
||||
self._consecutive_failures,
|
||||
@@ -267,6 +267,9 @@ async def get_redis_client(require_redis: bool = False) -> Optional[aioredis.Red
|
||||
|
||||
if _redis_manager is None:
|
||||
_redis_manager = RedisClientManager()
|
||||
# 如果尚未连接(例如启动时降级、或 close() 后),尝试重新初始化。
|
||||
# initialize() 内部包含熔断器逻辑,避免频繁重试导致抖动。
|
||||
if _redis_manager.get_client() is None:
|
||||
await _redis_manager.initialize(require_redis=require_redis)
|
||||
|
||||
return _redis_manager.get_client()
|
||||
|
||||
@@ -41,8 +41,8 @@ class CacheSize:
|
||||
class ConcurrencyDefaults:
|
||||
"""并发控制默认值"""
|
||||
|
||||
# 自适应并发初始限制(保守值)
|
||||
INITIAL_LIMIT = 3
|
||||
# 自适应并发初始限制(宽松起步,遇到 429 再降低)
|
||||
INITIAL_LIMIT = 50
|
||||
|
||||
# 429错误后的冷却时间(分钟)- 在此期间不会增加并发限制
|
||||
COOLDOWN_AFTER_429_MINUTES = 5
|
||||
@@ -67,13 +67,14 @@ class ConcurrencyDefaults:
|
||||
MIN_SAMPLES_FOR_DECISION = 5
|
||||
|
||||
# 扩容步长 - 每次扩容增加的并发数
|
||||
INCREASE_STEP = 1
|
||||
INCREASE_STEP = 2
|
||||
|
||||
# 缩容乘数 - 遇到 429 时的缩容比例
|
||||
DECREASE_MULTIPLIER = 0.7
|
||||
# 缩容乘数 - 遇到 429 时基于当前并发数的缩容比例
|
||||
# 0.85 表示降到触发 429 时并发数的 85%
|
||||
DECREASE_MULTIPLIER = 0.85
|
||||
|
||||
# 最大并发限制上限
|
||||
MAX_CONCURRENT_LIMIT = 100
|
||||
MAX_CONCURRENT_LIMIT = 200
|
||||
|
||||
# 最小并发限制下限
|
||||
MIN_CONCURRENT_LIMIT = 1
|
||||
@@ -85,6 +86,11 @@ class ConcurrencyDefaults:
|
||||
# 探测性扩容最小请求数 - 在探测间隔内至少需要这么多请求
|
||||
PROBE_INCREASE_MIN_REQUESTS = 10
|
||||
|
||||
# === 缓存用户预留比例 ===
|
||||
# 缓存用户槽位预留比例(新用户可用 1 - 此值)
|
||||
# 0.1 表示缓存用户预留 10%,新用户可用 90%
|
||||
CACHE_RESERVATION_RATIO = 0.1
|
||||
|
||||
|
||||
class CircuitBreakerDefaults:
|
||||
"""熔断器配置默认值(滑动窗口 + 半开状态模式)
|
||||
|
||||
@@ -105,6 +105,13 @@ class Config:
|
||||
self.llm_api_rate_limit = int(os.getenv("LLM_API_RATE_LIMIT", "100"))
|
||||
self.public_api_rate_limit = int(os.getenv("PUBLIC_API_RATE_LIMIT", "60"))
|
||||
|
||||
# 可信代理配置
|
||||
# TRUSTED_PROXY_COUNT: 信任的代理层数(默认 1,即信任最近一层代理)
|
||||
# 设置为 0 表示不信任任何代理头,直接使用连接 IP
|
||||
# 当服务部署在 Nginx/CloudFlare 等反向代理后面时,设置为对应的代理层数
|
||||
# 如果服务直接暴露公网,应设置为 0 以防止 IP 伪造
|
||||
self.trusted_proxy_count = int(os.getenv("TRUSTED_PROXY_COUNT", "1"))
|
||||
|
||||
# 异常处理配置
|
||||
# 设置为 True 时,ProxyException 会传播到路由层以便记录 provider_request_headers
|
||||
# 设置为 False 时,使用全局异常处理器统一处理
|
||||
@@ -122,9 +129,9 @@ class Config:
|
||||
|
||||
# 并发控制配置
|
||||
# CONCURRENCY_SLOT_TTL: 并发槽位 TTL(秒),防止死锁
|
||||
# CACHE_RESERVATION_RATIO: 缓存用户预留比例(默认 30%)
|
||||
# CACHE_RESERVATION_RATIO: 缓存用户预留比例(默认 10%,新用户可用 90%)
|
||||
self.concurrency_slot_ttl = int(os.getenv("CONCURRENCY_SLOT_TTL", "600"))
|
||||
self.cache_reservation_ratio = float(os.getenv("CACHE_RESERVATION_RATIO", "0.3"))
|
||||
self.cache_reservation_ratio = float(os.getenv("CACHE_RESERVATION_RATIO", "0.1"))
|
||||
|
||||
# HTTP 请求超时配置(秒)
|
||||
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0"))
|
||||
@@ -137,6 +144,18 @@ class Config:
|
||||
self.stream_prefetch_lines = int(os.getenv("STREAM_PREFETCH_LINES", "5"))
|
||||
self.stream_stats_delay = float(os.getenv("STREAM_STATS_DELAY", "0.1"))
|
||||
|
||||
# 内部请求 User-Agent 配置(用于查询上游模型列表等)
|
||||
# 可通过环境变量覆盖默认值
|
||||
self.internal_user_agent_claude = os.getenv(
|
||||
"CLAUDE_USER_AGENT", "claude-cli/1.0"
|
||||
)
|
||||
self.internal_user_agent_openai = os.getenv(
|
||||
"OPENAI_USER_AGENT", "openai-cli/1.0"
|
||||
)
|
||||
self.internal_user_agent_gemini = os.getenv(
|
||||
"GEMINI_USER_AGENT", "gemini-cli/1.0"
|
||||
)
|
||||
|
||||
# 验证连接池配置
|
||||
self._validate_pool_config()
|
||||
|
||||
|
||||
@@ -46,6 +46,11 @@ class BatchCommitter:
|
||||
|
||||
def mark_dirty(self, session: Session):
|
||||
"""标记 Session 有待提交的更改"""
|
||||
# 请求级事务由中间件统一 commit/rollback;避免后台任务在请求中途误提交。
|
||||
if session is None:
|
||||
return
|
||||
if session.info.get("managed_by_middleware"):
|
||||
return
|
||||
self._pending_sessions.add(session)
|
||||
|
||||
async def _batch_commit_loop(self):
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
"""
|
||||
统一的请求上下文
|
||||
|
||||
RequestContext 贯穿整个请求生命周期,包含所有请求相关信息。
|
||||
这确保了数据在各层之间传递时不会丢失。
|
||||
|
||||
使用方式:
|
||||
1. Pipeline 层创建 RequestContext
|
||||
2. 各层通过 context 访问和更新信息
|
||||
3. Adapter 层使用 context 记录 Usage
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestContext:
|
||||
"""
|
||||
请求上下文 - 贯穿整个请求生命周期
|
||||
|
||||
设计原则:
|
||||
1. 在请求开始时创建,包含所有已知信息
|
||||
2. 在请求执行过程中逐步填充 Provider 信息
|
||||
3. 在请求结束时用于记录 Usage
|
||||
"""
|
||||
|
||||
# ==================== 请求标识 ====================
|
||||
request_id: str
|
||||
|
||||
# ==================== 认证信息 ====================
|
||||
user: Any # User model
|
||||
api_key: Any # ApiKey model
|
||||
db: Any # Database session
|
||||
|
||||
# ==================== 请求信息 ====================
|
||||
api_format: str # CLAUDE, OPENAI, GEMINI, etc.
|
||||
model: str # 用户请求的模型名
|
||||
is_stream: bool = False
|
||||
|
||||
# ==================== 原始请求 ====================
|
||||
original_headers: Dict[str, str] = field(default_factory=dict)
|
||||
original_body: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# ==================== 客户端信息 ====================
|
||||
client_ip: str = "unknown"
|
||||
user_agent: str = ""
|
||||
|
||||
# ==================== 计时 ====================
|
||||
start_time: float = field(default_factory=time.time)
|
||||
|
||||
# ==================== Provider 信息(请求执行后填充)====================
|
||||
provider_name: Optional[str] = None
|
||||
provider_id: Optional[str] = None
|
||||
endpoint_id: Optional[str] = None
|
||||
provider_api_key_id: Optional[str] = None
|
||||
|
||||
# ==================== 模型映射信息 ====================
|
||||
resolved_model: Optional[str] = None # 映射后的模型名
|
||||
original_model: Optional[str] = None # 原始模型名(用于价格计算)
|
||||
|
||||
# ==================== 请求/响应头 ====================
|
||||
provider_request_headers: Dict[str, str] = field(default_factory=dict)
|
||||
provider_response_headers: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# ==================== 追踪信息 ====================
|
||||
attempt_id: Optional[str] = None
|
||||
|
||||
# ==================== 能力需求 ====================
|
||||
capability_requirements: Dict[str, bool] = field(default_factory=dict)
|
||||
# 运行时计算的能力需求,来源于:
|
||||
# 1. 用户 model_capability_settings
|
||||
# 2. 用户 ApiKey.force_capabilities
|
||||
# 3. 请求头 X-Require-Capability
|
||||
# 4. 失败重试时动态添加
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
db: Any,
|
||||
user: Any,
|
||||
api_key: Any,
|
||||
api_format: str,
|
||||
model: str,
|
||||
is_stream: bool = False,
|
||||
original_headers: Optional[Dict[str, str]] = None,
|
||||
original_body: Optional[Dict[str, Any]] = None,
|
||||
client_ip: str = "unknown",
|
||||
user_agent: str = "",
|
||||
request_id: Optional[str] = None,
|
||||
) -> "RequestContext":
|
||||
"""创建请求上下文"""
|
||||
return cls(
|
||||
request_id=request_id or str(uuid.uuid4()),
|
||||
db=db,
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
api_format=api_format,
|
||||
model=model,
|
||||
is_stream=is_stream,
|
||||
original_headers=original_headers or {},
|
||||
original_body=original_body or {},
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
original_model=model, # 初始时原始模型等于请求模型
|
||||
)
|
||||
|
||||
def update_provider_info(
|
||||
self,
|
||||
*,
|
||||
provider_name: str,
|
||||
provider_id: str,
|
||||
endpoint_id: str,
|
||||
provider_api_key_id: str,
|
||||
resolved_model: Optional[str] = None,
|
||||
) -> None:
|
||||
"""更新 Provider 信息(请求执行后调用)"""
|
||||
self.provider_name = provider_name
|
||||
self.provider_id = provider_id
|
||||
self.endpoint_id = endpoint_id
|
||||
self.provider_api_key_id = provider_api_key_id
|
||||
if resolved_model:
|
||||
self.resolved_model = resolved_model
|
||||
|
||||
def update_headers(
|
||||
self,
|
||||
*,
|
||||
request_headers: Optional[Dict[str, str]] = None,
|
||||
response_headers: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""更新请求/响应头"""
|
||||
if request_headers:
|
||||
self.provider_request_headers = request_headers
|
||||
if response_headers:
|
||||
self.provider_response_headers = response_headers
|
||||
|
||||
@property
|
||||
def elapsed_ms(self) -> int:
|
||||
"""计算已经过的时间(毫秒)"""
|
||||
return int((time.time() - self.start_time) * 1000)
|
||||
|
||||
@property
|
||||
def effective_model(self) -> str:
|
||||
"""获取有效的模型名(映射后优先)"""
|
||||
return self.resolved_model or self.model
|
||||
|
||||
@property
|
||||
def billing_model(self) -> str:
|
||||
"""获取计费模型名(原始模型优先)"""
|
||||
return self.original_model or self.model
|
||||
|
||||
def to_metadata_dict(self) -> Dict[str, Any]:
|
||||
"""转换为元数据字典(用于 Usage 记录)"""
|
||||
return {
|
||||
"api_format": self.api_format,
|
||||
"provider": self.provider_name or "unknown",
|
||||
"model": self.effective_model,
|
||||
"original_model": self.billing_model,
|
||||
"provider_id": self.provider_id,
|
||||
"provider_endpoint_id": self.endpoint_id,
|
||||
"provider_api_key_id": self.provider_api_key_id,
|
||||
"provider_request_headers": self.provider_request_headers,
|
||||
"provider_response_headers": self.provider_response_headers,
|
||||
"attempt_id": self.attempt_id,
|
||||
}
|
||||
@@ -10,8 +10,8 @@ class APIFormat(Enum):
|
||||
"""API格式枚举 - 决定请求/响应的处理方式"""
|
||||
|
||||
CLAUDE = "CLAUDE" # Claude API 格式
|
||||
OPENAI = "OPENAI" # OpenAI API 格式
|
||||
CLAUDE_CLI = "CLAUDE_CLI" # Claude CLI API 格式(使用 authorization: Bearer)
|
||||
OPENAI = "OPENAI" # OpenAI API 格式
|
||||
OPENAI_CLI = "OPENAI_CLI" # OpenAI CLI/Responses API 格式(用于 Claude Code 等客户端)
|
||||
GEMINI = "GEMINI" # Google Gemini API 格式
|
||||
GEMINI_CLI = "GEMINI_CLI" # Gemini CLI API 格式
|
||||
|
||||
@@ -188,12 +188,16 @@ class ProviderNotAvailableException(ProviderException):
|
||||
message: str,
|
||||
provider_name: Optional[str] = None,
|
||||
request_metadata: Optional[Any] = None,
|
||||
upstream_status: Optional[int] = None,
|
||||
upstream_response: Optional[str] = None,
|
||||
):
|
||||
super().__init__(
|
||||
message=message,
|
||||
provider_name=provider_name,
|
||||
request_metadata=request_metadata,
|
||||
)
|
||||
self.upstream_status = upstream_status
|
||||
self.upstream_response = upstream_response
|
||||
|
||||
|
||||
class ProviderTimeoutException(ProviderException):
|
||||
@@ -442,6 +446,36 @@ class EmbeddedErrorException(ProviderException):
|
||||
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):
|
||||
"""上游返回的客户端错误异常 - HTTP 4xx 错误,不应该重试
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
输出策略:
|
||||
- 控制台: 开发环境=DEBUG, 生产环境=INFO (通过 LOG_LEVEL 控制)
|
||||
- 文件: 始终保存 DEBUG 级别,保留30天,每日轮转
|
||||
- 文件: 始终保存 DEBUG 级别,保留30天,按大小轮转 (100MB)
|
||||
|
||||
使用方式:
|
||||
from src.core.logger import logger
|
||||
@@ -72,12 +72,15 @@ def _log_filter(record: dict) -> bool: # type: ignore[type-arg]
|
||||
|
||||
|
||||
if IS_DOCKER:
|
||||
# 生产环境:禁用 backtrace 和 diagnose,减少日志噪音
|
||||
logger.add(
|
||||
sys.stdout,
|
||||
format=CONSOLE_FORMAT_PROD,
|
||||
level=LOG_LEVEL,
|
||||
filter=_log_filter, # type: ignore[arg-type]
|
||||
colorize=False,
|
||||
backtrace=False,
|
||||
diagnose=False,
|
||||
)
|
||||
else:
|
||||
logger.add(
|
||||
@@ -92,30 +95,37 @@ if not DISABLE_FILE_LOG:
|
||||
log_dir = PROJECT_ROOT / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 文件日志通用配置
|
||||
file_log_config = {
|
||||
"format": FILE_FORMAT,
|
||||
"filter": _log_filter,
|
||||
"rotation": "100 MB",
|
||||
"retention": "30 days",
|
||||
"compression": "gz",
|
||||
"enqueue": True,
|
||||
"encoding": "utf-8",
|
||||
"catch": True,
|
||||
}
|
||||
|
||||
# 生产环境禁用详细堆栈
|
||||
if IS_DOCKER:
|
||||
file_log_config["backtrace"] = False
|
||||
file_log_config["diagnose"] = False
|
||||
|
||||
# 主日志文件 - 所有级别
|
||||
logger.add(
|
||||
log_dir / "app.log",
|
||||
format=FILE_FORMAT,
|
||||
level="DEBUG",
|
||||
filter=_log_filter, # type: ignore[arg-type]
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
compression="gz",
|
||||
enqueue=True,
|
||||
encoding="utf-8",
|
||||
**file_log_config, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# 错误日志文件 - 仅 ERROR 及以上
|
||||
error_log_config = file_log_config.copy()
|
||||
error_log_config["rotation"] = "50 MB"
|
||||
logger.add(
|
||||
log_dir / "error.log",
|
||||
format=FILE_FORMAT,
|
||||
level="ERROR",
|
||||
filter=_log_filter, # type: ignore[arg-type]
|
||||
rotation="00:00",
|
||||
retention="30 days",
|
||||
compression="gz",
|
||||
enqueue=True,
|
||||
encoding="utf-8",
|
||||
**error_log_config, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -50,7 +50,7 @@ model_mapping_resolution_total = Counter(
|
||||
"model_mapping_resolution_total",
|
||||
"Total number of model mapping resolutions",
|
||||
["method", "cache_hit"],
|
||||
# method: direct_match, provider_model_name, alias, not_found
|
||||
# method: direct_match, provider_model_name, mapping, not_found
|
||||
# cache_hit: true, false
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import time
|
||||
from typing import AsyncGenerator, Generator, Optional
|
||||
|
||||
from starlette.requests import Request
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
@@ -150,9 +151,22 @@ def _log_pool_capacity():
|
||||
theoretical = config.db_pool_size + config.db_max_overflow
|
||||
workers = max(1, config.worker_processes)
|
||||
total_estimated = theoretical * workers
|
||||
logger.info("数据库连接池配置")
|
||||
if total_estimated > config.db_pool_warn_threshold:
|
||||
logger.warning("数据库连接需求可能超过阈值,请调小池大小或减少 worker 数")
|
||||
safe_limit = config.pg_max_connections - config.pg_reserved_connections
|
||||
logger.info(
|
||||
"数据库连接池配置: pool_size={}, max_overflow={}, workers={}, total_estimated={}, safe_limit={}",
|
||||
config.db_pool_size,
|
||||
config.db_max_overflow,
|
||||
workers,
|
||||
total_estimated,
|
||||
safe_limit,
|
||||
)
|
||||
if total_estimated > safe_limit:
|
||||
logger.warning(
|
||||
"数据库连接池总需求可能超过 PostgreSQL 限制: {} > {} (pg_max_connections - reserved),"
|
||||
"建议调整 DB_POOL_SIZE/DB_MAX_OVERFLOW 或减少 worker 数",
|
||||
total_estimated,
|
||||
safe_limit,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_async_engine() -> AsyncEngine:
|
||||
@@ -185,7 +199,7 @@ def _ensure_async_engine() -> AsyncEngine:
|
||||
# 创建异步引擎
|
||||
_async_engine = create_async_engine(
|
||||
ASYNC_DATABASE_URL,
|
||||
poolclass=QueuePool, # 使用队列连接池
|
||||
# AsyncEngine 不能使用 QueuePool;默认使用 AsyncAdaptedQueuePool
|
||||
pool_size=config.db_pool_size,
|
||||
max_overflow=config.db_max_overflow,
|
||||
pool_timeout=config.db_pool_timeout,
|
||||
@@ -209,7 +223,18 @@ def _ensure_async_engine() -> AsyncEngine:
|
||||
|
||||
|
||||
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""获取异步数据库会话"""
|
||||
"""获取异步数据库会话
|
||||
|
||||
.. deprecated::
|
||||
此方法已废弃,项目统一使用同步 Session。
|
||||
未来版本可能移除此方法。请使用 get_db() 代替。
|
||||
"""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"get_async_db() 已废弃,项目统一使用同步 Session。请使用 get_db() 代替。",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# 确保异步引擎已初始化
|
||||
_ensure_async_engine()
|
||||
|
||||
@@ -220,16 +245,73 @@ async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
await session.close()
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
def get_db(request: Request = None) -> Generator[Session, None, None]: # type: ignore[assignment]
|
||||
"""获取数据库会话
|
||||
|
||||
注意:事务管理由业务逻辑层显式控制(手动调用 commit/rollback)
|
||||
这里只负责会话的创建和关闭,不自动提交
|
||||
事务策略说明
|
||||
============
|
||||
本项目采用**混合事务管理**策略:
|
||||
|
||||
1. **LLM 请求路径**:
|
||||
- 由 PluginMiddleware 统一管理事务
|
||||
- Service 层使用 db.flush() 使更改可见,但不提交
|
||||
- 请求结束时由中间件统一 commit 或 rollback
|
||||
- 例外:UsageService.record_usage() 会显式 commit,因为使用记录需要立即持久化
|
||||
|
||||
2. **管理后台 API**:
|
||||
- 路由层显式调用 db.commit()
|
||||
- 提交后设置 request.state.tx_committed_by_route = True
|
||||
- 中间件看到此标志后跳过 commit,只负责 close
|
||||
|
||||
3. **后台任务/调度器**:
|
||||
- 使用独立 Session(通过 create_session() 或 next(get_db()))
|
||||
- 自行管理事务生命周期
|
||||
|
||||
使用方式
|
||||
========
|
||||
- FastAPI 请求:通过 Depends(get_db) 注入,支持中间件管理的 session 复用
|
||||
- 非请求上下文:直接调用 get_db(),退化为独立 session 模式
|
||||
|
||||
路由层提交事务示例
|
||||
==================
|
||||
```python
|
||||
@router.post("/example")
|
||||
async def example(request: Request, db: Session = Depends(get_db)):
|
||||
# ... 业务逻辑 ...
|
||||
db.commit()
|
||||
request.state.tx_committed_by_route = True # 告知中间件已提交
|
||||
return {"message": "success"}
|
||||
```
|
||||
|
||||
注意事项
|
||||
========
|
||||
- 本函数不自动提交事务
|
||||
- 异常时会自动回滚
|
||||
- 中间件管理模式下,session 关闭由中间件负责
|
||||
"""
|
||||
# FastAPI 请求上下文:优先复用中间件绑定的 request.state.db
|
||||
if request is not None:
|
||||
existing_db = getattr(getattr(request, "state", None), "db", None)
|
||||
if isinstance(existing_db, Session):
|
||||
yield existing_db
|
||||
return
|
||||
|
||||
# 确保引擎已初始化
|
||||
_ensure_engine()
|
||||
|
||||
db = _SessionLocal()
|
||||
|
||||
# 如果中间件声明会统一管理会话生命周期,则把 session 绑定到 request.state,
|
||||
# 并由中间件负责 commit/rollback/close(这里不关闭,避免流式响应提前释放会话)。
|
||||
managed_by_middleware = bool(
|
||||
request is not None
|
||||
and hasattr(request, "state")
|
||||
and getattr(request.state, "db_managed_by_middleware", False)
|
||||
)
|
||||
if managed_by_middleware:
|
||||
request.state.db = db
|
||||
db.info["managed_by_middleware"] = True
|
||||
|
||||
try:
|
||||
yield db
|
||||
# 不再自动 commit,由业务代码显式管理事务
|
||||
@@ -241,12 +323,13 @@ def get_db() -> Generator[Session, None, None]:
|
||||
logger.debug(f"回滚事务时出错(可忽略): {rollback_error}")
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
db.close() # 确保连接返回池
|
||||
except Exception as close_error:
|
||||
# 记录关闭错误(如 IllegalStateChangeError)
|
||||
# 连接池会处理连接的回收
|
||||
logger.debug(f"关闭数据库连接时出错(可忽略): {close_error}")
|
||||
if not managed_by_middleware:
|
||||
try:
|
||||
db.close() # 确保连接返回池
|
||||
except Exception as close_error:
|
||||
# 记录关闭错误(如 IllegalStateChangeError)
|
||||
# 连接池会处理连接的回收
|
||||
logger.debug(f"关闭数据库连接时出错(可忽略): {close_error}")
|
||||
|
||||
|
||||
def create_session() -> Session:
|
||||
@@ -336,7 +419,7 @@ def init_admin_user(db: Session):
|
||||
admin.set_password(config.admin_password)
|
||||
|
||||
db.add(admin)
|
||||
db.commit() # 刷新以获取ID,但不提交
|
||||
db.flush() # 分配ID,但不提交事务(由外层 init_db 统一 commit)
|
||||
|
||||
logger.info(f"创建管理员账户成功: {admin.email} ({admin.username})")
|
||||
except Exception as e:
|
||||
|
||||
45
src/main.py
45
src/main.py
@@ -3,15 +3,11 @@
|
||||
采用模块化架构设计
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi import FastAPI, HTTPException
|
||||
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.announcements import router as announcement_router
|
||||
@@ -39,20 +35,18 @@ async def initialize_providers():
|
||||
"""从数据库初始化提供商(仅用于日志记录)"""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.enums import APIFormat
|
||||
from src.database import get_db
|
||||
from src.database.database import create_session
|
||||
from src.models.database import Provider
|
||||
|
||||
try:
|
||||
# 创建数据库会话
|
||||
db_gen = get_db()
|
||||
db: Session = next(db_gen)
|
||||
db: Session = create_session()
|
||||
|
||||
try:
|
||||
# 从数据库加载所有活跃的提供商
|
||||
providers = (
|
||||
db.query(Provider)
|
||||
.filter(Provider.is_active == True)
|
||||
.filter(Provider.is_active.is_(True))
|
||||
.order_by(Provider.provider_priority.asc())
|
||||
.all()
|
||||
)
|
||||
@@ -75,7 +69,7 @@ async def initialize_providers():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("从数据库初始化提供商失败")
|
||||
|
||||
|
||||
@@ -125,6 +119,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("初始化全局Redis客户端...")
|
||||
from src.clients.redis_client import get_redis_client
|
||||
|
||||
redis_client = None
|
||||
try:
|
||||
redis_client = await get_redis_client(require_redis=config.require_redis)
|
||||
if redis_client:
|
||||
@@ -136,6 +131,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.exception("[ERROR] Redis连接失败,应用启动中止")
|
||||
raise
|
||||
logger.warning(f"Redis连接失败,但配置允许降级,将继续使用内存模式: {e}")
|
||||
redis_client = None
|
||||
|
||||
# 初始化并发管理器(内部会使用Redis)
|
||||
logger.info("初始化并发管理器...")
|
||||
@@ -300,33 +296,6 @@ app.include_router(dashboard_router) # 仪表盘端点
|
||||
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
|
||||
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.startswith("api/") or full_path.startswith("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():
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
"""
|
||||
统一的插件中间件
|
||||
统一的插件中间件(纯 ASGI 实现)
|
||||
负责协调所有插件的调用
|
||||
|
||||
注意:使用纯 ASGI middleware 而非 BaseHTTPMiddleware,
|
||||
以避免 Starlette 已知的流式响应兼容性问题。
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.requests import Request
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
from src.config import config
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.plugins.manager import get_plugin_manager
|
||||
from src.plugins.rate_limit.base import RateLimitResult
|
||||
|
||||
|
||||
|
||||
class PluginMiddleware(BaseHTTPMiddleware):
|
||||
class PluginMiddleware:
|
||||
"""
|
||||
统一的插件调用中间件
|
||||
统一的插件调用中间件(纯 ASGI 实现)
|
||||
|
||||
职责:
|
||||
- 性能监控
|
||||
- 限流控制 (可选)
|
||||
- 数据库会话生命周期管理
|
||||
|
||||
注意: 认证由各路由通过 Depends() 显式声明,不在中间件层处理
|
||||
|
||||
为什么使用纯 ASGI 而非 BaseHTTPMiddleware:
|
||||
- BaseHTTPMiddleware 会缓冲整个响应体,对流式响应不友好
|
||||
- BaseHTTPMiddleware 与 StreamingResponse 存在已知兼容性问题
|
||||
- 纯 ASGI 可以直接透传流式响应,无额外开销
|
||||
"""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
super().__init__(app)
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
self.plugin_manager = get_plugin_manager()
|
||||
|
||||
# 从配置读取速率限制值
|
||||
@@ -62,175 +67,159 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
"/v1/completions",
|
||||
]
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[[Request], Awaitable[StarletteResponse]]
|
||||
) -> StarletteResponse:
|
||||
"""处理请求并调用相应插件"""
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""ASGI 入口点"""
|
||||
if scope["type"] != "http":
|
||||
# 非 HTTP 请求(如 WebSocket)直接透传
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# 构建 Request 对象以便复用现有逻辑
|
||||
request = Request(scope, receive, send)
|
||||
|
||||
# 记录请求开始时间
|
||||
start_time = time.time()
|
||||
|
||||
# 设置 request.state 属性
|
||||
# 注意:Starlette 的 Request 对象总是有 state 属性(State 实例)
|
||||
request.state.request_id = request.headers.get("x-request-id", "")
|
||||
request.state.start_time = start_time
|
||||
# 标记:若请求过程中通过 Depends(get_db) 创建了会话,则由本中间件统一管理其生命周期
|
||||
request.state.db_managed_by_middleware = True
|
||||
|
||||
# 从 request.app 获取 FastAPI 应用实例(而不是从 __init__ 的 app 参数)
|
||||
# 这样才能访问到真正的 FastAPI 实例和其 dependency_overrides
|
||||
db_func = get_db
|
||||
if hasattr(request, "app") and hasattr(request.app, "dependency_overrides"):
|
||||
if get_db in request.app.dependency_overrides:
|
||||
db_func = request.app.dependency_overrides[get_db]
|
||||
logger.debug("Using overridden get_db from app.dependency_overrides")
|
||||
# 1. 限流检查(在调用下游之前)
|
||||
rate_limit_result = await self._call_rate_limit_plugins(request)
|
||||
if rate_limit_result and not rate_limit_result.allowed:
|
||||
# 限流触发,返回429
|
||||
await self._send_rate_limit_response(send, rate_limit_result)
|
||||
return
|
||||
|
||||
# 创建数据库会话供需要的插件或后续处理使用
|
||||
db_gen = db_func()
|
||||
db = None
|
||||
response = None
|
||||
exception_to_raise = None
|
||||
# 2. 预处理插件调用
|
||||
await self._call_pre_request_plugins(request)
|
||||
|
||||
# 用于捕获响应状态码
|
||||
response_status_code: int = 0
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
nonlocal response_status_code
|
||||
|
||||
if message["type"] == "http.response.start":
|
||||
response_status_code = message.get("status", 0)
|
||||
|
||||
await send(message)
|
||||
|
||||
# 3. 调用下游应用
|
||||
exception_occurred: Optional[Exception] = None
|
||||
try:
|
||||
# 获取数据库会话
|
||||
db = next(db_gen)
|
||||
request.state.db = db
|
||||
|
||||
# 1. 限流插件调用(可选功能)
|
||||
rate_limit_result = await self._call_rate_limit_plugins(request)
|
||||
if rate_limit_result and not rate_limit_result.allowed:
|
||||
# 限流触发,返回429
|
||||
headers = rate_limit_result.headers or {}
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=rate_limit_result.message or "Rate limit exceeded",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# 2. 预处理插件调用
|
||||
await self._call_pre_request_plugins(request)
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 3. 提交关键数据库事务(在返回响应前)
|
||||
# 这确保了 Usage 记录、配额扣减等关键数据在响应返回前持久化
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as commit_error:
|
||||
logger.error(f"关键事务提交失败: {commit_error}")
|
||||
db.rollback()
|
||||
# 返回 500 错误,因为数据可能不一致
|
||||
response = JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "database_error",
|
||||
"message": "数据保存失败,请重试",
|
||||
},
|
||||
},
|
||||
)
|
||||
# 跳过后处理插件,直接返回错误响应
|
||||
return response
|
||||
|
||||
# 4. 后处理插件调用(监控等,非关键操作)
|
||||
# 这些操作失败不应影响用户响应
|
||||
await self._call_post_request_plugins(request, response, start_time)
|
||||
|
||||
# 注意:不在此处添加限流响应头,因为在BaseHTTPMiddleware中
|
||||
# 响应返回后修改headers会导致Content-Length不匹配错误
|
||||
# 限流响应头已在返回429错误时正确包含(见上面的HTTPException)
|
||||
|
||||
except RuntimeError as e:
|
||||
if str(e) == "No response returned.":
|
||||
if db:
|
||||
db.rollback()
|
||||
|
||||
logger.error("Downstream handler completed without returning a response")
|
||||
|
||||
await self._call_error_plugins(request, e, start_time)
|
||||
|
||||
if db:
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "internal_error",
|
||||
"message": "Internal server error: downstream handler returned no response.",
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
exception_to_raise = e
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
except Exception as e:
|
||||
# 回滚数据库事务
|
||||
if db:
|
||||
db.rollback()
|
||||
|
||||
exception_occurred = e
|
||||
# 错误处理插件调用
|
||||
await self._call_error_plugins(request, e, start_time)
|
||||
raise
|
||||
finally:
|
||||
# 4. 数据库会话清理(无论成功与否)
|
||||
await self._cleanup_db_session(request, exception_occurred)
|
||||
|
||||
# 尝试提交错误日志
|
||||
if db:
|
||||
# 5. 后处理插件调用(仅在成功时)
|
||||
if not exception_occurred and response_status_code > 0:
|
||||
await self._call_post_request_plugins(request, response_status_code, start_time)
|
||||
|
||||
async def _send_rate_limit_response(
|
||||
self, send: Send, result: RateLimitResult
|
||||
) -> None:
|
||||
"""发送 429 限流响应"""
|
||||
import json
|
||||
|
||||
body = json.dumps({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "rate_limit_error",
|
||||
"message": result.message or "Rate limit exceeded",
|
||||
},
|
||||
}).encode("utf-8")
|
||||
|
||||
headers = [(b"content-type", b"application/json")]
|
||||
if result.headers:
|
||||
for key, value in result.headers.items():
|
||||
headers.append((key.lower().encode(), str(value).encode()))
|
||||
|
||||
await send({
|
||||
"type": "http.response.start",
|
||||
"status": 429,
|
||||
"headers": headers,
|
||||
})
|
||||
await send({
|
||||
"type": "http.response.body",
|
||||
"body": body,
|
||||
})
|
||||
|
||||
async def _cleanup_db_session(
|
||||
self, request: Request, exception: Optional[Exception]
|
||||
) -> None:
|
||||
"""清理数据库会话
|
||||
|
||||
事务策略:
|
||||
- 如果 request.state.tx_committed_by_route 为 True,说明路由已自行提交,中间件只负责 close
|
||||
- 否则由中间件统一 commit/rollback
|
||||
|
||||
这避免了双重提交的问题,同时保持向后兼容。
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
db = getattr(request.state, "db", None)
|
||||
if not isinstance(db, Session):
|
||||
return
|
||||
|
||||
# 检查是否由路由层已经提交
|
||||
tx_committed_by_route = getattr(request.state, "tx_committed_by_route", False)
|
||||
|
||||
try:
|
||||
if exception is not None:
|
||||
# 发生异常,回滚事务(无论谁负责提交)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.debug(f"回滚事务时出错(可忽略): {rollback_error}")
|
||||
elif not tx_committed_by_route:
|
||||
# 正常完成且路由未自行提交,由中间件提交事务
|
||||
try:
|
||||
db.commit()
|
||||
except:
|
||||
pass
|
||||
|
||||
exception_to_raise = e
|
||||
|
||||
finally:
|
||||
# 确保数据库会话被正确关闭
|
||||
# 注意:需要安全地处理各种状态,避免 IllegalStateChangeError
|
||||
if db is not None:
|
||||
try:
|
||||
# 检查会话是否可以安全地进行回滚
|
||||
# 只有当没有进行中的事务操作时才尝试回滚
|
||||
if db.is_active and not db.get_transaction().is_active:
|
||||
# 事务不在活跃状态,可以安全回滚
|
||||
except Exception as commit_error:
|
||||
logger.error(f"关键事务提交失败: {commit_error}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
elif db.is_active:
|
||||
# 事务在活跃状态,尝试回滚
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception as rollback_error:
|
||||
# 回滚失败(可能是 commit 正在进行中),忽略错误
|
||||
logger.debug(f"Rollback skipped: {rollback_error}")
|
||||
except Exception:
|
||||
# 检查状态时出错,忽略
|
||||
pass
|
||||
|
||||
# 通过触发生成器的 finally 块来关闭会话(标准模式)
|
||||
# 这会调用 get_db() 的 finally 块,执行 db.close()
|
||||
# 如果 tx_committed_by_route 为 True,跳过 commit(路由已提交)
|
||||
finally:
|
||||
# 关闭会话,归还连接到连接池
|
||||
try:
|
||||
next(db_gen, None)
|
||||
except StopIteration:
|
||||
# 正常情况:生成器已耗尽
|
||||
pass
|
||||
except Exception as cleanup_error:
|
||||
# 忽略 IllegalStateChangeError 等清理错误
|
||||
# 这些错误通常是由于事务状态不一致导致的,不影响业务逻辑
|
||||
if "IllegalStateChangeError" not in str(type(cleanup_error).__name__):
|
||||
logger.warning(f"Database cleanup warning: {cleanup_error}")
|
||||
|
||||
# 在 finally 块之后处理异常和响应
|
||||
if exception_to_raise:
|
||||
raise exception_to_raise
|
||||
|
||||
return response
|
||||
db.close()
|
||||
except Exception as close_error:
|
||||
logger.debug(f"关闭数据库连接时出错(可忽略): {close_error}")
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""
|
||||
获取客户端 IP 地址,支持代理头
|
||||
|
||||
注意:此方法信任 X-Forwarded-For 和 X-Real-IP 头,
|
||||
仅当服务部署在可信代理(如 Nginx、CloudFlare)后面时才安全。
|
||||
如果服务直接暴露公网,攻击者可伪造这些头绕过限流。
|
||||
"""
|
||||
# 从配置获取可信代理层数(默认为 1,即信任最近一层代理)
|
||||
trusted_proxy_count = getattr(config, "trusted_proxy_count", 1)
|
||||
|
||||
# 优先从代理头获取真实 IP
|
||||
forwarded_for = request.headers.get("x-forwarded-for")
|
||||
if forwarded_for:
|
||||
# X-Forwarded-For 可能包含多个 IP,取第一个
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
# X-Forwarded-For 格式: "client, proxy1, proxy2"
|
||||
# 从右往左数 trusted_proxy_count 个,取其左边的第一个
|
||||
ips = [ip.strip() for ip in forwarded_for.split(",")]
|
||||
if len(ips) > trusted_proxy_count:
|
||||
return ips[-(trusted_proxy_count + 1)]
|
||||
elif ips:
|
||||
return ips[0]
|
||||
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
@@ -250,7 +239,7 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
return False
|
||||
|
||||
async def _get_rate_limit_key_and_config(
|
||||
self, request: Request, db: Session
|
||||
self, request: Request
|
||||
) -> tuple[Optional[str], Optional[int]]:
|
||||
"""
|
||||
获取速率限制的key和配置
|
||||
@@ -272,13 +261,11 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
api_key = request.headers.get("x-api-key", "")
|
||||
|
||||
if auth_header.startswith("Bearer "):
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
api_key = auth_header[7:]
|
||||
|
||||
if api_key:
|
||||
# 使用 API Key 的哈希作为限制 key(避免日志泄露完整 key)
|
||||
import hashlib
|
||||
|
||||
key_hash = hashlib.sha256(api_key.encode()).hexdigest()[:16]
|
||||
key = f"llm_api_key:{key_hash}"
|
||||
request.state.rate_limit_key_type = "api_key"
|
||||
@@ -318,14 +305,8 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
# 如果没有限流插件,允许通过
|
||||
return None
|
||||
|
||||
# 获取数据库会话
|
||||
db = getattr(request.state, "db", None)
|
||||
if not db:
|
||||
logger.warning("速率限制检查:无法获取数据库会话")
|
||||
return None
|
||||
|
||||
# 获取速率限制的key和配置(从数据库)
|
||||
key, rate_limit_value = await self._get_rate_limit_key_and_config(request, db)
|
||||
# 获取速率限制的 key 和配置
|
||||
key, rate_limit_value = await self._get_rate_limit_key_and_config(request)
|
||||
if not key:
|
||||
# 不需要限流的端点(如未分类路径),静默跳过
|
||||
return None
|
||||
@@ -336,7 +317,7 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
key=key,
|
||||
endpoint=request.url.path,
|
||||
method=request.method,
|
||||
rate_limit=rate_limit_value, # 传入数据库配置的限制值
|
||||
rate_limit=rate_limit_value, # 传入配置的限制值
|
||||
)
|
||||
# 类型检查:确保返回的是RateLimitResult类型
|
||||
if isinstance(result, RateLimitResult):
|
||||
@@ -349,7 +330,10 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
else:
|
||||
# 限流触发,记录日志
|
||||
logger.warning(f"速率限制触发: {getattr(request.state, 'rate_limit_key_type', 'unknown')}")
|
||||
logger.warning(
|
||||
"速率限制触发: {}",
|
||||
getattr(request.state, "rate_limit_key_type", "unknown"),
|
||||
)
|
||||
return result
|
||||
return None
|
||||
except Exception as e:
|
||||
@@ -362,7 +346,7 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
pass
|
||||
|
||||
async def _call_post_request_plugins(
|
||||
self, request: Request, response: StarletteResponse, start_time: float
|
||||
self, request: Request, status_code: int, start_time: float
|
||||
) -> None:
|
||||
"""调用请求后的插件"""
|
||||
|
||||
@@ -375,8 +359,8 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
monitor_labels = {
|
||||
"method": request.method,
|
||||
"endpoint": request.url.path,
|
||||
"status": str(response.status_code),
|
||||
"status_class": f"{response.status_code // 100}xx",
|
||||
"status": str(status_code),
|
||||
"status_class": f"{status_code // 100}xx",
|
||||
}
|
||||
|
||||
# 记录请求计数
|
||||
@@ -398,6 +382,7 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
self, request: Request, error: Exception, start_time: float
|
||||
) -> None:
|
||||
"""调用错误处理插件"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
@@ -410,7 +395,7 @@ class PluginMiddleware(BaseHTTPMiddleware):
|
||||
error=error,
|
||||
context={
|
||||
"endpoint": f"{request.method} {request.url.path}",
|
||||
"request_id": request.state.request_id,
|
||||
"request_id": getattr(request.state, "request_id", ""),
|
||||
"duration": duration,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,6 +13,42 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from src.core.enums import APIFormat, ProviderBillingType
|
||||
|
||||
|
||||
class ProxyConfig(BaseModel):
|
||||
"""代理配置"""
|
||||
|
||||
url: str = Field(..., description="代理 URL (http://, https://, socks5://)")
|
||||
username: Optional[str] = Field(None, max_length=255, description="代理用户名")
|
||||
password: Optional[str] = Field(None, max_length=500, description="代理密码")
|
||||
enabled: bool = Field(True, description="是否启用代理(false 时保留配置但不使用)")
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_proxy_url(cls, v: str) -> str:
|
||||
"""验证代理 URL 格式"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
v = v.strip()
|
||||
|
||||
# 检查禁止的字符(防止注入)
|
||||
if "\n" in v or "\r" in v:
|
||||
raise ValueError("代理 URL 包含非法字符")
|
||||
|
||||
# 验证协议(不支持 SOCKS4)
|
||||
if not re.match(r"^(http|https|socks5)://", v, re.IGNORECASE):
|
||||
raise ValueError("代理 URL 必须以 http://, https:// 或 socks5:// 开头")
|
||||
|
||||
# 验证 URL 结构
|
||||
parsed = urlparse(v)
|
||||
if not parsed.netloc:
|
||||
raise ValueError("代理 URL 必须包含有效的 host")
|
||||
|
||||
# 禁止 URL 中内嵌认证信息,强制使用独立字段
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("请勿在 URL 中包含用户名和密码,请使用独立的认证字段")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class CreateProviderRequest(BaseModel):
|
||||
"""创建 Provider 请求"""
|
||||
|
||||
@@ -107,20 +143,6 @@ class CreateProviderRequest(BaseModel):
|
||||
if not re.match(r"^https?://", v, re.IGNORECASE):
|
||||
v = f"https://{v}"
|
||||
|
||||
# 防止 SSRF 攻击:禁止内网地址
|
||||
forbidden_patterns = [
|
||||
r"localhost",
|
||||
r"127\.0\.0\.1",
|
||||
r"0\.0\.0\.0",
|
||||
r"192\.168\.",
|
||||
r"10\.",
|
||||
r"172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
||||
r"169\.254\.",
|
||||
]
|
||||
for pattern in forbidden_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
raise ValueError("不允许使用内网地址")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator("billing_type")
|
||||
@@ -179,6 +201,7 @@ class CreateEndpointRequest(BaseModel):
|
||||
rpm_limit: Optional[int] = Field(None, ge=0, description="RPM 限制")
|
||||
concurrent_limit: Optional[int] = Field(None, ge=0, description="并发限制")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
|
||||
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -195,19 +218,6 @@ class CreateEndpointRequest(BaseModel):
|
||||
if not re.match(r"^https?://", v, re.IGNORECASE):
|
||||
raise ValueError("URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
# 防止 SSRF
|
||||
forbidden_patterns = [
|
||||
r"localhost",
|
||||
r"127\.0\.0\.1",
|
||||
r"0\.0\.0\.0",
|
||||
r"192\.168\.",
|
||||
r"10\.",
|
||||
r"172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
||||
]
|
||||
for pattern in forbidden_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
raise ValueError("不允许使用内网地址")
|
||||
|
||||
return v.rstrip("/") # 移除末尾斜杠
|
||||
|
||||
@field_validator("api_format")
|
||||
@@ -247,6 +257,7 @@ class UpdateEndpointRequest(BaseModel):
|
||||
rpm_limit: Optional[int] = Field(None, ge=0)
|
||||
concurrent_limit: Optional[int] = Field(None, ge=0)
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
|
||||
|
||||
# 复用验证器
|
||||
_validate_name = field_validator("name")(CreateEndpointRequest.validate_name.__func__)
|
||||
|
||||
@@ -6,7 +6,7 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..core.enums import UserRole
|
||||
|
||||
@@ -336,8 +336,7 @@ class ProviderResponse(BaseModel):
|
||||
active_models_count: int = 0
|
||||
api_keys_count: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== 模型管理 ==========
|
||||
@@ -347,9 +346,9 @@ class ModelCreate(BaseModel):
|
||||
provider_model_name: str = Field(
|
||||
..., min_length=1, max_length=200, description="Provider 侧的主模型名称"
|
||||
)
|
||||
provider_model_aliases: Optional[List[dict]] = Field(
|
||||
provider_model_mappings: Optional[List[dict]] = Field(
|
||||
None,
|
||||
description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
|
||||
description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
|
||||
)
|
||||
global_model_id: str = Field(..., description="关联的 GlobalModel ID(必填)")
|
||||
# 按次计费配置 - 可选,为空时使用 GlobalModel 默认值
|
||||
@@ -377,9 +376,9 @@ class ModelUpdate(BaseModel):
|
||||
"""更新模型请求"""
|
||||
|
||||
provider_model_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
provider_model_aliases: Optional[List[dict]] = Field(
|
||||
provider_model_mappings: Optional[List[dict]] = Field(
|
||||
None,
|
||||
description="模型名称别名列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
|
||||
description="模型名称映射列表,格式: [{'name': 'alias1', 'priority': 1}, ...]",
|
||||
)
|
||||
global_model_id: Optional[str] = None
|
||||
# 按次计费配置
|
||||
@@ -405,7 +404,7 @@ class ModelResponse(BaseModel):
|
||||
provider_id: str
|
||||
global_model_id: Optional[str]
|
||||
provider_model_name: str
|
||||
provider_model_aliases: Optional[List[dict]] = None
|
||||
provider_model_mappings: Optional[List[dict]] = None
|
||||
|
||||
# 按次计费配置
|
||||
price_per_request: Optional[float] = None
|
||||
@@ -442,8 +441,7 @@ class ModelResponse(BaseModel):
|
||||
global_model_name: Optional[str] = None
|
||||
global_model_display_name: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ModelDetailResponse(BaseModel):
|
||||
@@ -469,8 +467,7 @@ class ModelDetailResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== 系统设置 ==========
|
||||
|
||||
@@ -5,7 +5,7 @@ Provider API Key相关的API模型
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ProviderAPIKeyBase(BaseModel):
|
||||
@@ -53,8 +53,7 @@ class ProviderAPIKeyResponse(ProviderAPIKeyBase):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderAPIKeyStats(BaseModel):
|
||||
|
||||
@@ -27,8 +27,7 @@ from sqlalchemy import (
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from ..config import config
|
||||
from ..core.enums import ProviderBillingType, UserRole
|
||||
@@ -539,6 +538,9 @@ class ProviderEndpoint(Base):
|
||||
# 额外配置
|
||||
config = Column(JSON, nullable=True) # 端点特定配置(不推荐使用,优先使用专用字段)
|
||||
|
||||
# 代理配置
|
||||
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password}
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
@@ -669,10 +671,10 @@ class Model(Base):
|
||||
|
||||
# Provider 映射配置
|
||||
provider_model_name = Column(String(200), nullable=False) # Provider 侧的主模型名称
|
||||
# 模型名称别名列表(带优先级),用于同一模型在 Provider 侧有多个名称变体的场景
|
||||
# 模型名称映射列表(带优先级),用于同一模型在 Provider 侧有多个名称变体的场景
|
||||
# 格式: [{"name": "Claude-Sonnet-4.5", "priority": 1}, {"name": "Claude-Sonnet-4-5", "priority": 2}]
|
||||
# 为空时只使用 provider_model_name
|
||||
provider_model_aliases = Column(JSON, nullable=True, default=None)
|
||||
provider_model_mappings = Column(JSON, nullable=True, default=None)
|
||||
|
||||
# 按次计费配置(每次请求的固定费用,美元)- 可为空,为空时使用 GlobalModel 的默认值
|
||||
price_per_request = Column(Float, nullable=True) # 每次请求固定费用
|
||||
@@ -818,25 +820,25 @@ class Model(Base):
|
||||
) -> str:
|
||||
"""按优先级选择要使用的 Provider 模型名称
|
||||
|
||||
如果配置了 provider_model_aliases,按优先级选择(数字越小越优先);
|
||||
相同优先级的别名通过哈希分散实现负载均衡(与 Key 调度策略一致);
|
||||
如果配置了 provider_model_mappings,按优先级选择(数字越小越优先);
|
||||
相同优先级的映射通过哈希分散实现负载均衡(与 Key 调度策略一致);
|
||||
否则返回 provider_model_name。
|
||||
|
||||
Args:
|
||||
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一别名
|
||||
api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的别名
|
||||
affinity_key: 用于哈希分散的亲和键(如用户 API Key 哈希),确保同一用户稳定选择同一映射
|
||||
api_format: 当前请求的 API 格式(如 CLAUDE、OPENAI 等),用于过滤适用的映射
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
if not self.provider_model_aliases:
|
||||
if not self.provider_model_mappings:
|
||||
return self.provider_model_name
|
||||
|
||||
raw_aliases = self.provider_model_aliases
|
||||
if not isinstance(raw_aliases, list) or len(raw_aliases) == 0:
|
||||
raw_mappings = self.provider_model_mappings
|
||||
if not isinstance(raw_mappings, list) or len(raw_mappings) == 0:
|
||||
return self.provider_model_name
|
||||
|
||||
aliases: list[dict] = []
|
||||
for raw in raw_aliases:
|
||||
mappings: list[dict] = []
|
||||
for raw in raw_mappings:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
name = raw.get("name")
|
||||
@@ -844,10 +846,10 @@ class Model(Base):
|
||||
continue
|
||||
|
||||
# 检查 api_formats 作用域(如果配置了且当前有 api_format)
|
||||
alias_api_formats = raw.get("api_formats")
|
||||
if api_format and alias_api_formats:
|
||||
mapping_api_formats = raw.get("api_formats")
|
||||
if api_format and mapping_api_formats:
|
||||
# 如果配置了作用域,只有匹配时才生效
|
||||
if isinstance(alias_api_formats, list) and api_format not in alias_api_formats:
|
||||
if isinstance(mapping_api_formats, list) and api_format not in mapping_api_formats:
|
||||
continue
|
||||
|
||||
raw_priority = raw.get("priority", 1)
|
||||
@@ -858,47 +860,47 @@ class Model(Base):
|
||||
if priority < 1:
|
||||
priority = 1
|
||||
|
||||
aliases.append({"name": name.strip(), "priority": priority})
|
||||
mappings.append({"name": name.strip(), "priority": priority})
|
||||
|
||||
if not aliases:
|
||||
if not mappings:
|
||||
return self.provider_model_name
|
||||
|
||||
# 按优先级排序(数字越小越优先)
|
||||
sorted_aliases = sorted(aliases, key=lambda x: x["priority"])
|
||||
sorted_mappings = sorted(mappings, key=lambda x: x["priority"])
|
||||
|
||||
# 获取最高优先级(最小数字)
|
||||
highest_priority = sorted_aliases[0]["priority"]
|
||||
highest_priority = sorted_mappings[0]["priority"]
|
||||
|
||||
# 获取所有最高优先级的别名
|
||||
top_priority_aliases = [
|
||||
alias for alias in sorted_aliases
|
||||
if alias["priority"] == highest_priority
|
||||
# 获取所有最高优先级的映射
|
||||
top_priority_mappings = [
|
||||
mapping for mapping in sorted_mappings
|
||||
if mapping["priority"] == highest_priority
|
||||
]
|
||||
|
||||
# 如果有多个相同优先级的别名,通过哈希分散选择
|
||||
if len(top_priority_aliases) > 1 and affinity_key:
|
||||
# 为每个别名计算哈希得分,选择得分最小的
|
||||
def hash_score(alias: dict) -> int:
|
||||
combined = f"{affinity_key}:{alias['name']}"
|
||||
# 如果有多个相同优先级的映射,通过哈希分散选择
|
||||
if len(top_priority_mappings) > 1 and affinity_key:
|
||||
# 为每个映射计算哈希得分,选择得分最小的
|
||||
def hash_score(mapping: dict) -> int:
|
||||
combined = f"{affinity_key}:{mapping['name']}"
|
||||
return int(hashlib.md5(combined.encode()).hexdigest(), 16)
|
||||
|
||||
selected = min(top_priority_aliases, key=hash_score)
|
||||
elif len(top_priority_aliases) > 1:
|
||||
selected = min(top_priority_mappings, key=hash_score)
|
||||
elif len(top_priority_mappings) > 1:
|
||||
# 没有 affinity_key 时,使用确定性选择(按名称排序后取第一个)
|
||||
# 避免随机选择导致同一请求重试时选择不同的模型名称
|
||||
selected = min(top_priority_aliases, key=lambda x: x["name"])
|
||||
selected = min(top_priority_mappings, key=lambda x: x["name"])
|
||||
else:
|
||||
selected = top_priority_aliases[0]
|
||||
selected = top_priority_mappings[0]
|
||||
|
||||
return selected["name"]
|
||||
|
||||
def get_all_provider_model_names(self) -> list[str]:
|
||||
"""获取所有可用的 Provider 模型名称(主名称 + 别名)"""
|
||||
"""获取所有可用的 Provider 模型名称(主名称 + 映射名称)"""
|
||||
names = [self.provider_model_name]
|
||||
if self.provider_model_aliases:
|
||||
for alias in self.provider_model_aliases:
|
||||
if isinstance(alias, dict) and alias.get("name"):
|
||||
names.append(alias["name"])
|
||||
if self.provider_model_mappings:
|
||||
for mapping in self.provider_model_mappings:
|
||||
if isinstance(mapping, dict) and mapping.get("name"):
|
||||
names.append(mapping["name"])
|
||||
return names
|
||||
|
||||
|
||||
@@ -1306,6 +1308,53 @@ class StatsDaily(Base):
|
||||
)
|
||||
|
||||
|
||||
class StatsDailyModel(Base):
|
||||
"""每日模型统计快照 - 用于快速查询每日模型维度数据"""
|
||||
|
||||
__tablename__ = "stats_daily_model"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# 统计日期 (UTC)
|
||||
date = Column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
# 模型名称
|
||||
model = Column(String(100), nullable=False)
|
||||
|
||||
# 请求统计
|
||||
total_requests = Column(Integer, default=0, nullable=False)
|
||||
|
||||
# Token 统计
|
||||
input_tokens = Column(BigInteger, default=0, nullable=False)
|
||||
output_tokens = Column(BigInteger, default=0, nullable=False)
|
||||
cache_creation_tokens = Column(BigInteger, default=0, nullable=False)
|
||||
cache_read_tokens = Column(BigInteger, default=0, nullable=False)
|
||||
|
||||
# 成本统计 (USD)
|
||||
total_cost = Column(Float, default=0.0, nullable=False)
|
||||
|
||||
# 性能统计
|
||||
avg_response_time_ms = Column(Float, default=0.0, nullable=False)
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 唯一约束:每个模型每天只有一条记录
|
||||
__table_args__ = (
|
||||
UniqueConstraint("date", "model", name="uq_stats_daily_model"),
|
||||
Index("idx_stats_daily_model_date", "date"),
|
||||
Index("idx_stats_daily_model_date_model", "date", "model"),
|
||||
)
|
||||
|
||||
|
||||
class StatsSummary(Base):
|
||||
"""全局统计汇总 - 单行记录,存储截止到昨天的累计数据"""
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from src.models.admin_requests import ProxyConfig
|
||||
|
||||
# ========== ProviderEndpoint CRUD ==========
|
||||
|
||||
@@ -30,6 +32,9 @@ class ProviderEndpointCreate(BaseModel):
|
||||
# 额外配置
|
||||
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置(JSON)")
|
||||
|
||||
# 代理配置
|
||||
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
|
||||
|
||||
@field_validator("api_format")
|
||||
@classmethod
|
||||
def validate_api_format(cls, v: str) -> str:
|
||||
@@ -45,24 +50,9 @@ class ProviderEndpointCreate(BaseModel):
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, v: str) -> str:
|
||||
"""验证 API URL(SSRF 防护)"""
|
||||
if not re.match(r"^https?://", v, re.IGNORECASE):
|
||||
raise ValueError("URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
# 防止 SSRF 攻击:禁止内网地址
|
||||
forbidden_patterns = [
|
||||
r"localhost",
|
||||
r"127\.0\.0\.1",
|
||||
r"0\.0\.0\.0",
|
||||
r"192\.168\.",
|
||||
r"10\.",
|
||||
r"172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
||||
r"169\.254\.",
|
||||
]
|
||||
for pattern in forbidden_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
raise ValueError("不允许使用内网地址")
|
||||
|
||||
return v.rstrip("/") # 移除末尾斜杠
|
||||
|
||||
|
||||
@@ -79,31 +69,18 @@ class ProviderEndpointUpdate(BaseModel):
|
||||
rate_limit: Optional[int] = Field(default=None, ge=1, description="速率限制")
|
||||
is_active: Optional[bool] = Field(default=None, description="是否启用")
|
||||
config: Optional[Dict[str, Any]] = Field(default=None, description="额外配置")
|
||||
proxy: Optional[ProxyConfig] = Field(default=None, description="代理配置")
|
||||
|
||||
@field_validator("base_url")
|
||||
@classmethod
|
||||
def validate_base_url(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 API URL(SSRF 防护)"""
|
||||
"""验证 API URL"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not re.match(r"^https?://", v, re.IGNORECASE):
|
||||
raise ValueError("URL 必须以 http:// 或 https:// 开头")
|
||||
|
||||
# 防止 SSRF 攻击:禁止内网地址
|
||||
forbidden_patterns = [
|
||||
r"localhost",
|
||||
r"127\.0\.0\.1",
|
||||
r"0\.0\.0\.0",
|
||||
r"192\.168\.",
|
||||
r"10\.",
|
||||
r"172\.(1[6-9]|2[0-9]|3[0-1])\.",
|
||||
r"169\.254\.",
|
||||
]
|
||||
for pattern in forbidden_patterns:
|
||||
if re.search(pattern, v, re.IGNORECASE):
|
||||
raise ValueError("不允许使用内网地址")
|
||||
|
||||
return v.rstrip("/") # 移除末尾斜杠
|
||||
|
||||
|
||||
@@ -133,6 +110,9 @@ class ProviderEndpointResponse(BaseModel):
|
||||
# 额外配置
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
|
||||
# 代理配置(响应中密码已脱敏)
|
||||
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置(密码已脱敏)")
|
||||
|
||||
# 统计(从 Keys 聚合)
|
||||
total_keys: int = Field(default=0, description="总 Key 数量")
|
||||
active_keys: int = Field(default=0, description="活跃 Key 数量")
|
||||
@@ -141,8 +121,7 @@ class ProviderEndpointResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== ProviderAPIKey 相关(新架构) ==========
|
||||
@@ -384,8 +363,7 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== 健康监控相关 ==========
|
||||
@@ -535,8 +513,7 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== 健康监控可视化模型 ==========
|
||||
|
||||
@@ -5,7 +5,7 @@ Pydantic 数据模型(阶段一统一模型管理)
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
# ========== 阶梯计费相关模型 ==========
|
||||
@@ -256,8 +256,7 @@ class GlobalModelResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class GlobalModelWithStats(GlobalModelResponse):
|
||||
@@ -302,6 +301,36 @@ class BatchAssignModelsToProviderResponse(BaseModel):
|
||||
errors: List[dict]
|
||||
|
||||
|
||||
class ImportFromUpstreamRequest(BaseModel):
|
||||
"""从上游提供商导入模型请求"""
|
||||
|
||||
model_ids: List[str] = Field(..., min_length=1, description="上游模型 ID 列表")
|
||||
|
||||
|
||||
class ImportFromUpstreamSuccessItem(BaseModel):
|
||||
"""导入成功的模型信息"""
|
||||
|
||||
model_id: str = Field(..., description="上游模型 ID")
|
||||
global_model_id: str = Field(..., description="GlobalModel ID")
|
||||
global_model_name: str = Field(..., description="GlobalModel 名称")
|
||||
provider_model_id: str = Field(..., description="Provider Model ID")
|
||||
created_global_model: bool = Field(..., description="是否新创建了 GlobalModel")
|
||||
|
||||
|
||||
class ImportFromUpstreamErrorItem(BaseModel):
|
||||
"""导入失败的模型信息"""
|
||||
|
||||
model_id: str = Field(..., description="上游模型 ID")
|
||||
error: str = Field(..., description="错误信息")
|
||||
|
||||
|
||||
class ImportFromUpstreamResponse(BaseModel):
|
||||
"""从上游提供商导入模型响应"""
|
||||
|
||||
success: List[ImportFromUpstreamSuccessItem]
|
||||
errors: List[ImportFromUpstreamErrorItem]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BatchAssignModelsToProviderRequest",
|
||||
"BatchAssignModelsToProviderResponse",
|
||||
@@ -312,6 +341,10 @@ __all__ = [
|
||||
"GlobalModelResponse",
|
||||
"GlobalModelUpdate",
|
||||
"GlobalModelWithStats",
|
||||
"ImportFromUpstreamErrorItem",
|
||||
"ImportFromUpstreamRequest",
|
||||
"ImportFromUpstreamResponse",
|
||||
"ImportFromUpstreamSuccessItem",
|
||||
"ModelCapabilities",
|
||||
"ModelCatalogItem",
|
||||
"ModelCatalogProviderDetail",
|
||||
|
||||
@@ -3,6 +3,7 @@ JWT认证插件
|
||||
支持JWT Bearer token认证
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request
|
||||
@@ -46,12 +47,12 @@ class JwtAuthPlugin(AuthPlugin):
|
||||
logger.debug("未找到JWT token")
|
||||
return None
|
||||
|
||||
# 记录认证尝试的详细信息
|
||||
logger.info(f"JWT认证尝试 - 路径: {request.url.path}, Token前20位: {token[:20]}...")
|
||||
token_fingerprint = hashlib.sha256(token.encode()).hexdigest()[:12]
|
||||
logger.info(f"JWT认证尝试 - 路径: {request.url.path}, token_fp={token_fingerprint}")
|
||||
|
||||
try:
|
||||
# 验证JWT token
|
||||
payload = AuthService.verify_token(token)
|
||||
payload = await AuthService.verify_token(token, token_type="access")
|
||||
logger.debug(f"JWT token验证成功, payload: {payload}")
|
||||
|
||||
# 从payload中提取用户信息
|
||||
|
||||
@@ -63,14 +63,16 @@ class JWTBlacklistService:
|
||||
|
||||
if ttl_seconds <= 0:
|
||||
# Token 已经过期,不需要加入黑名单
|
||||
logger.debug(f"Token 已过期,无需加入黑名单: {token[:10]}...")
|
||||
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
|
||||
logger.debug("Token 已过期,无需加入黑名单: token_fp={}", token_fp)
|
||||
return True
|
||||
|
||||
# 存储到 Redis,设置 TTL 为 Token 过期时间
|
||||
# 值存储为原因字符串
|
||||
await redis_client.setex(redis_key, ttl_seconds, reason)
|
||||
|
||||
logger.info(f"Token 已加入黑名单: {token[:10]}... (原因: {reason}, TTL: {ttl_seconds}s)")
|
||||
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
|
||||
logger.info("Token 已加入黑名单: token_fp={} (原因: {}, TTL: {}s)", token_fp, reason, ttl_seconds)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -109,7 +111,8 @@ class JWTBlacklistService:
|
||||
if exists:
|
||||
# 获取黑名单原因(可选)
|
||||
reason = await redis_client.get(redis_key)
|
||||
logger.warning(f"检测到黑名单 Token: {token[:10]}... (原因: {reason})")
|
||||
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
|
||||
logger.warning("检测到黑名单 Token: token_fp={} (原因: {})", token_fp, reason)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -148,9 +151,11 @@ class JWTBlacklistService:
|
||||
deleted = await redis_client.delete(redis_key)
|
||||
|
||||
if deleted:
|
||||
logger.info(f"Token 已从黑名单移除: {token[:10]}...")
|
||||
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
|
||||
logger.info("Token 已从黑名单移除: token_fp={}", token_fp)
|
||||
else:
|
||||
logger.debug(f"Token 不在黑名单中: {token[:10]}...")
|
||||
token_fp = JWTBlacklistService._get_token_hash(token)[:12]
|
||||
logger.debug("Token 不在黑名单中: token_fp={}", token_fp)
|
||||
|
||||
return bool(deleted)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -93,8 +94,8 @@ class AuthService:
|
||||
@staticmethod
|
||||
async def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
"""用户登录认证"""
|
||||
# 使用缓存查询用户
|
||||
user = await UserCacheService.get_user_by_email(db, email)
|
||||
# 登录校验必须读取密码哈希,不能使用不包含 password_hash 的缓存对象
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
if not user:
|
||||
logger.warning(f"登录失败 - 用户不存在: {email}")
|
||||
@@ -109,13 +110,10 @@ class AuthService:
|
||||
return None
|
||||
|
||||
# 更新最后登录时间
|
||||
# 需要重新从数据库获取以便更新(缓存的对象是分离的)
|
||||
db_user = db.query(User).filter(User.id == user.id).first()
|
||||
if db_user:
|
||||
db_user.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit() # 立即提交事务,释放数据库锁
|
||||
# 清除缓存,因为用户信息已更新
|
||||
await UserCacheService.invalidate_user_cache(user.id, user.email)
|
||||
user.last_login_at = datetime.now(timezone.utc)
|
||||
db.commit() # 立即提交事务,释放数据库锁
|
||||
# 清除缓存,因为用户信息已更新
|
||||
await UserCacheService.invalidate_user_cache(user.id, user.email)
|
||||
|
||||
logger.info(f"用户登录成功: {email} (ID: {user.id})")
|
||||
return user
|
||||
@@ -172,7 +170,8 @@ class AuthService:
|
||||
key_record.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit() # 立即提交事务,释放数据库锁,避免阻塞后续请求
|
||||
|
||||
logger.debug(f"API认证成功: 用户 {user.email} (Key: {api_key[:10]}...)")
|
||||
api_key_fp = hashlib.sha256(api_key.encode()).hexdigest()[:12]
|
||||
logger.debug("API认证成功: 用户 {} (api_key_fp={})", user.email, api_key_fp)
|
||||
return user, key_record
|
||||
|
||||
@staticmethod
|
||||
@@ -198,7 +197,10 @@ class AuthService:
|
||||
if user.role == UserRole.ADMIN:
|
||||
return True
|
||||
|
||||
if user.role.value >= required_role.value:
|
||||
# 避免使用字符串比较导致权限判断错误(例如 'user' >= 'admin')
|
||||
role_rank = {UserRole.USER: 0, UserRole.ADMIN: 1}
|
||||
# 未知用户角色默认 -1(拒绝),未知要求角色默认 999(拒绝)
|
||||
if role_rank.get(user.role, -1) >= role_rank.get(required_role, 999):
|
||||
return True
|
||||
|
||||
logger.warning(f"权限不足: 用户 {user.email} 角色 {user.role.value} < 需要 {required_role.value}")
|
||||
@@ -230,7 +232,7 @@ class AuthService:
|
||||
)
|
||||
|
||||
if success:
|
||||
user_id = payload.get("sub")
|
||||
user_id = payload.get("user_id")
|
||||
logger.info(f"用户登出成功: user_id={user_id}")
|
||||
|
||||
return success
|
||||
|
||||
16
src/services/cache/aware_scheduler.py
vendored
16
src/services/cache/aware_scheduler.py
vendored
@@ -59,7 +59,6 @@ from src.services.health.monitor import health_monitor
|
||||
from src.services.provider.format import normalize_api_format
|
||||
from src.services.rate_limit.adaptive_reservation import (
|
||||
AdaptiveReservationManager,
|
||||
ReservationResult,
|
||||
get_adaptive_reservation_manager,
|
||||
)
|
||||
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||
@@ -112,8 +111,6 @@ class CacheAwareScheduler:
|
||||
- 健康度监控
|
||||
"""
|
||||
|
||||
# 静态常量作为默认值(实际由 AdaptiveReservationManager 动态计算)
|
||||
CACHE_RESERVATION_RATIO = 0.3
|
||||
# 优先级模式常量
|
||||
PRIORITY_MODE_PROVIDER = "provider" # 提供商优先模式
|
||||
PRIORITY_MODE_GLOBAL_KEY = "global_key" # 全局 Key 优先模式
|
||||
@@ -592,14 +589,14 @@ class CacheAwareScheduler:
|
||||
|
||||
target_format = normalize_api_format(api_format)
|
||||
|
||||
# 0. 解析 model_name 到 GlobalModel(支持直接匹配和别名匹配,使用 ModelCacheService)
|
||||
# 0. 解析 model_name 到 GlobalModel(支持直接匹配和映射名匹配,使用 ModelCacheService)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
|
||||
|
||||
if not global_model:
|
||||
logger.warning(f"GlobalModel not found: {model_name}")
|
||||
raise ModelNotSupportedException(model=model_name)
|
||||
|
||||
# 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保别名和规范名都能命中同一个缓存
|
||||
# 使用 GlobalModel.id 作为缓存亲和性的模型标识,确保映射名和规范名都能命中同一个缓存
|
||||
global_model_id: str = str(global_model.id)
|
||||
requested_model_name = model_name
|
||||
resolved_model_name = str(global_model.name)
|
||||
@@ -754,19 +751,19 @@ class CacheAwareScheduler:
|
||||
|
||||
支持两种匹配方式:
|
||||
1. 直接匹配 GlobalModel.name
|
||||
2. 通过 ModelCacheService 匹配别名(全局查找)
|
||||
2. 通过 ModelCacheService 匹配映射名(全局查找)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
provider: Provider 对象
|
||||
model_name: 模型名称(可以是 GlobalModel.name 或别名)
|
||||
model_name: 模型名称(可以是 GlobalModel.name 或映射名)
|
||||
is_stream: 是否是流式请求,如果为 True 则同时检查流式支持
|
||||
capability_requirements: 能力需求(可选),用于检查模型是否支持所需能力
|
||||
|
||||
Returns:
|
||||
(is_supported, skip_reason, supported_capabilities) - 是否支持、跳过原因、模型支持的能力列表
|
||||
"""
|
||||
# 使用 ModelCacheService 解析模型名称(支持别名)
|
||||
# 使用 ModelCacheService 解析模型名称(支持映射名)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, model_name)
|
||||
|
||||
if not global_model:
|
||||
@@ -917,7 +914,7 @@ class CacheAwareScheduler:
|
||||
db: 数据库会话
|
||||
providers: Provider 列表
|
||||
target_format: 目标 API 格式
|
||||
model_name: 模型名称(用户请求的名称,可能是别名)
|
||||
model_name: 模型名称(用户请求的名称,可能是映射名)
|
||||
affinity_key: 亲和性标识符(通常为API Key ID)
|
||||
resolved_model_name: 解析后的 GlobalModel.name(用于 Key.allowed_models 校验)
|
||||
max_candidates: 最大候选数
|
||||
@@ -1320,7 +1317,6 @@ class CacheAwareScheduler:
|
||||
|
||||
return {
|
||||
"scheduler": "cache_aware",
|
||||
"cache_reservation_ratio": self.CACHE_RESERVATION_RATIO,
|
||||
"dynamic_reservation": {
|
||||
"enabled": True,
|
||||
"config": reservation_stats["config"],
|
||||
|
||||
54
src/services/cache/model_cache.py
vendored
54
src/services/cache/model_cache.py
vendored
@@ -1,5 +1,21 @@
|
||||
"""
|
||||
Model 映射缓存服务 - 减少模型查询
|
||||
|
||||
架构说明
|
||||
========
|
||||
本服务采用混合 async/sync 模式:
|
||||
- 缓存操作(CacheService):真正的 async,使用 aioredis
|
||||
- 数据库查询(db.query):同步的 SQLAlchemy Session
|
||||
|
||||
设计决策
|
||||
--------
|
||||
1. 保持 async 方法签名:因为缓存命中时完全异步,性能最优
|
||||
2. 缓存未命中时的同步查询:FastAPI 会在线程池中执行,不会阻塞事件循环
|
||||
3. 调用方必须在 async 上下文中使用 await
|
||||
|
||||
使用示例
|
||||
--------
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(db, "gpt-4")
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -19,7 +35,11 @@ from src.models.database import GlobalModel, Model
|
||||
|
||||
|
||||
class ModelCacheService:
|
||||
"""Model 映射缓存服务"""
|
||||
"""Model 映射缓存服务
|
||||
|
||||
提供 GlobalModel 和 Model 的缓存查询功能,减少数据库访问。
|
||||
所有公开方法均为 async,需要在 async 上下文中调用。
|
||||
"""
|
||||
|
||||
# 缓存 TTL(秒)- 使用统一常量
|
||||
CACHE_TTL = CacheTTL.MODEL
|
||||
@@ -178,7 +198,7 @@ class ModelCacheService:
|
||||
provider_id: Optional[str] = None,
|
||||
global_model_id: Optional[str] = None,
|
||||
provider_model_name: Optional[str] = None,
|
||||
provider_model_aliases: Optional[list] = None,
|
||||
provider_model_mappings: Optional[list] = None,
|
||||
) -> None:
|
||||
"""清除 Model 缓存
|
||||
|
||||
@@ -187,7 +207,7 @@ class ModelCacheService:
|
||||
provider_id: Provider ID(用于清除 provider_global 缓存)
|
||||
global_model_id: GlobalModel ID(用于清除 provider_global 缓存)
|
||||
provider_model_name: provider_model_name(用于清除 resolve 缓存)
|
||||
provider_model_aliases: 映射名称列表(用于清除 resolve 缓存)
|
||||
provider_model_mappings: 映射名称列表(用于清除 resolve 缓存)
|
||||
"""
|
||||
# 清除 model:id 缓存
|
||||
await CacheService.delete(f"model:id:{model_id}")
|
||||
@@ -202,16 +222,16 @@ class ModelCacheService:
|
||||
else:
|
||||
logger.debug(f"Model 缓存已清除: {model_id}")
|
||||
|
||||
# 清除 resolve 缓存(provider_model_name 和 aliases 可能都被用作解析 key)
|
||||
# 清除 resolve 缓存(provider_model_name 和 mappings 可能都被用作解析 key)
|
||||
resolve_keys_to_clear = []
|
||||
if provider_model_name:
|
||||
resolve_keys_to_clear.append(provider_model_name)
|
||||
if provider_model_aliases:
|
||||
for alias_entry in provider_model_aliases:
|
||||
if isinstance(alias_entry, dict):
|
||||
alias_name = alias_entry.get("name", "").strip()
|
||||
if alias_name:
|
||||
resolve_keys_to_clear.append(alias_name)
|
||||
if provider_model_mappings:
|
||||
for mapping_entry in provider_model_mappings:
|
||||
if isinstance(mapping_entry, dict):
|
||||
mapping_name = mapping_entry.get("name", "").strip()
|
||||
if mapping_name:
|
||||
resolve_keys_to_clear.append(mapping_name)
|
||||
|
||||
for key in resolve_keys_to_clear:
|
||||
await CacheService.delete(f"global_model:resolve:{key}")
|
||||
@@ -241,8 +261,8 @@ class ModelCacheService:
|
||||
2. 通过 provider_model_name 匹配(查询 Model 表)
|
||||
3. 直接匹配 GlobalModel.name(兜底)
|
||||
|
||||
注意:此方法不使用 provider_model_aliases 进行全局解析。
|
||||
provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效,
|
||||
注意:此方法不使用 provider_model_mappings 进行全局解析。
|
||||
provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效,
|
||||
由 resolve_provider_model() 处理。
|
||||
|
||||
Args:
|
||||
@@ -281,9 +301,9 @@ class ModelCacheService:
|
||||
logger.debug(f"GlobalModel 缓存命中(映射解析): {normalized_name}")
|
||||
return ModelCacheService._dict_to_global_model(cached_data)
|
||||
|
||||
# 2. 通过 provider_model_name 匹配(不考虑 provider_model_aliases)
|
||||
# 重要:provider_model_aliases 是 Provider 级别的别名配置,只在特定 Provider 上下文中生效
|
||||
# 全局解析不应该受到某个 Provider 别名配置的影响
|
||||
# 2. 通过 provider_model_name 匹配(不考虑 provider_model_mappings)
|
||||
# 重要:provider_model_mappings 是 Provider 级别的映射配置,只在特定 Provider 上下文中生效
|
||||
# 全局解析不应该受到某个 Provider 映射配置的影响
|
||||
# 例如:Provider A 把 "haiku" 映射到 "sonnet",不应该影响 Provider B 的 "haiku" 解析
|
||||
from src.models.database import Provider
|
||||
|
||||
@@ -381,7 +401,7 @@ class ModelCacheService:
|
||||
"provider_id": model.provider_id,
|
||||
"global_model_id": model.global_model_id,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"provider_model_aliases": getattr(model, "provider_model_aliases", None),
|
||||
"provider_model_mappings": getattr(model, "provider_model_mappings", None),
|
||||
"is_active": model.is_active,
|
||||
"is_available": model.is_available if hasattr(model, "is_available") else True,
|
||||
"price_per_request": (
|
||||
@@ -404,7 +424,7 @@ class ModelCacheService:
|
||||
provider_id=model_dict["provider_id"],
|
||||
global_model_id=model_dict["global_model_id"],
|
||||
provider_model_name=model_dict["provider_model_name"],
|
||||
provider_model_aliases=model_dict.get("provider_model_aliases"),
|
||||
provider_model_mappings=model_dict.get("provider_model_mappings"),
|
||||
is_active=model_dict["is_active"],
|
||||
is_available=model_dict.get("is_available", True),
|
||||
price_per_request=model_dict.get("price_per_request"),
|
||||
|
||||
254
src/services/cache/provider_cache.py
vendored
254
src/services/cache/provider_cache.py
vendored
@@ -1,254 +0,0 @@
|
||||
"""
|
||||
Provider 配置缓存服务 - 减少 Provider/Endpoint/APIKey 查询
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.config.constants import CacheTTL
|
||||
from src.core.cache_service import CacheKeys, CacheService
|
||||
from src.core.logger import logger
|
||||
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||
|
||||
|
||||
|
||||
class ProviderCacheService:
|
||||
"""Provider 配置缓存服务"""
|
||||
|
||||
# 缓存 TTL(秒)- 使用统一常量
|
||||
CACHE_TTL = CacheTTL.PROVIDER
|
||||
|
||||
@staticmethod
|
||||
async def get_provider_by_id(db: Session, provider_id: str) -> Optional[Provider]:
|
||||
"""
|
||||
获取 Provider(带缓存)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
provider_id: Provider ID
|
||||
|
||||
Returns:
|
||||
Provider 对象或 None
|
||||
"""
|
||||
cache_key = CacheKeys.provider_by_id(provider_id)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
cached_data = await CacheService.get(cache_key)
|
||||
if cached_data:
|
||||
logger.debug(f"Provider 缓存命中: {provider_id}")
|
||||
return ProviderCacheService._dict_to_provider(cached_data)
|
||||
|
||||
# 2. 缓存未命中,查询数据库
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
|
||||
# 3. 写入缓存
|
||||
if provider:
|
||||
provider_dict = ProviderCacheService._provider_to_dict(provider)
|
||||
await CacheService.set(
|
||||
cache_key, provider_dict, ttl_seconds=ProviderCacheService.CACHE_TTL
|
||||
)
|
||||
logger.debug(f"Provider 已缓存: {provider_id}")
|
||||
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
async def get_endpoint_by_id(db: Session, endpoint_id: str) -> Optional[ProviderEndpoint]:
|
||||
"""
|
||||
获取 Endpoint(带缓存)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
endpoint_id: Endpoint ID
|
||||
|
||||
Returns:
|
||||
ProviderEndpoint 对象或 None
|
||||
"""
|
||||
cache_key = CacheKeys.endpoint_by_id(endpoint_id)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
cached_data = await CacheService.get(cache_key)
|
||||
if cached_data:
|
||||
logger.debug(f"Endpoint 缓存命中: {endpoint_id}")
|
||||
return ProviderCacheService._dict_to_endpoint(cached_data)
|
||||
|
||||
# 2. 缓存未命中,查询数据库
|
||||
endpoint = db.query(ProviderEndpoint).filter(ProviderEndpoint.id == endpoint_id).first()
|
||||
|
||||
# 3. 写入缓存
|
||||
if endpoint:
|
||||
endpoint_dict = ProviderCacheService._endpoint_to_dict(endpoint)
|
||||
await CacheService.set(
|
||||
cache_key, endpoint_dict, ttl_seconds=ProviderCacheService.CACHE_TTL
|
||||
)
|
||||
logger.debug(f"Endpoint 已缓存: {endpoint_id}")
|
||||
|
||||
return endpoint
|
||||
|
||||
@staticmethod
|
||||
async def get_api_key_by_id(db: Session, api_key_id: str) -> Optional[ProviderAPIKey]:
|
||||
"""
|
||||
获取 API Key(带缓存)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
api_key_id: API Key ID
|
||||
|
||||
Returns:
|
||||
ProviderAPIKey 对象或 None
|
||||
"""
|
||||
cache_key = CacheKeys.api_key_by_id(api_key_id)
|
||||
|
||||
# 1. 尝试从缓存获取
|
||||
cached_data = await CacheService.get(cache_key)
|
||||
if cached_data:
|
||||
logger.debug(f"API Key 缓存命中: {api_key_id}")
|
||||
return ProviderCacheService._dict_to_api_key(cached_data)
|
||||
|
||||
# 2. 缓存未命中,查询数据库
|
||||
api_key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == api_key_id).first()
|
||||
|
||||
# 3. 写入缓存
|
||||
if api_key:
|
||||
api_key_dict = ProviderCacheService._api_key_to_dict(api_key)
|
||||
await CacheService.set(
|
||||
cache_key, api_key_dict, ttl_seconds=ProviderCacheService.CACHE_TTL
|
||||
)
|
||||
logger.debug(f"API Key 已缓存: {api_key_id}")
|
||||
|
||||
return api_key
|
||||
|
||||
@staticmethod
|
||||
async def invalidate_provider_cache(provider_id: str):
|
||||
"""
|
||||
清除 Provider 缓存
|
||||
|
||||
Args:
|
||||
provider_id: Provider ID
|
||||
"""
|
||||
await CacheService.delete(CacheKeys.provider_by_id(provider_id))
|
||||
logger.debug(f"Provider 缓存已清除: {provider_id}")
|
||||
|
||||
@staticmethod
|
||||
async def invalidate_endpoint_cache(endpoint_id: str):
|
||||
"""
|
||||
清除 Endpoint 缓存
|
||||
|
||||
Args:
|
||||
endpoint_id: Endpoint ID
|
||||
"""
|
||||
await CacheService.delete(CacheKeys.endpoint_by_id(endpoint_id))
|
||||
logger.debug(f"Endpoint 缓存已清除: {endpoint_id}")
|
||||
|
||||
@staticmethod
|
||||
async def invalidate_api_key_cache(api_key_id: str):
|
||||
"""
|
||||
清除 API Key 缓存
|
||||
|
||||
Args:
|
||||
api_key_id: API Key ID
|
||||
"""
|
||||
await CacheService.delete(CacheKeys.api_key_by_id(api_key_id))
|
||||
logger.debug(f"API Key 缓存已清除: {api_key_id}")
|
||||
|
||||
@staticmethod
|
||||
def _provider_to_dict(provider: Provider) -> dict:
|
||||
"""将 Provider 对象转换为字典(用于缓存)"""
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"api_format": provider.api_format,
|
||||
"base_url": provider.base_url,
|
||||
"is_active": provider.is_active,
|
||||
"priority": provider.priority,
|
||||
"rpm_limit": provider.rpm_limit,
|
||||
"rpm_used": provider.rpm_used,
|
||||
"rpm_reset_at": provider.rpm_reset_at.isoformat() if provider.rpm_reset_at else None,
|
||||
"config": provider.config,
|
||||
"description": provider.description,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_provider(provider_dict: dict) -> Provider:
|
||||
"""从字典重建 Provider 对象(分离的对象,不在 Session 中)"""
|
||||
from datetime import datetime
|
||||
|
||||
provider = Provider(
|
||||
id=provider_dict["id"],
|
||||
name=provider_dict["name"],
|
||||
api_format=provider_dict["api_format"],
|
||||
base_url=provider_dict.get("base_url"),
|
||||
is_active=provider_dict["is_active"],
|
||||
priority=provider_dict.get("priority", 0),
|
||||
rpm_limit=provider_dict.get("rpm_limit"),
|
||||
rpm_used=provider_dict.get("rpm_used", 0),
|
||||
config=provider_dict.get("config"),
|
||||
description=provider_dict.get("description"),
|
||||
)
|
||||
|
||||
if provider_dict.get("rpm_reset_at"):
|
||||
provider.rpm_reset_at = datetime.fromisoformat(provider_dict["rpm_reset_at"])
|
||||
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
def _endpoint_to_dict(endpoint: ProviderEndpoint) -> dict:
|
||||
"""将 Endpoint 对象转换为字典"""
|
||||
return {
|
||||
"id": endpoint.id,
|
||||
"provider_id": endpoint.provider_id,
|
||||
"name": endpoint.name,
|
||||
"base_url": endpoint.base_url,
|
||||
"is_active": endpoint.is_active,
|
||||
"priority": endpoint.priority,
|
||||
"weight": endpoint.weight,
|
||||
"custom_path": endpoint.custom_path,
|
||||
"config": endpoint.config,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_endpoint(endpoint_dict: dict) -> ProviderEndpoint:
|
||||
"""从字典重建 Endpoint 对象"""
|
||||
endpoint = ProviderEndpoint(
|
||||
id=endpoint_dict["id"],
|
||||
provider_id=endpoint_dict["provider_id"],
|
||||
name=endpoint_dict["name"],
|
||||
base_url=endpoint_dict["base_url"],
|
||||
is_active=endpoint_dict["is_active"],
|
||||
priority=endpoint_dict.get("priority", 0),
|
||||
weight=endpoint_dict.get("weight", 1.0),
|
||||
custom_path=endpoint_dict.get("custom_path"),
|
||||
config=endpoint_dict.get("config"),
|
||||
)
|
||||
return endpoint
|
||||
|
||||
@staticmethod
|
||||
def _api_key_to_dict(api_key: ProviderAPIKey) -> dict:
|
||||
"""将 API Key 对象转换为字典"""
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"endpoint_id": api_key.endpoint_id,
|
||||
"key_value": api_key.key_value,
|
||||
"is_active": api_key.is_active,
|
||||
"max_rpm": api_key.max_rpm,
|
||||
"current_rpm": api_key.current_rpm,
|
||||
"health_score": api_key.health_score,
|
||||
"circuit_breaker_state": api_key.circuit_breaker_state,
|
||||
"adaptive_concurrency_limit": api_key.adaptive_concurrency_limit,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_api_key(api_key_dict: dict) -> ProviderAPIKey:
|
||||
"""从字典重建 API Key 对象"""
|
||||
api_key = ProviderAPIKey(
|
||||
id=api_key_dict["id"],
|
||||
endpoint_id=api_key_dict["endpoint_id"],
|
||||
key_value=api_key_dict["key_value"],
|
||||
is_active=api_key_dict["is_active"],
|
||||
max_rpm=api_key_dict.get("max_rpm"),
|
||||
current_rpm=api_key_dict.get("current_rpm", 0),
|
||||
health_score=api_key_dict.get("health_score", 1.0),
|
||||
circuit_breaker_state=api_key_dict.get("circuit_breaker_state"),
|
||||
adaptive_concurrency_limit=api_key_dict.get("adaptive_concurrency_limit"),
|
||||
)
|
||||
return api_key
|
||||
24
src/services/cache/user_cache.py
vendored
24
src/services/cache/user_cache.py
vendored
@@ -1,5 +1,22 @@
|
||||
"""
|
||||
用户缓存服务 - 减少数据库查询
|
||||
|
||||
架构说明
|
||||
========
|
||||
本服务采用混合 async/sync 模式:
|
||||
- 缓存操作(CacheService):真正的 async,使用 aioredis
|
||||
- 数据库查询(db.query):同步的 SQLAlchemy Session
|
||||
|
||||
设计决策
|
||||
--------
|
||||
1. 保持 async 方法签名:因为缓存命中时完全异步,性能最优
|
||||
2. 缓存未命中时的同步查询:FastAPI 会在线程池中执行,不会阻塞事件循环
|
||||
3. 调用方必须在 async 上下文中使用 await
|
||||
|
||||
使用示例
|
||||
--------
|
||||
user = await UserCacheService.get_user_by_id(db, user_id)
|
||||
await UserCacheService.invalidate_user_cache(user_id, email)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
@@ -12,9 +29,12 @@ from src.core.logger import logger
|
||||
from src.models.database import User
|
||||
|
||||
|
||||
|
||||
class UserCacheService:
|
||||
"""用户缓存服务"""
|
||||
"""用户缓存服务
|
||||
|
||||
提供 User 的缓存查询功能,减少数据库访问。
|
||||
所有公开方法均为 async,需要在 async 上下文中调用。
|
||||
"""
|
||||
|
||||
# 缓存 TTL(秒)- 使用统一常量
|
||||
CACHE_TTL = CacheTTL.USER
|
||||
|
||||
@@ -443,7 +443,7 @@ class ModelCostService:
|
||||
|
||||
Args:
|
||||
provider: Provider 对象或提供商名称
|
||||
model: 用户请求的模型名(可能是 GlobalModel.name 或别名)
|
||||
model: 用户请求的模型名(可能是 GlobalModel.name 或映射名)
|
||||
|
||||
Returns:
|
||||
按次计费价格,如果没有配置则返回 None
|
||||
|
||||
@@ -84,11 +84,11 @@ class ModelMapperMiddleware:
|
||||
获取模型映射
|
||||
|
||||
简化后的逻辑:
|
||||
1. 通过 GlobalModel.name 或别名解析 GlobalModel
|
||||
1. 通过 GlobalModel.name 或映射名解析 GlobalModel
|
||||
2. 找到 GlobalModel 后,查找该 Provider 的 Model 实现
|
||||
|
||||
Args:
|
||||
source_model: 用户请求的模型名(可以是 GlobalModel.name 或别名)
|
||||
source_model: 用户请求的模型名(可以是 GlobalModel.name 或映射名)
|
||||
provider_id: 提供商ID (UUID)
|
||||
|
||||
Returns:
|
||||
@@ -101,7 +101,7 @@ class ModelMapperMiddleware:
|
||||
|
||||
mapping = None
|
||||
|
||||
# 步骤 1: 解析 GlobalModel(支持别名)
|
||||
# 步骤 1: 解析 GlobalModel(支持映射名)
|
||||
global_model = await ModelCacheService.resolve_global_model_by_name_or_alias(
|
||||
self.db, source_model
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.models.api import ModelCreate, ModelResponse, ModelUpdate
|
||||
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.model_cache import ModelCacheService
|
||||
|
||||
@@ -50,7 +51,7 @@ class ModelService:
|
||||
provider_id=provider_id,
|
||||
global_model_id=model_data.global_model_id,
|
||||
provider_model_name=model_data.provider_model_name,
|
||||
provider_model_aliases=model_data.provider_model_aliases,
|
||||
provider_model_mappings=model_data.provider_model_mappings,
|
||||
price_per_request=model_data.price_per_request,
|
||||
tiered_pricing=model_data.tiered_pricing,
|
||||
supports_vision=model_data.supports_vision,
|
||||
@@ -75,6 +76,10 @@ class ModelService:
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
except IntegrityError as e:
|
||||
@@ -148,9 +153,9 @@ class ModelService:
|
||||
if not model:
|
||||
raise NotFoundException(f"模型 {model_id} 不存在")
|
||||
|
||||
# 保存旧的别名,用于清除缓存
|
||||
# 保存旧的映射,用于清除缓存
|
||||
old_provider_model_name = model.provider_model_name
|
||||
old_provider_model_aliases = model.provider_model_aliases
|
||||
old_provider_model_mappings = model.provider_model_mappings
|
||||
|
||||
# 更新字段
|
||||
update_data = model_data.model_dump(exclude_unset=True)
|
||||
@@ -169,26 +174,26 @@ class ModelService:
|
||||
db.refresh(model)
|
||||
|
||||
# 清除 Redis 缓存(异步执行,不阻塞返回)
|
||||
# 先清除旧的别名缓存
|
||||
# 先清除旧的映射缓存
|
||||
asyncio.create_task(
|
||||
ModelCacheService.invalidate_model_cache(
|
||||
model_id=model.id,
|
||||
provider_id=model.provider_id,
|
||||
global_model_id=model.global_model_id,
|
||||
provider_model_name=old_provider_model_name,
|
||||
provider_model_aliases=old_provider_model_aliases,
|
||||
provider_model_mappings=old_provider_model_mappings,
|
||||
)
|
||||
)
|
||||
# 再清除新的别名缓存(如果有变化)
|
||||
# 再清除新的映射缓存(如果有变化)
|
||||
if (model.provider_model_name != old_provider_model_name or
|
||||
model.provider_model_aliases != old_provider_model_aliases):
|
||||
model.provider_model_mappings != old_provider_model_mappings):
|
||||
asyncio.create_task(
|
||||
ModelCacheService.invalidate_model_cache(
|
||||
model_id=model.id,
|
||||
provider_id=model.provider_id,
|
||||
global_model_id=model.global_model_id,
|
||||
provider_model_name=model.provider_model_name,
|
||||
provider_model_aliases=model.provider_model_aliases,
|
||||
provider_model_mappings=model.provider_model_mappings,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -197,6 +202,9 @@ class ModelService:
|
||||
cache_service = get_cache_invalidation_service()
|
||||
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}")
|
||||
return model
|
||||
except IntegrityError as e:
|
||||
@@ -238,7 +246,7 @@ class ModelService:
|
||||
"provider_id": model.provider_id,
|
||||
"global_model_id": model.global_model_id,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"provider_model_aliases": model.provider_model_aliases,
|
||||
"provider_model_mappings": model.provider_model_mappings,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -252,7 +260,7 @@ class ModelService:
|
||||
provider_id=cache_info["provider_id"],
|
||||
global_model_id=cache_info["global_model_id"],
|
||||
provider_model_name=cache_info["provider_model_name"],
|
||||
provider_model_aliases=cache_info["provider_model_aliases"],
|
||||
provider_model_mappings=cache_info["provider_model_mappings"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -261,6 +269,9 @@ class ModelService:
|
||||
cache_service = get_cache_invalidation_service()
|
||||
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']}, "
|
||||
f"global_model_id={cache_info['global_model_id'][:8] if cache_info['global_model_id'] else 'None'}...")
|
||||
except Exception as e:
|
||||
@@ -286,7 +297,7 @@ class ModelService:
|
||||
provider_id=model.provider_id,
|
||||
global_model_id=model.global_model_id,
|
||||
provider_model_name=model.provider_model_name,
|
||||
provider_model_aliases=model.provider_model_aliases,
|
||||
provider_model_mappings=model.provider_model_mappings,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -295,6 +306,9 @@ class ModelService:
|
||||
cache_service = get_cache_invalidation_service()
|
||||
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 "不可用"
|
||||
logger.info(f"更新模型可用状态: id={model_id}, status={status}")
|
||||
return model
|
||||
@@ -358,6 +372,9 @@ class ModelService:
|
||||
for model in created_models:
|
||||
db.refresh(model)
|
||||
logger.info(f"批量创建 {len(created_models)} 个模型成功")
|
||||
|
||||
# 清除 /v1/models 列表缓存
|
||||
asyncio.create_task(invalidate_models_list_cache())
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
logger.error(f"批量创建模型失败: {str(e)}")
|
||||
@@ -373,7 +390,7 @@ class ModelService:
|
||||
provider_id=model.provider_id,
|
||||
global_model_id=model.global_model_id,
|
||||
provider_model_name=model.provider_model_name,
|
||||
provider_model_aliases=model.provider_model_aliases,
|
||||
provider_model_mappings=model.provider_model_mappings,
|
||||
# 原始配置值(可能为空)
|
||||
price_per_request=model.price_per_request,
|
||||
tiered_pricing=model.tiered_pricing,
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.core.enums import APIFormat
|
||||
from src.core.exceptions import (
|
||||
ConcurrencyLimitError,
|
||||
ProviderAuthException,
|
||||
ProviderCompatibilityException,
|
||||
ProviderException,
|
||||
ProviderNotAvailableException,
|
||||
ProviderRateLimitException,
|
||||
@@ -69,24 +70,31 @@ class ErrorClassifier:
|
||||
# 这些错误是由用户请求本身导致的,换 Provider 也无济于事
|
||||
# 注意:标准 API 返回的 error.type 已在 CLIENT_ERROR_TYPES 中处理
|
||||
# 这里主要用于匹配非标准格式或第三方代理的错误消息
|
||||
#
|
||||
# 重要:不要在此列表中包含 Provider Key 配置问题(如 invalid_api_key)
|
||||
# 这类错误应该触发故障转移,而不是直接返回给用户
|
||||
CLIENT_ERROR_PATTERNS: Tuple[str, ...] = (
|
||||
"could not process image", # 图片处理失败
|
||||
"image too large", # 图片过大
|
||||
"invalid image", # 无效图片
|
||||
"unsupported image", # 不支持的图片格式
|
||||
"content_policy_violation", # 内容违规
|
||||
"invalid_api_key", # 无效的 API Key(不同于认证失败)
|
||||
"context_length_exceeded", # 上下文长度超限
|
||||
"content_length_limit", # 请求内容长度超限 (Claude API)
|
||||
"max_tokens", # token 数超限
|
||||
"content_length_exceeds", # 内容长度超限变体 (AWS CodeWhisperer)
|
||||
# 注意:移除了 "max_tokens",因为 max_tokens 相关错误可能是 Provider 兼容性问题
|
||||
# 如 "Unsupported parameter: 'max_tokens' is not supported with this model"
|
||||
# 这类错误应由 COMPATIBILITY_ERROR_PATTERNS 处理
|
||||
"invalid_prompt", # 无效的提示词
|
||||
"content too long", # 内容过长
|
||||
"input is too long", # 输入过长 (AWS)
|
||||
"message is too long", # 消息过长
|
||||
"prompt is too long", # Prompt 超长(第三方代理常见格式)
|
||||
"image exceeds", # 图片超出限制
|
||||
"pdf too large", # PDF 过大
|
||||
"file too large", # 文件过大
|
||||
"tool_use_id", # tool_result 引用了不存在的 tool_use(兼容非标准代理)
|
||||
"validationexception", # AWS 验证异常
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -110,18 +118,137 @@ class ErrorClassifier:
|
||||
# 表示客户端错误的 error type(不区分大小写)
|
||||
# 这些 type 表明是请求本身的问题,不应重试
|
||||
CLIENT_ERROR_TYPES: Tuple[str, ...] = (
|
||||
"invalid_request_error", # Claude/OpenAI 标准客户端错误类型
|
||||
"invalid_argument", # Gemini 参数错误
|
||||
"failed_precondition", # Gemini 前置条件错误
|
||||
# Claude/OpenAI 标准
|
||||
"invalid_request_error",
|
||||
# Gemini
|
||||
"invalid_argument",
|
||||
"failed_precondition",
|
||||
# AWS
|
||||
"validationexception",
|
||||
# 通用
|
||||
"validation_error",
|
||||
"bad_request",
|
||||
)
|
||||
|
||||
# 表示客户端错误的 reason/code 字段值
|
||||
CLIENT_ERROR_REASONS: Tuple[str, ...] = (
|
||||
"CONTENT_LENGTH_EXCEEDS_THRESHOLD",
|
||||
"CONTEXT_LENGTH_EXCEEDED",
|
||||
"MAX_TOKENS_EXCEEDED",
|
||||
"INVALID_CONTENT",
|
||||
"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]:
|
||||
"""
|
||||
解析错误响应为结构化数据
|
||||
|
||||
支持多种格式:
|
||||
- {"error": {"type": "...", "message": "..."}} (Claude/OpenAI)
|
||||
- {"error": {"message": "...", "__type": "..."}} (AWS)
|
||||
- {"errorMessage": "..."} (Lambda)
|
||||
- {"error": "..."}
|
||||
- {"message": "...", "reason": "..."}
|
||||
|
||||
Returns:
|
||||
结构化的错误信息: {
|
||||
"type": str, # 错误类型
|
||||
"message": str, # 错误消息
|
||||
"reason": str, # 错误原因/代码
|
||||
"raw": str, # 原始文本
|
||||
}
|
||||
"""
|
||||
result = {"type": "", "message": "", "reason": "", "raw": error_text or ""}
|
||||
|
||||
if not error_text:
|
||||
return result
|
||||
|
||||
try:
|
||||
data = json.loads(error_text)
|
||||
|
||||
# 格式 1: {"error": {"type": "...", "message": "..."}}
|
||||
if isinstance(data.get("error"), dict):
|
||||
error_obj = data["error"]
|
||||
result["type"] = str(error_obj.get("type", ""))
|
||||
result["message"] = str(error_obj.get("message", ""))
|
||||
|
||||
# AWS 格式: {"error": {"__type": "...", "message": "...", "reason": "..."}}
|
||||
# __type 直接在 error 对象中,而不是嵌套在 message 里
|
||||
if "__type" in error_obj:
|
||||
result["type"] = result["type"] or str(error_obj.get("__type", ""))
|
||||
if "reason" in error_obj:
|
||||
result["reason"] = str(error_obj.get("reason", ""))
|
||||
if "code" in error_obj:
|
||||
result["reason"] = result["reason"] or str(error_obj.get("code", ""))
|
||||
|
||||
# 嵌套 JSON 格式: message 字段本身是 JSON 字符串
|
||||
# 支持多种嵌套格式:
|
||||
# - AWS: {"__type": "...", "message": "...", "reason": "..."}
|
||||
# - 第三方代理: {"error": {"type": "...", "message": "..."}}
|
||||
if result["message"].startswith("{"):
|
||||
try:
|
||||
nested = json.loads(result["message"])
|
||||
if isinstance(nested, dict):
|
||||
# AWS 格式
|
||||
if "__type" in nested:
|
||||
result["type"] = result["type"] or str(nested.get("__type", ""))
|
||||
result["message"] = str(nested.get("message", result["message"]))
|
||||
result["reason"] = str(nested.get("reason", ""))
|
||||
# 第三方代理格式: {"error": {"message": "..."}}
|
||||
elif isinstance(nested.get("error"), dict):
|
||||
inner_error = nested["error"]
|
||||
inner_msg = str(inner_error.get("message", ""))
|
||||
if inner_msg:
|
||||
result["message"] = inner_msg
|
||||
# 简单格式: {"message": "..."}
|
||||
elif "message" in nested:
|
||||
result["message"] = str(nested["message"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 格式 2: {"error": "..."}
|
||||
elif isinstance(data.get("error"), str):
|
||||
result["message"] = str(data["error"])
|
||||
|
||||
# 格式 3: {"errorMessage": "..."} (Lambda)
|
||||
elif "errorMessage" in data:
|
||||
result["message"] = str(data["errorMessage"])
|
||||
|
||||
# 格式 4: {"message": "...", "reason": "..."}
|
||||
elif "message" in data:
|
||||
result["message"] = str(data["message"])
|
||||
result["reason"] = str(data.get("reason", ""))
|
||||
|
||||
# 提取顶层的 reason/code
|
||||
if not result["reason"]:
|
||||
result["reason"] = str(data.get("reason", data.get("code", "")))
|
||||
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
result["message"] = error_text[:500] if len(error_text) > 500 else error_text
|
||||
|
||||
return result
|
||||
|
||||
def _is_client_error(self, error_text: Optional[str]) -> bool:
|
||||
"""
|
||||
检测错误响应是否为客户端错误(不应重试)
|
||||
|
||||
判断逻辑:
|
||||
判断逻辑(按优先级):
|
||||
1. 检查 error.type 是否为已知的客户端错误类型
|
||||
2. 检查错误文本是否包含已知的客户端错误模式
|
||||
2. 检查 reason/code 是否为已知的客户端错误原因
|
||||
3. 回退到关键词匹配
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
@@ -132,67 +259,72 @@ class ErrorClassifier:
|
||||
if not error_text:
|
||||
return False
|
||||
|
||||
# 尝试解析 JSON 并检查 error type
|
||||
try:
|
||||
data = json.loads(error_text)
|
||||
if isinstance(data.get("error"), dict):
|
||||
error_type = data["error"].get("type", "")
|
||||
if error_type and any(
|
||||
t.lower() in error_type.lower() for t in self.CLIENT_ERROR_TYPES
|
||||
):
|
||||
return True
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
pass
|
||||
parsed = self._parse_error_response(error_text)
|
||||
|
||||
# 回退到关键词匹配
|
||||
error_lower = error_text.lower()
|
||||
return any(pattern.lower() in error_lower for pattern in self.CLIENT_ERROR_PATTERNS)
|
||||
# 1. 检查 error type
|
||||
if parsed["type"]:
|
||||
error_type_lower = parsed["type"].lower()
|
||||
if any(t.lower() in error_type_lower for t in self.CLIENT_ERROR_TYPES):
|
||||
return True
|
||||
|
||||
def _extract_error_message(self, error_text: Optional[str]) -> Optional[str]:
|
||||
# 2. 检查 reason/code
|
||||
if parsed["reason"]:
|
||||
reason_upper = parsed["reason"].upper()
|
||||
if any(r in reason_upper for r in self.CLIENT_ERROR_REASONS):
|
||||
return True
|
||||
|
||||
# 3. 回退到关键词匹配(合并 message 和 raw)
|
||||
search_text = f"{parsed['message']} {parsed['raw']}".lower()
|
||||
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 兼容性错误(应触发故障转移)
|
||||
|
||||
支持格式:
|
||||
- {"error": {"message": "..."}} (OpenAI/Claude)
|
||||
- {"error": {"type": "...", "message": "..."}}
|
||||
- {"error": "..."}
|
||||
- {"message": "..."}
|
||||
这类错误是因为 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]:
|
||||
"""
|
||||
从错误响应中提取错误消息
|
||||
|
||||
Args:
|
||||
error_text: 错误响应文本
|
||||
|
||||
Returns:
|
||||
提取的错误消息
|
||||
"""
|
||||
if not error_text:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(error_text)
|
||||
parsed = self._parse_error_response(error_text)
|
||||
|
||||
# {"error": {"message": "..."}} 或 {"error": {"type": "...", "message": "..."}}
|
||||
if isinstance(data.get("error"), dict):
|
||||
error_obj = data["error"]
|
||||
message = error_obj.get("message", "")
|
||||
error_type = error_obj.get("type", "")
|
||||
if message:
|
||||
if error_type:
|
||||
return f"{error_type}: {message}"
|
||||
return str(message)
|
||||
# 构建可读的错误消息
|
||||
parts = []
|
||||
if parsed["type"]:
|
||||
parts.append(parsed["type"])
|
||||
if parsed["reason"]:
|
||||
parts.append(f"[{parsed['reason']}]")
|
||||
if parsed["message"]:
|
||||
parts.append(parsed["message"])
|
||||
|
||||
# {"error": "..."}
|
||||
if isinstance(data.get("error"), str):
|
||||
return str(data["error"])
|
||||
|
||||
# {"message": "..."}
|
||||
if isinstance(data.get("message"), str):
|
||||
return str(data["message"])
|
||||
|
||||
except (json.JSONDecodeError, TypeError, KeyError):
|
||||
pass
|
||||
if parts:
|
||||
return ": ".join(parts) if len(parts) > 1 else parts[0]
|
||||
|
||||
# 无法解析,返回原始文本(截断)
|
||||
return error_text[:500] if len(error_text) > 500 else error_text
|
||||
return parsed["raw"][:500] if len(parsed["raw"]) > 500 else parsed["raw"]
|
||||
|
||||
def classify(
|
||||
self,
|
||||
@@ -328,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 错误:检查是否为客户端请求错误(不应重试)
|
||||
if status == 400 and self._is_client_error(error_response_text):
|
||||
logger.info(f"检测到客户端请求错误,不进行重试: {extracted_message}")
|
||||
|
||||
@@ -427,6 +427,9 @@ class FallbackOrchestrator:
|
||||
)
|
||||
# str(cause) 可能为空(如 httpx 超时异常),使用 repr() 作为备用
|
||||
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(
|
||||
db=self.db,
|
||||
candidate_id=candidate_record_id,
|
||||
@@ -439,6 +442,9 @@ class FallbackOrchestrator:
|
||||
|
||||
# 未知错误:记录失败并抛出
|
||||
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(
|
||||
db=self.db,
|
||||
candidate_id=candidate_record_id,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
"""响应标准化服务,用于 STANDARD 模式下的响应格式验证和补全"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.models.claude import ClaudeResponse
|
||||
|
||||
|
||||
|
||||
class ResponseNormalizer:
|
||||
"""响应标准化器 - 用于标准模式下验证和补全响应字段"""
|
||||
|
||||
@staticmethod
|
||||
def normalize_claude_response(
|
||||
response_data: Dict[str, Any], request_id: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
标准化 Claude API 响应
|
||||
|
||||
Args:
|
||||
response_data: 原始响应数据
|
||||
request_id: 请求ID(用于日志)
|
||||
|
||||
Returns:
|
||||
标准化后的响应数据(失败时返回原始数据)
|
||||
"""
|
||||
if "error" in response_data:
|
||||
logger.debug(f"[ResponseNormalizer] 检测到错误响应,跳过标准化 | ID:{request_id}")
|
||||
return response_data
|
||||
|
||||
try:
|
||||
validated = ClaudeResponse.model_validate(response_data)
|
||||
normalized = validated.model_dump(mode="json", exclude_none=False)
|
||||
|
||||
logger.debug(f"[ResponseNormalizer] 响应标准化成功 | ID:{request_id}")
|
||||
return normalized
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[ResponseNormalizer] 响应验证失败,透传原始数据 | ID:{request_id}")
|
||||
return response_data
|
||||
|
||||
@staticmethod
|
||||
def should_normalize(response_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
判断是否需要进行标准化
|
||||
|
||||
Args:
|
||||
response_data: 响应数据
|
||||
|
||||
Returns:
|
||||
是否需要标准化
|
||||
"""
|
||||
# 错误响应不需要标准化
|
||||
if "error" in response_data:
|
||||
return False
|
||||
|
||||
# 已经包含新字段的响应不需要再次标准化
|
||||
if "context_management" in response_data and "container" in response_data:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -5,6 +5,10 @@
|
||||
- 使用滑动窗口采样,容忍并发波动
|
||||
- 基于窗口内高利用率采样比例决策,而非要求连续高利用率
|
||||
- 增加探测性扩容机制,长时间稳定时主动尝试扩容
|
||||
|
||||
AIMD 参数说明:
|
||||
- 扩容:加性增加 (+INCREASE_STEP)
|
||||
- 缩容:乘性减少 (*DECREASE_MULTIPLIER,默认 0.85)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
@@ -34,7 +38,7 @@ class AdaptiveConcurrencyManager:
|
||||
核心算法:基于滑动窗口利用率的 AIMD
|
||||
- 滑动窗口记录最近 N 次请求的利用率
|
||||
- 当窗口内高利用率采样比例 >= 60% 时触发扩容
|
||||
- 遇到 429 错误时乘性减少 (*0.7)
|
||||
- 遇到 429 错误时乘性减少 (*0.85)
|
||||
- 长时间无 429 且有流量时触发探测性扩容
|
||||
|
||||
扩容条件(满足任一即可):
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import timedelta # noqa: F401 - kept for potential future use
|
||||
from typing import Optional, Tuple
|
||||
@@ -40,6 +39,7 @@ class ConcurrencyManager:
|
||||
self._memory_lock: asyncio.Lock = asyncio.Lock()
|
||||
self._memory_endpoint_counts: dict[str, int] = {}
|
||||
self._memory_key_counts: dict[str, int] = {}
|
||||
self._owns_redis: bool = False
|
||||
self._memory_initialized = True
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@@ -47,41 +47,29 @@ class ConcurrencyManager:
|
||||
if self._redis is not None:
|
||||
return
|
||||
|
||||
# 优先使用 REDIS_URL,如果没有则根据密码构建 URL
|
||||
redis_url = os.getenv("REDIS_URL")
|
||||
|
||||
if not redis_url:
|
||||
# 本地开发模式:从 REDIS_PASSWORD 构建 URL
|
||||
redis_password = os.getenv("REDIS_PASSWORD")
|
||||
if redis_password:
|
||||
redis_url = f"redis://:{redis_password}@localhost:6379/0"
|
||||
else:
|
||||
redis_url = "redis://localhost:6379/0"
|
||||
|
||||
try:
|
||||
self._redis = await aioredis.from_url(
|
||||
redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_timeout=5.0,
|
||||
socket_connect_timeout=5.0,
|
||||
)
|
||||
# 测试连接
|
||||
await self._redis.ping()
|
||||
# 脱敏显示(隐藏密码)
|
||||
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url
|
||||
logger.info(f"[OK] Redis 连接成功: {safe_url}")
|
||||
# 复用全局 Redis 客户端(带熔断/降级),避免重复创建连接池
|
||||
from src.clients.redis_client import get_redis_client
|
||||
|
||||
self._redis = await get_redis_client(require_redis=False)
|
||||
self._owns_redis = False
|
||||
if self._redis:
|
||||
logger.info("[OK] ConcurrencyManager 已复用全局 Redis 客户端")
|
||||
else:
|
||||
logger.warning("[WARN] Redis 不可用,并发控制降级为内存模式(仅在单实例环境下安全)")
|
||||
except Exception as e:
|
||||
logger.error(f"[ERROR] Redis 连接失败: {e}")
|
||||
logger.warning("[WARN] 并发控制将被禁用(仅在单实例环境下安全)")
|
||||
logger.error(f"[ERROR] 获取全局 Redis 客户端失败: {e}")
|
||||
logger.warning("[WARN] 并发控制将降级为内存模式(仅在单实例环境下安全)")
|
||||
self._redis = None
|
||||
self._owns_redis = False
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 Redis 连接"""
|
||||
if self._redis:
|
||||
if self._redis and self._owns_redis:
|
||||
await self._redis.close()
|
||||
self._redis = None
|
||||
logger.info("Redis 连接已关闭")
|
||||
logger.info("ConcurrencyManager Redis 连接已关闭")
|
||||
self._redis = None
|
||||
self._owns_redis = False
|
||||
|
||||
def _get_endpoint_key(self, endpoint_id: str) -> str:
|
||||
"""获取 Endpoint 并发计数的 Redis Key"""
|
||||
|
||||
@@ -3,7 +3,7 @@ RPM (Requests Per Minute) 限流服务
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -72,11 +72,7 @@ class RPMLimiter:
|
||||
# 获取当前分钟窗口
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now.replace(second=0, microsecond=0)
|
||||
window_end = (
|
||||
window_start.replace(minute=window_start.minute + 1)
|
||||
if window_start.minute < 59
|
||||
else window_start.replace(hour=window_start.hour + 1, minute=0)
|
||||
)
|
||||
window_end = window_start + timedelta(minutes=1)
|
||||
|
||||
# 查找或创建追踪记录
|
||||
tracking = (
|
||||
|
||||
@@ -289,11 +289,17 @@ class RequestResult:
|
||||
status_code = 500
|
||||
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(
|
||||
status=RequestStatus.FAILED,
|
||||
metadata=metadata,
|
||||
status_code=status_code,
|
||||
error_message=str(exception),
|
||||
error_message=error_message,
|
||||
error_type=error_type,
|
||||
response_time_ms=response_time_ms,
|
||||
is_stream=is_stream,
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
|
||||
from src.core.logger import logger
|
||||
from src.database import get_db
|
||||
from src.models.database import AuditEventType, AuditLog
|
||||
from src.utils.transaction_manager import transactional
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +18,13 @@ from src.utils.transaction_manager import transactional
|
||||
|
||||
|
||||
class AuditService:
|
||||
"""审计服务"""
|
||||
"""审计服务
|
||||
|
||||
事务策略:本服务不负责事务提交,由中间件统一管理。
|
||||
所有方法只做 db.add/flush,提交由请求结束时的中间件处理。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@transactional(commit=False) # 不自动提交,让调用方决定
|
||||
def log_event(
|
||||
db: Session,
|
||||
event_type: AuditEventType,
|
||||
@@ -54,47 +56,44 @@ class AuditService:
|
||||
|
||||
Returns:
|
||||
审计日志记录
|
||||
|
||||
Note:
|
||||
不在此方法内提交事务,由调用方或中间件统一管理。
|
||||
"""
|
||||
try:
|
||||
audit_log = AuditLog(
|
||||
event_type=event_type.value,
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
status_code=status_code,
|
||||
error_message=error_message,
|
||||
event_metadata=metadata,
|
||||
)
|
||||
audit_log = AuditLog(
|
||||
event_type=event_type.value,
|
||||
description=description,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_id=request_id,
|
||||
status_code=status_code,
|
||||
error_message=error_message,
|
||||
event_metadata=metadata,
|
||||
)
|
||||
|
||||
db.add(audit_log)
|
||||
db.commit() # 立即提交事务,释放数据库锁
|
||||
db.refresh(audit_log)
|
||||
db.add(audit_log)
|
||||
# 使用 flush 使记录可见但不提交事务,事务由中间件统一管理
|
||||
db.flush()
|
||||
|
||||
# 同时记录到系统日志
|
||||
log_message = (
|
||||
f"AUDIT [{event_type.value}] - {description} | "
|
||||
f"user_id={user_id}, ip={ip_address}"
|
||||
)
|
||||
# 同时记录到系统日志
|
||||
log_message = (
|
||||
f"AUDIT [{event_type.value}] - {description} | "
|
||||
f"user_id={user_id}, ip={ip_address}"
|
||||
)
|
||||
|
||||
if event_type in [
|
||||
AuditEventType.UNAUTHORIZED_ACCESS,
|
||||
AuditEventType.SUSPICIOUS_ACTIVITY,
|
||||
]:
|
||||
logger.warning(log_message)
|
||||
elif event_type in [AuditEventType.LOGIN_FAILED, AuditEventType.REQUEST_FAILED]:
|
||||
logger.info(log_message)
|
||||
else:
|
||||
logger.debug(log_message)
|
||||
if event_type in [
|
||||
AuditEventType.UNAUTHORIZED_ACCESS,
|
||||
AuditEventType.SUSPICIOUS_ACTIVITY,
|
||||
]:
|
||||
logger.warning(log_message)
|
||||
elif event_type in [AuditEventType.LOGIN_FAILED, AuditEventType.REQUEST_FAILED]:
|
||||
logger.info(log_message)
|
||||
else:
|
||||
logger.debug(log_message)
|
||||
|
||||
return audit_log
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log audit event: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
return audit_log
|
||||
|
||||
@staticmethod
|
||||
def log_login_attempt(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user