mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c83453d06 |
@@ -1,18 +1,12 @@
|
|||||||
# Build artifacts
|
# Build artifacts
|
||||||
build/
|
build/
|
||||||
target/
|
|
||||||
*.so
|
*.so
|
||||||
*.egg
|
*.egg
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
aether-vscodex/web/node_modules/
|
|
||||||
aether-vscodex/web/dist/
|
|
||||||
aether-vscodex/vscode-extension/node_modules/
|
|
||||||
aether-vscodex/vscode-extension/dist/
|
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
.git/
|
.git/
|
||||||
|
|||||||
+11
-84
@@ -4,22 +4,6 @@
|
|||||||
# 应用端口(默认 8084)
|
# 应用端口(默认 8084)
|
||||||
APP_PORT=8084
|
APP_PORT=8084
|
||||||
|
|
||||||
# 对外访问地址,用于一键安装、CC Switch 导入、支付回调等需要生成公网 URL 的场景。
|
|
||||||
# 生产环境建议显式配置为不带内部端口的公网域名,例如 https://aether.example.com
|
|
||||||
# AETHER_PUBLIC_BASE_URL=https://aether.example.com
|
|
||||||
|
|
||||||
# Docker Compose 镜像(默认正式版 latest;提前测试可改 rc/beta;也可固定具体版本)
|
|
||||||
# 示例:
|
|
||||||
# APP_IMAGE=ghcr.io/fawney19/aether:latest
|
|
||||||
# APP_IMAGE=ghcr.io/fawney19/aether:rc
|
|
||||||
# APP_IMAGE=ghcr.io/fawney19/aether:beta
|
|
||||||
# APP_IMAGE=ghcr.io/fawney19/aether:0.7.0-rc.1
|
|
||||||
|
|
||||||
# Compose 应用容器的非 root 数字身份。
|
|
||||||
# install.sh 会自动写入安装用户的 UID/GID。
|
|
||||||
AETHER_CONTAINER_UID=65532
|
|
||||||
AETHER_CONTAINER_GID=65532
|
|
||||||
|
|
||||||
# API Key 前缀(默认 sk)
|
# API Key 前缀(默认 sk)
|
||||||
API_KEY_PREFIX=sk
|
API_KEY_PREFIX=sk
|
||||||
|
|
||||||
@@ -35,17 +19,17 @@ RUST_LOG=aether_gateway=info
|
|||||||
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
# AUTH_REFRESH_COOKIE_SAMESITE=None
|
||||||
# AUTH_REFRESH_COOKIE_SECURE=true
|
# AUTH_REFRESH_COOKIE_SECURE=true
|
||||||
|
|
||||||
# 数据库配置(仅支持 PostgreSQL)
|
# 数据库配置
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_NAME=aether
|
DB_NAME=aether
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=aether
|
||||||
|
|
||||||
# Redis 配置
|
# Redis 配置
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=aether
|
||||||
|
|
||||||
# JWT密钥(使用 ./generate_keys.sh 生成)
|
# JWT密钥(使用 ./generate_keys.sh 生成)
|
||||||
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
# 用于用户登录 token 签名,更换后所有用户需重新登录
|
||||||
@@ -55,75 +39,18 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
|
|||||||
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
|
||||||
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
ENCRYPTION_KEY=change-this-to-another-secure-random-string
|
||||||
|
|
||||||
# S3 备份的独立加密密钥(推荐)。未配置时为兼容旧部署,会回退到 ENCRYPTION_KEY。
|
|
||||||
# 密钥轮换前必须保留旧值,离线恢复工具需要它解密历史备份。
|
|
||||||
# AETHER_BACKUP_ENCRYPTION_KEY=change-this-to-a-dedicated-secure-random-string
|
|
||||||
|
|
||||||
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
# 启动自举管理员(仅在当前库里还没有活动管理员时生效)
|
||||||
# 首次启动前必须设置 ADMIN_PASSWORD;install.sh 首次生成配置时会提示输入。
|
# 手动部署时取消注释并设置;install.sh 首次生成配置时会提示输入。
|
||||||
ADMIN_EMAIL=[email protected]
|
ADMIN_EMAIL=[email protected]
|
||||||
ADMIN_USERNAME=admin123456
|
ADMIN_USERNAME=admin
|
||||||
# ADMIN_PASSWORD=
|
# ADMIN_PASSWORD=
|
||||||
|
|
||||||
# ==================== 可选配置(有默认值) ====================
|
# ==================== 可选配置(有默认值) ====================
|
||||||
|
|
||||||
# 可信反向代理 IP/CIDR,只有这些来源发送的 X-Real-IP / X-Forwarded-For 会被采用。
|
# docker compose 下 app 启动前自动执行 pending migration/backfill(默认 true)
|
||||||
# 默认仅信任本机回环代理:127.0.0.0/8,::1/128。
|
# AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true
|
||||||
# Docker/Nginx 位于独立容器时,请按实际容器网络设置,例如:172.16.0.0/12。
|
|
||||||
# AETHER_TRUSTED_PROXY_CIDRS=127.0.0.0/8,::1/128,172.16.0.0/12
|
|
||||||
|
|
||||||
# VS Code Codex 云端协同(仅在叠加 aether-vscodex/docker-compose.aether.yml 时需要)
|
# PostgreSQL 连接池配置(默认适合单实例/小型部署;高并发可按需调大)
|
||||||
# 内部 token 至少 24 字节,建议使用:openssl rand -base64 32
|
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=1
|
||||||
# AETHER_VSCODEX_INTERNAL_TOKEN=replace-with-a-long-random-secret
|
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=20
|
||||||
# AETHER_VSCODEX_PUBLIC_WS_URL=wss://aether.example.com/api/vscodex/ws
|
# AETHER_GATEWAY_DATA_POSTGRES_IDLE_TIMEOUT_MS=30000
|
||||||
# AETHER_VSCODEX_ALLOWED_ORIGINS=https://aether.example.com
|
|
||||||
|
|
||||||
# 启动时的数据库准备策略:auto(默认)或 verify-only
|
|
||||||
# AETHER_GATEWAY_DATABASE_MODE=auto
|
|
||||||
|
|
||||||
# PostgreSQL 连接池配置(默认每核 4 条、总池至少 32 条且最多 100 条;多实例部署应显式分配每实例预算)
|
|
||||||
# AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
|
||||||
# AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
|
||||||
# AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS=2048
|
|
||||||
# AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB=256
|
|
||||||
# 请求体完整读取总超时默认关闭;确需限制时配置 1000-600000 毫秒的非零值。
|
|
||||||
# AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS=0
|
|
||||||
# 单请求解压后 Payload 上限(MiB),默认 256;显式设为 0 才表示不限制。
|
|
||||||
# AETHER_MAX_REQUEST_BODY_MB=256
|
|
||||||
# AETHER_GATEWAY_SECURITY_CACHE_TTL_MS=1000
|
|
||||||
# AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB=64
|
|
||||||
# AETHER_MAX_INTERNAL_BUFFERED_BODY_MB=64
|
|
||||||
# AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY=1024
|
|
||||||
# Tunnel relay 使用的独立 HMAC 密钥。启用 HTTP tunnel relay 或多网关 owner 转发时必须配置,
|
|
||||||
# 所有网关实例必须使用同一个至少 32 字节的随机值;不要复用 JWT 或数据加密密钥。
|
|
||||||
# AETHER_TUNNEL_RELAY_AUTH_SECRET=
|
|
||||||
# 旧版 /api/internal/gateway/* 控制面默认关闭。确需独立服务调用时,配置至少 32 字节的
|
|
||||||
# 独立 HMAC 密钥;不要复用 JWT、数据加密或 tunnel relay 密钥。多节点必须使用相同值和共享 Redis。
|
|
||||||
# AETHER_INTERNAL_GATEWAY_AUTH_SECRET=
|
|
||||||
# 远程 relay 地址必须使用 HTTPS;HTTP 仅允许 localhost 或回环 IP。
|
|
||||||
# AETHER_TUNNEL_RELAY_BASE_URL=https://gateway-a.example.com
|
|
||||||
# 跨网关 relay 解析到受控私有地址时才显式开启;默认关闭以防止被篡改的 attachment
|
|
||||||
# 记录诱导网关向内网转发 relay 凭据。该开关不放宽普通 provider 的目标地址策略。
|
|
||||||
# AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS=false
|
|
||||||
# 更推荐按 relay 主机名精确放行私网部署(逗号分隔,大小写不敏感);不支持通配符/后缀。
|
|
||||||
# AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST=gateway-a.internal,gateway-b.internal
|
|
||||||
# Bark 自建服务默认仅允许公网 HTTPS。确需明文 HTTP 或内网目标时分别显式开启:
|
|
||||||
# AETHER_BARK_ALLOW_HTTP=false
|
|
||||||
# AETHER_BARK_ALLOW_PRIVATE_TARGETS=false
|
|
||||||
|
|
||||||
# 可选 Provider OAuth 客户端。使用 Gemini CLI / Antigravity 浏览器授权时必须配置
|
|
||||||
# 对应的 client secret;client ID 未配置时使用内置的公开 native-app client ID。
|
|
||||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_ID=
|
|
||||||
# AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET=
|
|
||||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID=
|
|
||||||
# AETHER_ANTIGRAVITY_OAUTH_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# PostgreSQL 容器调优:docker-compose.yml 已内置通用默认值,通常不用配置。
|
|
||||||
# 只有在 Postgres 独占大内存、或压测显示 DB 缓存/排序/维护任务成为瓶颈时再覆盖。
|
|
||||||
# 内置默认:shared_buffers=1GB, effective_cache_size=3GB, shm_size=512mb,
|
|
||||||
# work_mem=16MB, maintenance_work_mem=256MB。
|
|
||||||
# POSTGRES_SHARED_BUFFERS=8GB
|
|
||||||
# POSTGRES_EFFECTIVE_CACHE_SIZE=24GB
|
|
||||||
# POSTGRES_SHM_SIZE=2gb
|
|
||||||
# POSTGRES_WORK_MEM=16MB
|
|
||||||
# POSTGRES_MAINTENANCE_WORK_MEM=1GB
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
name: Build aether-proxy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['proxy-v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-proxy-${{ github.ref }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Ensure proxy tag matches Cargo version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
EXPECTED="${TAG#proxy-v}"
|
||||||
|
ACTUAL="$(cargo metadata --manifest-path apps/aether-proxy/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-proxy") | .version')"
|
||||||
|
|
||||||
|
echo "tag version: ${EXPECTED}"
|
||||||
|
echo "cargo version: ${ACTUAL}"
|
||||||
|
|
||||||
|
if [ -z "${ACTUAL}" ]; then
|
||||||
|
echo "Could not resolve aether-proxy package version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
|
||||||
|
echo "proxy tag ${TAG} does not match apps/aether-proxy/Cargo.toml version ${ACTUAL}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: preflight
|
||||||
|
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: linux-amd64
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
use_cross: true
|
||||||
|
- name: linux-arm64
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
os: ubuntu-latest
|
||||||
|
use_cross: true
|
||||||
|
- name: linux-musl-amd64
|
||||||
|
target: x86_64-unknown-linux-musl
|
||||||
|
os: ubuntu-latest
|
||||||
|
use_cross: true
|
||||||
|
- name: linux-musl-arm64
|
||||||
|
target: aarch64-unknown-linux-musl
|
||||||
|
os: ubuntu-latest
|
||||||
|
use_cross: true
|
||||||
|
- name: macos-amd64
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
os: macos-15-intel
|
||||||
|
use_cross: false
|
||||||
|
- name: macos-arm64
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
os: macos-15
|
||||||
|
use_cross: false
|
||||||
|
- name: windows-amd64
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
os: windows-latest
|
||||||
|
use_cross: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Ensure Rust target is installed
|
||||||
|
run: rustup target add ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: apps/aether-proxy -> target
|
||||||
|
key: ${{ matrix.target }}
|
||||||
|
|
||||||
|
- name: Install cross
|
||||||
|
if: matrix.use_cross
|
||||||
|
uses: taiki-e/install-action@cross
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
working-directory: apps/aether-proxy
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
||||||
|
cross build --release --target ${{ matrix.target }}
|
||||||
|
else
|
||||||
|
cargo build --release --target ${{ matrix.target }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Package (Unix)
|
||||||
|
if: runner.os != 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd target/${{ matrix.target }}/release
|
||||||
|
chmod +x aether-proxy
|
||||||
|
tar czf ../../../aether-proxy-${{ matrix.name }}.tar.gz aether-proxy
|
||||||
|
|
||||||
|
- name: Package (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
cd target/${{ matrix.target }}/release
|
||||||
|
7z a ../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
|
||||||
|
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v5
|
||||||
|
with:
|
||||||
|
name: aether-proxy-${{ matrix.name }}
|
||||||
|
path: |
|
||||||
|
aether-proxy-*.tar.gz
|
||||||
|
aether-proxy-*.zip
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- name: Download all artifacts
|
||||||
|
uses: actions/download-artifact@v5
|
||||||
|
with:
|
||||||
|
merge-multiple: true
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Generate checksums
|
||||||
|
working-directory: artifacts
|
||||||
|
run: sha256sum aether-proxy-* > SHA256SUMS.txt
|
||||||
|
|
||||||
|
- name: Delete stale draft releases for tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
RELEASE_TAG: ${{ github.ref_name }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
||||||
|
|
||||||
|
if [[ -z "${draft_ids}" ]]; then
|
||||||
|
echo "No stale draft releases for ${RELEASE_TAG}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r release_id; do
|
||||||
|
[[ -z "${release_id}" ]] && continue
|
||||||
|
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
||||||
|
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
||||||
|
done <<< "${draft_ids}"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
name: "${{ github.ref_name }}"
|
||||||
|
generate_release_notes: true
|
||||||
|
files: |
|
||||||
|
artifacts/aether-proxy-*
|
||||||
|
artifacts/SHA256SUMS.txt
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
|
||||||
|
update-readme:
|
||||||
|
needs: release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
ref: aether-rust-pioneer
|
||||||
|
|
||||||
|
- name: Update README download links
|
||||||
|
env:
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
VERSION="${TAG#proxy-v}"
|
||||||
|
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
||||||
|
|
||||||
|
if [ -d apps/aether-proxy ]; then
|
||||||
|
PROXY_DIR="apps/aether-proxy"
|
||||||
|
else
|
||||||
|
PROXY_DIR="aether-proxy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROXY_DIR"
|
||||||
|
|
||||||
|
TABLE="| Platform | Download |\n|----------|----------|\n"
|
||||||
|
TABLE+="| Linux x86_64 (GNU) | [aether-proxy-linux-amd64.tar.gz](${BASE}/aether-proxy-linux-amd64.tar.gz) |\n"
|
||||||
|
TABLE+="| Linux ARM64 (GNU) | [aether-proxy-linux-arm64.tar.gz](${BASE}/aether-proxy-linux-arm64.tar.gz) |\n"
|
||||||
|
TABLE+="| Linux x86_64 (musl) | [aether-proxy-linux-musl-amd64.tar.gz](${BASE}/aether-proxy-linux-musl-amd64.tar.gz) |\n"
|
||||||
|
TABLE+="| Linux ARM64 (musl) | [aether-proxy-linux-musl-arm64.tar.gz](${BASE}/aether-proxy-linux-musl-arm64.tar.gz) |\n"
|
||||||
|
TABLE+="| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](${BASE}/aether-proxy-macos-amd64.tar.gz) |\n"
|
||||||
|
TABLE+="| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](${BASE}/aether-proxy-macos-arm64.tar.gz) |\n"
|
||||||
|
TABLE+="| Windows x86_64 | [aether-proxy-windows-amd64.zip](${BASE}/aether-proxy-windows-amd64.zip) |"
|
||||||
|
|
||||||
|
# Replace content between markers
|
||||||
|
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
||||||
|
awk -v table="$TABLE" '
|
||||||
|
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
||||||
|
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
||||||
|
!skip { print }
|
||||||
|
' README.md > README.tmp && mv README.tmp README.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit and push
|
||||||
|
run: |
|
||||||
|
if [ -d apps/aether-proxy ]; then
|
||||||
|
PROXY_DIR="apps/aether-proxy"
|
||||||
|
else
|
||||||
|
PROXY_DIR="aether-proxy"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$PROXY_DIR"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add README.md
|
||||||
|
git diff --cached --quiet && exit 0
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
git commit -m "chore(proxy): update download links for ${TAG}"
|
||||||
|
git push
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
name: Build aether-tunnel
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags: ['tunnel-v*']
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: build-tunnel-${{ github.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
preflight:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Ensure tunnel tag matches Cargo version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
TAG="${GITHUB_REF_NAME}"
|
|
||||||
EXPECTED="${TAG#tunnel-v}"
|
|
||||||
ACTUAL="$(cargo metadata --manifest-path apps/aether-tunnel/Cargo.toml --locked --no-deps --format-version 1 | jq -r '.packages[] | select(.name == "aether-tunnel") | .version')"
|
|
||||||
|
|
||||||
echo "tag version: ${EXPECTED}"
|
|
||||||
echo "cargo version: ${ACTUAL}"
|
|
||||||
|
|
||||||
if [ -z "${ACTUAL}" ]; then
|
|
||||||
echo "Could not resolve aether-tunnel package version" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "${EXPECTED}" != "${ACTUAL}" ]; then
|
|
||||||
echo "tunnel tag ${TAG} does not match apps/aether-tunnel/Cargo.toml version ${ACTUAL}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: preflight
|
|
||||||
if: always() && (needs.preflight.result == 'success' || needs.preflight.result == 'skipped')
|
|
||||||
name: ${{ matrix.name }}
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: linux-amd64
|
|
||||||
target: x86_64-unknown-linux-gnu
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: linux-arm64
|
|
||||||
target: aarch64-unknown-linux-gnu
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: linux-musl-amd64
|
|
||||||
target: x86_64-unknown-linux-musl
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: linux-musl-arm64
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: macos-amd64
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
os: macos-15-intel
|
|
||||||
use_cross: false
|
|
||||||
- name: macos-arm64
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
os: macos-15
|
|
||||||
use_cross: false
|
|
||||||
- name: windows-amd64
|
|
||||||
target: x86_64-pc-windows-msvc
|
|
||||||
os: windows-latest
|
|
||||||
use_cross: false
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
with:
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Ensure Rust target is installed
|
|
||||||
run: rustup target add ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
workspaces: apps/aether-tunnel -> target
|
|
||||||
key: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Install cross
|
|
||||||
if: matrix.use_cross
|
|
||||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
working-directory: apps/aether-tunnel
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [ "${{ matrix.use_cross }}" = "true" ]; then
|
|
||||||
cross build --release --locked --target ${{ matrix.target }}
|
|
||||||
else
|
|
||||||
cargo build --release --locked --target ${{ matrix.target }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Package (Unix)
|
|
||||||
if: runner.os != 'Windows'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd target/${{ matrix.target }}/release
|
|
||||||
chmod +x aether-tunnel
|
|
||||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel
|
|
||||||
|
|
||||||
- name: Package (Windows)
|
|
||||||
if: runner.os == 'Windows'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
cd target/${{ matrix.target }}/release
|
|
||||||
7z a ../../../aether-tunnel-${{ matrix.name }}.zip aether-tunnel.exe
|
|
||||||
tar czf ../../../aether-tunnel-${{ matrix.name }}.tar.gz aether-tunnel.exe
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
with:
|
|
||||||
name: aether-tunnel-${{ matrix.name }}
|
|
||||||
path: |
|
|
||||||
aether-tunnel-*.tar.gz
|
|
||||||
aether-tunnel-*.zip
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
release:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
attestations: write
|
|
||||||
contents: write
|
|
||||||
id-token: write
|
|
||||||
steps:
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
|
||||||
with:
|
|
||||||
merge-multiple: true
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
working-directory: artifacts
|
|
||||||
run: sha256sum aether-tunnel-* > SHA256SUMS.txt
|
|
||||||
|
|
||||||
- name: Attest tunnel release provenance
|
|
||||||
id: attest-release
|
|
||||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
artifacts/aether-tunnel-*.tar.gz
|
|
||||||
artifacts/aether-tunnel-*.zip
|
|
||||||
artifacts/SHA256SUMS.txt
|
|
||||||
|
|
||||||
- name: Bundle tunnel release provenance
|
|
||||||
env:
|
|
||||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
|
||||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
|
||||||
|
|
||||||
- name: Delete stale draft releases for tag
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
RELEASE_TAG: ${{ github.ref_name }}
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
draft_ids="$(gh api "repos/${REPOSITORY}/releases" --paginate --jq '.[] | select(.tag_name == env.RELEASE_TAG and .draft == true) | .id')"
|
|
||||||
|
|
||||||
if [[ -z "${draft_ids}" ]]; then
|
|
||||||
echo "No stale draft releases for ${RELEASE_TAG}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r release_id; do
|
|
||||||
[[ -z "${release_id}" ]] && continue
|
|
||||||
echo "Deleting stale draft release ${release_id} for ${RELEASE_TAG}"
|
|
||||||
gh api -X DELETE "repos/${REPOSITORY}/releases/${release_id}"
|
|
||||||
done <<< "${draft_ids}"
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
|
||||||
with:
|
|
||||||
name: "${{ github.ref_name }}"
|
|
||||||
generate_release_notes: true
|
|
||||||
files: |
|
|
||||||
artifacts/aether-tunnel-*
|
|
||||||
artifacts/AETHER_TUNNEL_RELEASE_PROVENANCE.sigstore.json
|
|
||||||
artifacts/SHA256SUMS.txt
|
|
||||||
fail_on_unmatched_files: true
|
|
||||||
|
|
||||||
update-readme:
|
|
||||||
needs: release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: main
|
|
||||||
|
|
||||||
- name: Update README download links
|
|
||||||
env:
|
|
||||||
TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
VERSION="${TAG#tunnel-v}"
|
|
||||||
BASE="https://github.com/fawney19/Aether/releases/download/${TAG}"
|
|
||||||
|
|
||||||
if [ -d apps/aether-tunnel ]; then
|
|
||||||
TUNNEL_DIR="apps/aether-tunnel"
|
|
||||||
else
|
|
||||||
TUNNEL_DIR="aether-tunnel"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$TUNNEL_DIR"
|
|
||||||
|
|
||||||
TABLE="| Platform | Download |\n|----------|----------|\n"
|
|
||||||
TABLE+="| Linux x86_64 (GNU) | [aether-tunnel-linux-amd64.tar.gz](${BASE}/aether-tunnel-linux-amd64.tar.gz) |\n"
|
|
||||||
TABLE+="| Linux ARM64 (GNU) | [aether-tunnel-linux-arm64.tar.gz](${BASE}/aether-tunnel-linux-arm64.tar.gz) |\n"
|
|
||||||
TABLE+="| Linux x86_64 (musl) | [aether-tunnel-linux-musl-amd64.tar.gz](${BASE}/aether-tunnel-linux-musl-amd64.tar.gz) |\n"
|
|
||||||
TABLE+="| Linux ARM64 (musl) | [aether-tunnel-linux-musl-arm64.tar.gz](${BASE}/aether-tunnel-linux-musl-arm64.tar.gz) |\n"
|
|
||||||
TABLE+="| macOS x86_64 | [aether-tunnel-macos-amd64.tar.gz](${BASE}/aether-tunnel-macos-amd64.tar.gz) |\n"
|
|
||||||
TABLE+="| macOS ARM64 | [aether-tunnel-macos-arm64.tar.gz](${BASE}/aether-tunnel-macos-arm64.tar.gz) |\n"
|
|
||||||
TABLE+="| Windows x86_64 | [aether-tunnel-windows-amd64.zip](${BASE}/aether-tunnel-windows-amd64.zip) |"
|
|
||||||
|
|
||||||
# Replace content between markers
|
|
||||||
if grep -q '<!-- DOWNLOAD_TABLE_START -->' README.md; then
|
|
||||||
awk -v table="$TABLE" '
|
|
||||||
/<!-- DOWNLOAD_TABLE_START -->/ { print; printf "%s\n", table; skip=1; next }
|
|
||||||
/<!-- DOWNLOAD_TABLE_END -->/ { skip=0 }
|
|
||||||
!skip { print }
|
|
||||||
' README.md > README.tmp && mv README.tmp README.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Commit and push
|
|
||||||
run: |
|
|
||||||
if [ -d apps/aether-tunnel ]; then
|
|
||||||
TUNNEL_DIR="apps/aether-tunnel"
|
|
||||||
else
|
|
||||||
TUNNEL_DIR="aether-tunnel"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$TUNNEL_DIR"
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git add README.md
|
|
||||||
git diff --cached --quiet && exit 0
|
|
||||||
TAG="${GITHUB_REF#refs/tags/}"
|
|
||||||
git commit -m "chore(tunnel): update download links for ${TAG}"
|
|
||||||
git push
|
|
||||||
@@ -7,59 +7,25 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
preflight:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
deploy_pages: ${{ steps.classify.outputs.deploy_pages }}
|
|
||||||
steps:
|
|
||||||
- name: Ensure stable Pages release tag
|
|
||||||
id: classify
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
echo "deploy_pages=false" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
|
||||||
echo "Manual Pages deployment."
|
|
||||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
|
||||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
|
||||||
echo "Skipping Pages deploy for non-stable release tag: ${tag}"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "deploy_pages=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: preflight
|
|
||||||
if: needs.preflight.outputs.deploy_pages == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: |
|
cache-dependency-path: frontend/package-lock.json
|
||||||
frontend/package-lock.json
|
|
||||||
aether-vscodex/web/package-lock.json
|
|
||||||
|
|
||||||
- name: Build aether-vscodex web
|
|
||||||
working-directory: aether-vscodex/web
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -75,10 +41,10 @@ jobs:
|
|||||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: frontend/dist
|
path: frontend/dist
|
||||||
|
|
||||||
@@ -88,10 +54,7 @@ jobs:
|
|||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
pages: write
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
id: deployment
|
id: deployment
|
||||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
uses: actions/deploy-pages@v4
|
||||||
|
|||||||
@@ -1,620 +0,0 @@
|
|||||||
name: Nightly Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
# 02:17 Asia/Shanghai (18:17 UTC) every day.
|
|
||||||
schedule:
|
|
||||||
- cron: '17 18 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Checks and builds only need read access. Publishing jobs opt into write access
|
|
||||||
# below so a failed build cannot modify the existing nightly release.
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
# A rolling tag and image are shared by scheduled and manually retried runs.
|
|
||||||
# Keep GitHub Release immutability disabled for this repository: the tag and
|
|
||||||
# assets intentionally move after each successful daily build.
|
|
||||||
concurrency:
|
|
||||||
group: nightly-main
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_INCREMENTAL: '0'
|
|
||||||
CARGO_PROFILE_DEV_DEBUG: '0'
|
|
||||||
CARGO_PROFILE_TEST_DEBUG: '0'
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
RUST_BACKTRACE: '1'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
source:
|
|
||||||
name: Resolve main snapshot
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
outputs:
|
|
||||||
sha: ${{ steps.snapshot.outputs.sha }}
|
|
||||||
short_sha: ${{ steps.snapshot.outputs.short_sha }}
|
|
||||||
date: ${{ steps.snapshot.outputs.date }}
|
|
||||||
ghcr_image: ${{ steps.snapshot.outputs.ghcr_image }}
|
|
||||||
steps:
|
|
||||||
- name: Require main branch
|
|
||||||
id: snapshot
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
|
||||||
echo "Nightly releases must run from refs/heads/main (got ${GITHUB_REF})." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sha="${GITHUB_SHA}"
|
|
||||||
# Docker 镜像仓库名必须全小写;GitHub owner 可能保留大写,先统一规范化。
|
|
||||||
repository_owner="${GITHUB_REPOSITORY%%/*}"
|
|
||||||
repository_owner="${repository_owner,,}"
|
|
||||||
echo "sha=${sha}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "short_sha=${sha:0:7}" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "date=$(date -u +'%Y-%m-%d')" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "ghcr_image=ghcr.io/${repository_owner}/aether" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "Building main at ${sha}."
|
|
||||||
|
|
||||||
# Keep the scheduled backend coverage in one place so it cannot drift from PR CI.
|
|
||||||
rust_ci:
|
|
||||||
name: Rust CI
|
|
||||||
needs: source
|
|
||||||
uses: ./.github/workflows/rust-ci.yml
|
|
||||||
|
|
||||||
rust_extended:
|
|
||||||
name: Rust extended checks
|
|
||||||
needs: source
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 90
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Install pinned Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
|
||||||
|
|
||||||
- name: Show Rust toolchain
|
|
||||||
run: rustc -Vv
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
shared-key: nightly-rust-1.95-${{ runner.os }}
|
|
||||||
workspaces: . -> target
|
|
||||||
|
|
||||||
- name: Setup sccache
|
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
|
||||||
|
|
||||||
- name: Check all workspace targets
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: 'true'
|
|
||||||
run: cargo check --workspace --all-targets --all-features --locked
|
|
||||||
|
|
||||||
- name: Run workspace doctests
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: 'true'
|
|
||||||
run: cargo test --workspace --all-features --doc --locked
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: 'true'
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
name: Frontend checks and build
|
|
||||||
needs: source
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: |
|
|
||||||
frontend/package-lock.json
|
|
||||||
aether-vscodex/web/package-lock.json
|
|
||||||
|
|
||||||
# The frontend prebuild synchronizes the embedded VSCodex UI by running
|
|
||||||
# its build from a separate package. Install that package explicitly so
|
|
||||||
# vue-tsc can resolve vite/client, vitest/globals, and node types in a
|
|
||||||
# clean runner.
|
|
||||||
- name: Install VSCodex web dependencies
|
|
||||||
working-directory: aether-vscodex/web
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
working-directory: frontend
|
|
||||||
run: npx --no-install eslint .
|
|
||||||
|
|
||||||
- name: Type-check
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run type-check
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
working-directory: frontend
|
|
||||||
run: npm run test:run
|
|
||||||
|
|
||||||
- name: Build nightly frontend
|
|
||||||
working-directory: frontend
|
|
||||||
env:
|
|
||||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
|
||||||
AETHER_VERSION: nightly
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Upload frontend artifact
|
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
with:
|
|
||||||
name: nightly-frontend-dist
|
|
||||||
path: frontend/dist/
|
|
||||||
if-no-files-found: error
|
|
||||||
overwrite: true
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
repository_health:
|
|
||||||
name: Repository health checks
|
|
||||||
needs: source
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Check generated format coverage matrix
|
|
||||||
run: python3 docs/api/generate_format_field_coverage.py --check
|
|
||||||
|
|
||||||
- name: Test pressure report checker
|
|
||||||
run: node --test tools/pressure/check_gateway_stage_report.test.js
|
|
||||||
|
|
||||||
checks:
|
|
||||||
name: Nightly check gate
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs:
|
|
||||||
- source
|
|
||||||
- rust_ci
|
|
||||||
- rust_extended
|
|
||||||
- frontend
|
|
||||||
- repository_health
|
|
||||||
steps:
|
|
||||||
- name: Verify check jobs
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
failed=0
|
|
||||||
echo "source=${{ needs.source.result }}"
|
|
||||||
echo "rust_ci=${{ needs.rust_ci.result }}"
|
|
||||||
echo "rust_extended=${{ needs.rust_extended.result }}"
|
|
||||||
echo "frontend=${{ needs.frontend.result }}"
|
|
||||||
echo "repository_health=${{ needs.repository_health.result }}"
|
|
||||||
|
|
||||||
for result in \
|
|
||||||
"${{ needs.source.result }}" \
|
|
||||||
"${{ needs.rust_ci.result }}" \
|
|
||||||
"${{ needs.rust_extended.result }}" \
|
|
||||||
"${{ needs.frontend.result }}" \
|
|
||||||
"${{ needs.repository_health.result }}"; do
|
|
||||||
if [[ "${result}" != "success" ]]; then
|
|
||||||
failed=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "${failed}" -ne 0 ]]; then
|
|
||||||
echo 'One or more nightly checks failed or were cancelled.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build ${{ matrix.name }}
|
|
||||||
needs: [source, checks]
|
|
||||||
if: ${{ needs.checks.result == 'success' }}
|
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
timeout-minutes: 120
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- name: linux-amd64
|
|
||||||
target: x86_64-unknown-linux-musl
|
|
||||||
platform: linux
|
|
||||||
arch: amd64
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: linux-arm64
|
|
||||||
target: aarch64-unknown-linux-musl
|
|
||||||
platform: linux
|
|
||||||
arch: arm64
|
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: macos-amd64
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: amd64
|
|
||||||
os: macos-15-intel
|
|
||||||
use_cross: false
|
|
||||||
- name: macos-arm64
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: arm64
|
|
||||||
os: macos-15
|
|
||||||
use_cross: false
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Install pinned Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
|
||||||
targets: ${{ matrix.target }}
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
shared-key: nightly-release-${{ matrix.target }}
|
|
||||||
workspaces: . -> target
|
|
||||||
|
|
||||||
- name: Install cross
|
|
||||||
if: matrix.use_cross
|
|
||||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
|
||||||
|
|
||||||
- name: Build release binary
|
|
||||||
env:
|
|
||||||
AETHER_BUILD_VERSION: nightly-${{ needs.source.outputs.short_sha }}
|
|
||||||
AETHER_VERSION: nightly
|
|
||||||
AETHER_BUILD_TYPE: release
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
|
||||||
cross build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
|
||||||
else
|
|
||||||
cargo build --release --locked -p aether-gateway --target "${{ matrix.target }}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
with:
|
|
||||||
name: nightly-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
|
||||||
path: target/${{ matrix.target }}/release/aether-gateway
|
|
||||||
if-no-files-found: error
|
|
||||||
overwrite: true
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
docker:
|
|
||||||
name: Publish nightly GHCR image
|
|
||||||
needs: [source, checks, build]
|
|
||||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Download Linux binaries and frontend
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
|
||||||
with:
|
|
||||||
pattern: nightly-*
|
|
||||||
path: artifacts
|
|
||||||
merge-multiple: false
|
|
||||||
|
|
||||||
- name: Prepare Docker build context
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
mkdir -p dist/frontend
|
|
||||||
cp artifacts/nightly-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
|
||||||
cp artifacts/nightly-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
|
||||||
chmod 0755 dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
|
||||||
cp -R artifacts/nightly-frontend-dist/. dist/frontend/
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6 # v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push nightly image
|
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile.app
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: |
|
|
||||||
${{ env.GHCR_IMAGE }}:nightly
|
|
||||||
${{ env.GHCR_IMAGE }}:nightly-${{ needs.source.outputs.sha }}
|
|
||||||
labels: |
|
|
||||||
org.opencontainers.image.title=Aether
|
|
||||||
org.opencontainers.image.version=nightly
|
|
||||||
org.opencontainers.image.revision=${{ needs.source.outputs.sha }}
|
|
||||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
|
||||||
|
|
||||||
package:
|
|
||||||
name: Package nightly archives
|
|
||||||
needs: [source, checks, build]
|
|
||||||
if: ${{ needs.checks.result == 'success' && needs.build.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.source.outputs.sha }}
|
|
||||||
|
|
||||||
- name: Download nightly artifacts
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
|
||||||
with:
|
|
||||||
pattern: nightly-*
|
|
||||||
path: artifacts
|
|
||||||
merge-multiple: false
|
|
||||||
|
|
||||||
- name: Build nightly release packages
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
SOURCE_REF: ${{ needs.source.outputs.sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION="nightly"
|
|
||||||
|
|
||||||
mkdir -p package release-assets
|
|
||||||
for platform in linux macos; do
|
|
||||||
for arch in amd64 arm64; do
|
|
||||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
|
||||||
root="package/${bundle}"
|
|
||||||
mkdir -p "${root}/bin" "${root}/frontend"
|
|
||||||
|
|
||||||
install -m 0755 \
|
|
||||||
"artifacts/nightly-gateway-${platform}-${arch}/aether-gateway" \
|
|
||||||
"${root}/bin/aether-gateway"
|
|
||||||
cp -R artifacts/nightly-frontend-dist/. "${root}/frontend/"
|
|
||||||
sed \
|
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
|
||||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
|
||||||
install.sh > "${root}/install.sh"
|
|
||||||
chmod 0755 "${root}/install.sh"
|
|
||||||
install -m 0755 update.sh "${root}/update.sh"
|
|
||||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
|
||||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
|
||||||
install -m 0644 .env.example "${root}/.env.example"
|
|
||||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
|
||||||
install -m 0644 README.md "${root}/README.md"
|
|
||||||
install -m 0644 LICENSE "${root}/LICENSE"
|
|
||||||
|
|
||||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
|
|
||||||
sed \
|
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
|
||||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
|
||||||
install.sh > release-assets/install.sh
|
|
||||||
chmod 0755 release-assets/install.sh
|
|
||||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
|
||||||
|
|
||||||
test "$(find release-assets -maxdepth 1 -name '*.tar.gz' | wc -l)" -eq 4
|
|
||||||
test "$(wc -l < release-assets/SHA256SUMS)" -eq 4
|
|
||||||
(cd release-assets && sha256sum -c SHA256SUMS)
|
|
||||||
for archive in release-assets/*.tar.gz; do
|
|
||||||
tar -tzf "${archive}" >/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload nightly package artifact
|
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
with:
|
|
||||||
name: nightly-release-assets
|
|
||||||
path: release-assets/*
|
|
||||||
if-no-files-found: error
|
|
||||||
overwrite: true
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
github_release:
|
|
||||||
name: Publish nightly GitHub Release
|
|
||||||
needs: [source, checks, docker, package]
|
|
||||||
if: ${{ needs.checks.result == 'success' && needs.docker.result == 'success' && needs.package.result == 'success' }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Download nightly package artifact
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
|
||||||
with:
|
|
||||||
name: nightly-release-assets
|
|
||||||
path: release-assets
|
|
||||||
|
|
||||||
- name: Update rolling nightly release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
REPOSITORY: ${{ github.repository }}
|
|
||||||
RELEASE_TAG: nightly
|
|
||||||
SOURCE_SHA: ${{ needs.source.outputs.sha }}
|
|
||||||
SOURCE_SHORT_SHA: ${{ needs.source.outputs.short_sha }}
|
|
||||||
RELEASE_DATE: ${{ needs.source.outputs.date }}
|
|
||||||
GHCR_IMAGE: ${{ needs.source.outputs.ghcr_image }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
release_title="Aether Nightly ${RELEASE_DATE} (${SOURCE_SHORT_SHA})"
|
|
||||||
notes_file="${RUNNER_TEMP}/nightly-release-notes.md"
|
|
||||||
cat > "${notes_file}" <<EOF
|
|
||||||
## Aether nightly
|
|
||||||
|
|
||||||
This rolling prerelease was built from [main commit ${SOURCE_SHORT_SHA}](https://github.com/${REPOSITORY}/commit/${SOURCE_SHA}).
|
|
||||||
|
|
||||||
- Source branch: main
|
|
||||||
- Source commit: ${SOURCE_SHA}
|
|
||||||
- Build date (UTC): ${RELEASE_DATE}
|
|
||||||
- Container image: ${GHCR_IMAGE}:nightly
|
|
||||||
- Commit image: ${GHCR_IMAGE}:nightly-${SOURCE_SHA}
|
|
||||||
|
|
||||||
The nightly tag and assets are replaced by the next successful daily build.
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Create a draft on the first run. Later runs repair the same rolling
|
|
||||||
# release on retry if any upload or metadata update is interrupted.
|
|
||||||
if ! gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" >/dev/null 2>&1; then
|
|
||||||
gh release create "${RELEASE_TAG}" \
|
|
||||||
--repo "${REPOSITORY}" \
|
|
||||||
--draft \
|
|
||||||
--prerelease \
|
|
||||||
--latest=false \
|
|
||||||
--target "${SOURCE_SHA}" \
|
|
||||||
--title "${release_title}" \
|
|
||||||
--notes-file "${notes_file}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Upload archives first, then the checksum/installer metadata. This
|
|
||||||
# keeps a failed upload from leaving a checksum that describes files
|
|
||||||
# which have not reached the Release yet.
|
|
||||||
gh release upload "${RELEASE_TAG}" release-assets/*.tar.gz \
|
|
||||||
--repo "${REPOSITORY}" \
|
|
||||||
--clobber
|
|
||||||
gh release upload "${RELEASE_TAG}" \
|
|
||||||
release-assets/SHA256SUMS \
|
|
||||||
release-assets/install.sh \
|
|
||||||
--repo "${REPOSITORY}" \
|
|
||||||
--clobber
|
|
||||||
|
|
||||||
# target_commitish does not move an existing git tag. Move the ref
|
|
||||||
# only after the complete asset set is available.
|
|
||||||
if gh api "repos/${REPOSITORY}/git/ref/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
|
||||||
gh api -X PATCH "repos/${REPOSITORY}/git/refs/tags/${RELEASE_TAG}" \
|
|
||||||
-f "sha=${SOURCE_SHA}" \
|
|
||||||
-F 'force=true' >/dev/null
|
|
||||||
else
|
|
||||||
gh api -X POST "repos/${REPOSITORY}/git/refs" \
|
|
||||||
-f "ref=refs/tags/${RELEASE_TAG}" \
|
|
||||||
-f "sha=${SOURCE_SHA}" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
gh release edit "${RELEASE_TAG}" \
|
|
||||||
--repo "${REPOSITORY}" \
|
|
||||||
--draft=false \
|
|
||||||
--prerelease \
|
|
||||||
--latest=false \
|
|
||||||
--target "${SOURCE_SHA}" \
|
|
||||||
--title "${release_title}" \
|
|
||||||
--notes-file "${notes_file}"
|
|
||||||
|
|
||||||
expected_assets=(
|
|
||||||
aether-nightly-linux-amd64.tar.gz
|
|
||||||
aether-nightly-linux-arm64.tar.gz
|
|
||||||
aether-nightly-macos-amd64.tar.gz
|
|
||||||
aether-nightly-macos-arm64.tar.gz
|
|
||||||
SHA256SUMS
|
|
||||||
install.sh
|
|
||||||
)
|
|
||||||
asset_names="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json assets --jq '.assets[].name')"
|
|
||||||
for expected_asset in "${expected_assets[@]}"; do
|
|
||||||
if ! grep -Fxq "${expected_asset}" <<<"${asset_names}"; then
|
|
||||||
echo "Published release is missing asset ${expected_asset}." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
resolved_sha=""
|
|
||||||
for attempt in {1..10}; do
|
|
||||||
resolved_sha="$(gh api "repos/${REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2>/dev/null || true)"
|
|
||||||
if [[ "${resolved_sha}" == "${SOURCE_SHA}" ]]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [[ "${resolved_sha}" != "${SOURCE_SHA}" ]]; then
|
|
||||||
echo "nightly tag resolved to ${resolved_sha}, expected ${SOURCE_SHA}." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
release_state="$(gh release view "${RELEASE_TAG}" --repo "${REPOSITORY}" --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv')"
|
|
||||||
if [[ "${release_state}" != $'false\ttrue' ]]; then
|
|
||||||
echo "nightly release has unexpected state: ${release_state}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Published ${RELEASE_TAG} for ${SOURCE_SHA}."
|
|
||||||
|
|
||||||
summary:
|
|
||||||
name: Nightly summary
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ always() }}
|
|
||||||
needs:
|
|
||||||
- source
|
|
||||||
- rust_ci
|
|
||||||
- rust_extended
|
|
||||||
- frontend
|
|
||||||
- repository_health
|
|
||||||
- checks
|
|
||||||
- build
|
|
||||||
- docker
|
|
||||||
- package
|
|
||||||
- github_release
|
|
||||||
steps:
|
|
||||||
- name: Verify nightly pipeline
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
failed=0
|
|
||||||
for entry in \
|
|
||||||
"source=${{ needs.source.result }}" \
|
|
||||||
"rust_ci=${{ needs.rust_ci.result }}" \
|
|
||||||
"rust_extended=${{ needs.rust_extended.result }}" \
|
|
||||||
"frontend=${{ needs.frontend.result }}" \
|
|
||||||
"repository_health=${{ needs.repository_health.result }}" \
|
|
||||||
"checks=${{ needs.checks.result }}" \
|
|
||||||
"build=${{ needs.build.result }}" \
|
|
||||||
"docker=${{ needs.docker.result }}" \
|
|
||||||
"package=${{ needs.package.result }}" \
|
|
||||||
"github_release=${{ needs.github_release.result }}"; do
|
|
||||||
echo "${entry}"
|
|
||||||
if [[ "${entry#*=}" != "success" ]]; then
|
|
||||||
failed=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "${failed}" -ne 0 ]]; then
|
|
||||||
echo 'Nightly pipeline did not publish a new release.' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
+76
-273
@@ -6,8 +6,8 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
actions: read
|
contents: write
|
||||||
contents: read
|
packages: write
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: release-aether-${{ github.ref }}
|
group: release-aether-${{ github.ref }}
|
||||||
@@ -19,73 +19,18 @@ env:
|
|||||||
DOCKERHUB_IMAGE: fawney19/aether
|
DOCKERHUB_IMAGE: fawney19/aether
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
preflight:
|
|
||||||
name: Release preflight
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
publish: ${{ steps.classify.outputs.publish }}
|
|
||||||
version_tag: ${{ steps.classify.outputs.version_tag }}
|
|
||||||
prerelease: ${{ steps.classify.outputs.prerelease }}
|
|
||||||
make_latest: ${{ steps.classify.outputs.make_latest }}
|
|
||||||
steps:
|
|
||||||
- name: Classify release tag
|
|
||||||
id: classify
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
echo "publish=false" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "version_tag=" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
|
|
||||||
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
if [[ "${GITHUB_REF_TYPE}" != "tag" ]]; then
|
|
||||||
echo "Manual release build; publish jobs will be skipped."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
tag="${GITHUB_REF_NAME}"
|
|
||||||
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; then
|
|
||||||
echo "Unsupported release tag: ${tag}" >&2
|
|
||||||
echo "Expected vX.Y.Z, vX.Y.Z-beta.N, or vX.Y.Z-rc.N." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "version_tag=${tag}" >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
if [[ "${tag}" == *-* ]]; then
|
|
||||||
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
else
|
|
||||||
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
|
|
||||||
echo "publish=true" >> "${GITHUB_OUTPUT}"
|
|
||||||
else
|
|
||||||
echo "Manual release build for ${tag}; publish jobs will be skipped."
|
|
||||||
fi
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Build frontend
|
name: Build frontend
|
||||||
needs: preflight
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: |
|
cache-dependency-path: frontend/package-lock.json
|
||||||
frontend/package-lock.json
|
|
||||||
aether-vscodex/web/package-lock.json
|
|
||||||
|
|
||||||
- name: Build aether-vscodex web
|
|
||||||
working-directory: aether-vscodex/web
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Install & build
|
- name: Install & build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -94,215 +39,114 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
- name: Upload frontend artifact
|
- name: Upload frontend artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: frontend-dist
|
name: frontend-dist
|
||||||
path: frontend/dist/
|
path: frontend/dist/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
vscodex:
|
|
||||||
name: Build VS Code Codex extension
|
|
||||||
needs: preflight
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: |
|
|
||||||
aether-vscodex/package-lock.json
|
|
||||||
aether-vscodex/web/package-lock.json
|
|
||||||
aether-vscodex/vscode-extension/package-lock.json
|
|
||||||
|
|
||||||
- name: Install module test dependencies
|
|
||||||
working-directory: aether-vscodex
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build the embedded Web UI
|
|
||||||
working-directory: aether-vscodex/web
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Install extension dependencies
|
|
||||||
working-directory: aether-vscodex/vscode-extension
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Check and compile the extension
|
|
||||||
working-directory: aether-vscodex/vscode-extension
|
|
||||||
run: |
|
|
||||||
npm run check
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
- name: Run module tests
|
|
||||||
working-directory: aether-vscodex
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Run Web UI tests
|
|
||||||
working-directory: aether-vscodex/web
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Package VSIX
|
|
||||||
working-directory: aether-vscodex/vscode-extension
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version="$(node -p "require('./package.json').version")"
|
|
||||||
npx --yes @vscode/vsce package --no-update-package-json --allow-missing-repository
|
|
||||||
source_vsix="codex-remote-collab-${version}.vsix"
|
|
||||||
test -f "${source_vsix}"
|
|
||||||
mv "${source_vsix}" "aether-vscodex-${version}.vsix"
|
|
||||||
unzip -l "aether-vscodex-${version}.vsix" | grep 'extension/node_modules/ws/index.js' >/dev/null
|
|
||||||
|
|
||||||
- name: Upload VSIX artifact
|
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
||||||
with:
|
|
||||||
name: aether-vscodex-vsix
|
|
||||||
path: aether-vscodex/vscode-extension/aether-vscodex-*.vsix
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 7
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.name }}
|
name: Build ${{ matrix.name }}
|
||||||
needs: preflight
|
runs-on: ubuntu-latest
|
||||||
runs-on: ${{ matrix.os }}
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- name: linux-amd64
|
- name: linux-amd64
|
||||||
target: x86_64-unknown-linux-musl
|
target: x86_64-unknown-linux-musl
|
||||||
platform: linux
|
|
||||||
arch: amd64
|
arch: amd64
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: linux-arm64
|
- name: linux-arm64
|
||||||
target: aarch64-unknown-linux-musl
|
target: aarch64-unknown-linux-musl
|
||||||
platform: linux
|
|
||||||
arch: arm64
|
arch: arm64
|
||||||
os: ubuntu-latest
|
|
||||||
use_cross: true
|
|
||||||
- name: macos-amd64
|
|
||||||
target: x86_64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: amd64
|
|
||||||
os: macos-15-intel
|
|
||||||
use_cross: false
|
|
||||||
- name: macos-arm64
|
|
||||||
target: aarch64-apple-darwin
|
|
||||||
platform: macos
|
|
||||||
arch: arm64
|
|
||||||
os: macos-15
|
|
||||||
use_cross: false
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
targets: ${{ matrix.target }}
|
targets: ${{ matrix.target }}
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: release-${{ matrix.target }}
|
shared-key: release-${{ matrix.target }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Install cross
|
- name: Install cross
|
||||||
if: matrix.use_cross
|
uses: taiki-e/install-action@cross
|
||||||
uses: taiki-e/install-action@1ae7257be536a92d9218a6b343dc6e6ba650f7e1 # cross
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
AETHER_VERSION: ${{ needs.preflight.outputs.version_tag }}
|
AETHER_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '' }}
|
||||||
AETHER_BUILD_TYPE: release
|
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
shell: bash
|
run: cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
||||||
run: |
|
|
||||||
if [[ "${{ matrix.use_cross }}" == "true" ]]; then
|
|
||||||
cross build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
|
||||||
else
|
|
||||||
cargo build --release --locked -p aether-gateway --target ${{ matrix.target }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Upload binary artifact
|
- name: Upload binary artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: aether-gateway-${{ matrix.platform }}-${{ matrix.arch }}
|
name: aether-gateway-${{ matrix.arch }}
|
||||||
path: target/${{ matrix.target }}/release/aether-gateway
|
path: target/${{ matrix.target }}/release/aether-gateway
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
name: Docker multi-arch
|
name: Docker multi-arch
|
||||||
needs: [preflight, frontend, build]
|
needs: [frontend, build]
|
||||||
if: needs.preflight.outputs.publish == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
attestations: write
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
packages: write
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Prepare dist layout
|
- name: Prepare dist layout
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
cp artifacts/aether-gateway-linux-amd64/aether-gateway dist/aether-gateway-amd64
|
cp artifacts/aether-gateway-amd64/aether-gateway dist/aether-gateway-amd64
|
||||||
cp artifacts/aether-gateway-linux-arm64/aether-gateway dist/aether-gateway-arm64
|
cp artifacts/aether-gateway-arm64/aether-gateway dist/aether-gateway-arm64
|
||||||
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
chmod +x dist/aether-gateway-amd64 dist/aether-gateway-arm64
|
||||||
cp -r artifacts/frontend-dist dist/frontend
|
cp -r artifacts/frontend-dist dist/frontend
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Extract metadata
|
- name: Extract metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
||||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}},enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=raw,value=latest,enable=${{ needs.preflight.outputs.make_latest == 'true' }}
|
type=raw,value=pre,enable=${{ contains(github.ref, '-') }}
|
||||||
type=raw,value=beta,enable=${{ contains(github.ref_name, '-beta.') }}
|
type=raw,value=fix,enable=${{ contains(github.ref, '-fix') }}
|
||||||
type=raw,value=rc,enable=${{ contains(github.ref_name, '-rc.') }}
|
|
||||||
type=sha,prefix=
|
type=sha,prefix=
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=auto
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: push
|
uses: docker/build-push-action@v6
|
||||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.app
|
file: ./Dockerfile.app
|
||||||
@@ -311,36 +155,15 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|
||||||
- name: Attest GHCR image provenance
|
|
||||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
||||||
with:
|
|
||||||
subject-name: ${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
create-storage-record: false
|
|
||||||
|
|
||||||
- name: Attest Docker Hub image provenance
|
|
||||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
||||||
with:
|
|
||||||
subject-name: docker.io/${{ env.DOCKERHUB_IMAGE }}
|
|
||||||
subject-digest: ${{ steps.push.outputs.digest }}
|
|
||||||
push-to-registry: true
|
|
||||||
create-storage-record: false
|
|
||||||
|
|
||||||
package:
|
package:
|
||||||
name: Release tarballs
|
name: Release tarballs
|
||||||
needs: [preflight, frontend, build]
|
needs: [frontend, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
attestations: write
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
@@ -349,65 +172,37 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
|
||||||
VERSION="${GITHUB_REF_NAME}"
|
VERSION="${GITHUB_REF_NAME}"
|
||||||
SOURCE_REF="${GITHUB_REF_NAME}"
|
|
||||||
else
|
else
|
||||||
VERSION="snapshot-${GITHUB_SHA::7}"
|
VERSION="snapshot-${GITHUB_SHA::7}"
|
||||||
SOURCE_REF="${GITHUB_SHA}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p package release-assets
|
mkdir -p package release-assets
|
||||||
for platform in linux macos; do
|
for arch in amd64 arm64; do
|
||||||
for arch in amd64 arm64; do
|
bundle="aether-${VERSION}-linux-${arch}"
|
||||||
bundle="aether-${VERSION}-${platform}-${arch}"
|
root="package/${bundle}"
|
||||||
root="package/${bundle}"
|
mkdir -p \
|
||||||
mkdir -p \
|
"${root}/bin" \
|
||||||
"${root}/bin" \
|
"${root}/frontend"
|
||||||
"${root}/frontend"
|
|
||||||
|
|
||||||
install -m 0755 "artifacts/aether-gateway-${platform}-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
install -m 0755 "artifacts/aether-gateway-${arch}/aether-gateway" "${root}/bin/aether-gateway"
|
||||||
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
cp -R artifacts/frontend-dist/. "${root}/frontend/"
|
||||||
sed \
|
sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > "${root}/install.sh"
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
chmod 0755 "${root}/install.sh"
|
||||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
||||||
install.sh > "${root}/install.sh"
|
install -m 0644 .env.example "${root}/.env.example"
|
||||||
chmod 0755 "${root}/install.sh"
|
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
||||||
install -m 0755 update.sh "${root}/update.sh"
|
install -m 0644 README.md "${root}/README.md"
|
||||||
install -m 0644 docker-compose.yml "${root}/docker-compose.yml"
|
install -m 0644 LICENSE "${root}/LICENSE"
|
||||||
install -m 0644 docker-compose.single-node.yml "${root}/docker-compose.single-node.yml"
|
|
||||||
install -m 0644 .env.example "${root}/.env.example"
|
|
||||||
install -m 0755 generate_keys.sh "${root}/generate_keys.sh"
|
|
||||||
install -m 0644 README.md "${root}/README.md"
|
|
||||||
install -m 0644 LICENSE "${root}/LICENSE"
|
|
||||||
|
|
||||||
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
tar -C package -czf "release-assets/${bundle}.tar.gz" "${bundle}"
|
||||||
done
|
|
||||||
done
|
done
|
||||||
|
|
||||||
sed \
|
sed "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" install.sh > release-assets/install.sh
|
||||||
-e "s/^SOURCE_REF=\"\${AETHER_SOURCE_REF:-main}\"/SOURCE_REF=\"\${AETHER_SOURCE_REF:-${SOURCE_REF}}\"/" \
|
|
||||||
-e "s/^VERSION=\"\${AETHER_VERSION:-}\"/VERSION=\"\${AETHER_VERSION:-${VERSION}}\"/" \
|
|
||||||
install.sh > release-assets/install.sh
|
|
||||||
chmod +x release-assets/install.sh
|
chmod +x release-assets/install.sh
|
||||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS)
|
||||||
|
|
||||||
- name: Attest release package provenance
|
|
||||||
id: attest-release
|
|
||||||
if: needs.preflight.outputs.publish == 'true'
|
|
||||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
release-assets/*.tar.gz
|
|
||||||
release-assets/install.sh
|
|
||||||
release-assets/SHA256SUMS
|
|
||||||
|
|
||||||
- name: Bundle release package provenance
|
|
||||||
if: needs.preflight.outputs.publish == 'true'
|
|
||||||
env:
|
|
||||||
ATTESTATION_BUNDLE: ${{ steps.attest-release.outputs.bundle-path }}
|
|
||||||
run: install -m 0644 "${ATTESTATION_BUNDLE}" release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
|
||||||
|
|
||||||
- name: Upload release package artifact
|
- name: Upload release package artifact
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: release-assets
|
name: release-assets
|
||||||
path: release-assets/*
|
path: release-assets/*
|
||||||
@@ -416,25 +211,35 @@ jobs:
|
|||||||
|
|
||||||
github-release:
|
github-release:
|
||||||
name: GitHub Release assets
|
name: GitHub Release assets
|
||||||
needs: [preflight, docker, package, vscodex]
|
needs: [docker, package]
|
||||||
if: needs.preflight.outputs.publish == 'true'
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: write
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Classify release tag
|
||||||
|
id: release_tag
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then
|
||||||
|
echo "Unsupported release tag: ${tag}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${tag}" == *-* ]]; then
|
||||||
|
echo "prerelease=true" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "make_latest=false" >> "${GITHUB_OUTPUT}"
|
||||||
|
else
|
||||||
|
echo "prerelease=false" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "make_latest=true" >> "${GITHUB_OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Download release package artifact
|
- name: Download release package artifact
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: release-assets
|
name: release-assets
|
||||||
path: release-assets
|
path: release-assets
|
||||||
|
|
||||||
- name: Download VSIX artifact
|
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5
|
|
||||||
with:
|
|
||||||
name: aether-vscodex-vsix
|
|
||||||
path: release-assets
|
|
||||||
|
|
||||||
- name: Delete stale draft releases for tag
|
- name: Delete stale draft releases for tag
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
@@ -457,14 +262,12 @@ jobs:
|
|||||||
done <<< "${draft_ids}"
|
done <<< "${draft_ids}"
|
||||||
|
|
||||||
- name: Publish GitHub Release assets
|
- name: Publish GitHub Release assets
|
||||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
prerelease: ${{ needs.preflight.outputs.prerelease }}
|
prerelease: ${{ steps.release_tag.outputs.prerelease }}
|
||||||
make_latest: ${{ needs.preflight.outputs.make_latest }}
|
make_latest: ${{ steps.release_tag.outputs.make_latest }}
|
||||||
files: |
|
files: |
|
||||||
release-assets/*.tar.gz
|
release-assets/*.tar.gz
|
||||||
release-assets/AETHER_RELEASE_PROVENANCE.sigstore.json
|
|
||||||
release-assets/SHA256SUMS
|
release-assets/SHA256SUMS
|
||||||
release-assets/install.sh
|
release-assets/install.sh
|
||||||
release-assets/*.vsix
|
|
||||||
|
|||||||
+211
-293
@@ -1,113 +1,47 @@
|
|||||||
name: Rust CI
|
name: Rust CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- main
|
- aether-rust-pioneer
|
||||||
paths:
|
paths:
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/**"
|
- "crates/**"
|
||||||
- "apps/**"
|
- "apps/**"
|
||||||
- "install.sh"
|
|
||||||
- "deploy.sh"
|
|
||||||
- "update.sh"
|
|
||||||
- "generate_keys.sh"
|
|
||||||
- ".env.example"
|
|
||||||
- "README.md"
|
|
||||||
- "Dockerfile.app"
|
|
||||||
- "docker-compose.yml"
|
|
||||||
- "docker-compose.single-node.yml"
|
|
||||||
- "docker-compose.local.yml"
|
|
||||||
- "docker-compose.release-local.yml"
|
|
||||||
- "tests/compose_database_config_test.py"
|
|
||||||
- "tests/install_*_test.sh"
|
|
||||||
- "tests/deploy_*_test.sh"
|
|
||||||
- "tests/update_*_test.sh"
|
|
||||||
- "tests/release_supply_chain_test.sh"
|
|
||||||
- "tests/tunnel_installer_config_security_test.sh"
|
|
||||||
- ".github/workflows/build-tunnel.yml"
|
|
||||||
- ".github/workflows/deploy-pages.yml"
|
|
||||||
- ".github/workflows/release.yml"
|
|
||||||
- ".github/workflows/rust-ci.yml"
|
- ".github/workflows/rust-ci.yml"
|
||||||
- ".github/workflows/nightly.yml"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- "Cargo.toml"
|
- "Cargo.toml"
|
||||||
- "Cargo.lock"
|
- "Cargo.lock"
|
||||||
- "crates/**"
|
- "crates/**"
|
||||||
- "apps/**"
|
- "apps/**"
|
||||||
- "install.sh"
|
|
||||||
- "deploy.sh"
|
|
||||||
- "update.sh"
|
|
||||||
- "generate_keys.sh"
|
|
||||||
- ".env.example"
|
|
||||||
- "README.md"
|
|
||||||
- "Dockerfile.app"
|
|
||||||
- "docker-compose.yml"
|
|
||||||
- "docker-compose.single-node.yml"
|
|
||||||
- "docker-compose.local.yml"
|
|
||||||
- "docker-compose.release-local.yml"
|
|
||||||
- "tests/compose_database_config_test.py"
|
|
||||||
- "tests/install_*_test.sh"
|
|
||||||
- "tests/deploy_*_test.sh"
|
|
||||||
- "tests/update_*_test.sh"
|
|
||||||
- "tests/release_supply_chain_test.sh"
|
|
||||||
- "tests/tunnel_installer_config_security_test.sh"
|
|
||||||
- ".github/workflows/build-tunnel.yml"
|
|
||||||
- ".github/workflows/deploy-pages.yml"
|
|
||||||
- ".github/workflows/release.yml"
|
|
||||||
- ".github/workflows/rust-ci.yml"
|
- ".github/workflows/rust-ci.yml"
|
||||||
- ".github/workflows/nightly.yml"
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rust-ci-${{ github.event_name }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_INCREMENTAL: 0
|
CARGO_INCREMENTAL: 0
|
||||||
CARGO_PROFILE_DEV_DEBUG: 0
|
|
||||||
CARGO_PROFILE_TEST_DEBUG: 0
|
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
shell_security:
|
|
||||||
name: Shell security fixtures
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Run installer and supply-chain fixtures
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
python3 tests/compose_database_config_test.py
|
|
||||||
bash tests/deploy_state_safety_test.sh
|
|
||||||
bash tests/install_archive_safety_test.sh
|
|
||||||
bash tests/install_container_runtime_security_test.sh
|
|
||||||
bash tests/install_current_release_link_test.sh
|
|
||||||
bash tests/install_local_bundle_safety_test.sh
|
|
||||||
bash tests/install_privileged_write_safety_test.sh
|
|
||||||
bash tests/install_source_trust_test.sh
|
|
||||||
bash tests/release_supply_chain_test.sh
|
|
||||||
bash tests/update_compose_safety_test.sh
|
|
||||||
bash tests/tunnel_installer_config_security_test.sh
|
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
name: Format
|
name: Format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
- name: Show Rust toolchain
|
||||||
components: rustfmt
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Install rustfmt component
|
||||||
|
run: rustup component add rustfmt
|
||||||
|
|
||||||
- name: Format
|
- name: Format
|
||||||
run: cargo fmt --all --check
|
run: cargo fmt --all --check
|
||||||
@@ -116,28 +50,31 @@ jobs:
|
|||||||
name: Clippy (Gateway)
|
name: Clippy (Gateway)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
- name: Show Rust toolchain
|
||||||
components: clippy
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Install clippy component
|
||||||
|
run: rustup component add clippy
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo clippy -p aether-gateway --lib --bins --examples -- -D warnings
|
run: cargo clippy -p aether-gateway --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -150,22 +87,25 @@ jobs:
|
|||||||
name: Clippy (Data)
|
name: Clippy (Data)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
- name: Show Rust toolchain
|
||||||
components: clippy
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Install clippy component
|
||||||
|
run: rustup component add clippy
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
env:
|
env:
|
||||||
@@ -184,28 +124,31 @@ jobs:
|
|||||||
name: Clippy (Workspace Rest)
|
name: Clippy (Workspace Rest)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
|
||||||
toolchain: 1.95.0
|
- name: Show Rust toolchain
|
||||||
components: clippy
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Install clippy component
|
||||||
|
run: rustup component add clippy
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Clippy
|
- name: Clippy
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests --all-targets -- -D warnings
|
run: cargo clippy --workspace --exclude aether-gateway --exclude aether-data --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -236,47 +179,28 @@ jobs:
|
|||||||
name: Test (Gateway)
|
name: Test (Gateway)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Show Rust toolchain
|
- name: Show Rust toolchain
|
||||||
run: rustup show active-toolchain
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Setup mold
|
- name: Test
|
||||||
uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1
|
|
||||||
|
|
||||||
- name: Install nextest
|
|
||||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
|
||||||
|
|
||||||
- name: Expose PostgreSQL test binaries
|
|
||||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Test lib
|
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
RUST_MIN_STACK: "16777216"
|
run: cargo test -p aether-gateway
|
||||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
|
||||||
run: cargo nextest run -p aether-gateway --lib
|
|
||||||
|
|
||||||
- name: Test bins
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
RUST_MIN_STACK: "16777216"
|
|
||||||
RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
|
||||||
run: cargo nextest run -p aether-gateway --bins
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -289,72 +213,28 @@ jobs:
|
|||||||
name: Test (Data)
|
name: Test (Data)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Show Rust toolchain
|
- name: Show Rust toolchain
|
||||||
run: rustup show active-toolchain
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Install nextest
|
|
||||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
|
||||||
|
|
||||||
- name: Expose PostgreSQL test binaries
|
|
||||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
run: cargo test -p aether-data
|
||||||
run: cargo nextest run -p aether-data
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
check_data_features:
|
|
||||||
name: Check (Data Feature - ${{ matrix.feature }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
feature:
|
|
||||||
- postgres
|
|
||||||
- all-drivers
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
|
||||||
workspaces: . -> target
|
|
||||||
|
|
||||||
- name: Setup sccache
|
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
|
||||||
|
|
||||||
- name: Check selected data driver
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: cargo check -p aether-data --no-default-features --features ${{ matrix.feature }}
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -367,104 +247,28 @@ jobs:
|
|||||||
name: Test (Workspace Rest)
|
name: Test (Workspace Rest)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Show Rust toolchain
|
- name: Show Rust toolchain
|
||||||
run: rustup show active-toolchain
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Install nextest
|
|
||||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
run: cargo nextest run --workspace --exclude aether-gateway --exclude aether-data --exclude aether-integration-tests
|
run: cargo test --workspace --exclude aether-gateway --exclude aether-data
|
||||||
|
|
||||||
- name: Show sccache stats
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
test_data_adapters:
|
|
||||||
name: Test (Data Adapter - ${{ matrix.package }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
package:
|
|
||||||
- aether-data-postgres
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
|
||||||
workspaces: . -> target
|
|
||||||
|
|
||||||
- name: Setup sccache
|
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
|
||||||
|
|
||||||
- name: Install nextest
|
|
||||||
uses: taiki-e/install-action@d5f9268ff7620505a81ada10ddf18cdd72240185 # nextest
|
|
||||||
|
|
||||||
- name: Test adapter
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: cargo nextest run -p ${{ matrix.package }}
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: sccache --show-stats
|
|
||||||
|
|
||||||
check_integration_scenarios:
|
|
||||||
name: Test (Integration Scenarios)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
|
||||||
|
|
||||||
- name: Rust cache
|
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
|
||||||
with:
|
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
|
||||||
workspaces: . -> target
|
|
||||||
|
|
||||||
- name: Setup sccache
|
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
|
||||||
|
|
||||||
- name: Expose PostgreSQL test binaries
|
|
||||||
run: pg_config --bindir >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Test scenario binaries and end-to-end suites
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
run: cargo test -p aether-integration-tests --bins --tests
|
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -479,24 +283,52 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- test_gateway
|
- test_gateway
|
||||||
- test_data
|
- test_data
|
||||||
- check_data_features
|
|
||||||
- test_rest
|
- test_rest
|
||||||
- test_data_adapters
|
|
||||||
- check_integration_scenarios
|
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify test jobs
|
- name: Verify test jobs
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
if [ "${{ needs.test_gateway.result }}" != "success" ] || \
|
||||||
[ "${{ needs.test_data.result }}" != "success" ] || \
|
[ "${{ needs.test_data.result }}" != "success" ] || \
|
||||||
[ "${{ needs.check_data_features.result }}" != "success" ] || \
|
[ "${{ needs.test_rest.result }}" != "success" ]; then
|
||||||
[ "${{ needs.test_rest.result }}" != "success" ] || \
|
|
||||||
[ "${{ needs.test_data_adapters.result }}" != "success" ] || \
|
|
||||||
[ "${{ needs.check_integration_scenarios.result }}" != "success" ]; then
|
|
||||||
echo "Tests failed"
|
echo "Tests failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
data_db_smoke_sqlite:
|
||||||
|
name: Data DB Smoke (SQLite)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Show Rust toolchain
|
||||||
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
|
workspaces: . -> target
|
||||||
|
|
||||||
|
- name: Setup sccache
|
||||||
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
|
- name: Run SQLite data smoke tests
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: cargo test -p aether-data sqlite --lib
|
||||||
|
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
data_db_smoke_postgres:
|
data_db_smoke_postgres:
|
||||||
name: Data DB Smoke (Postgres)
|
name: Data DB Smoke (Postgres)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -515,58 +347,142 @@ jobs:
|
|||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=20
|
--health-retries=20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
- name: Show Rust toolchain
|
- name: Show Rust toolchain
|
||||||
run: rustup show active-toolchain
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
- name: Rust cache
|
- name: Rust cache
|
||||||
uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
|
uses: Swatinem/rust-cache@v2
|
||||||
with:
|
with:
|
||||||
shared-key: rust-ci-${{ runner.os }}
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
workspaces: . -> target
|
workspaces: . -> target
|
||||||
|
|
||||||
- name: Setup sccache
|
- name: Setup sccache
|
||||||
uses: mozilla-actions/sccache-action@7d986dd989559c6ecdb630a3fd2557667be217ad # v0.0.9
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
- name: Add PostgreSQL server binaries to PATH
|
|
||||||
run: echo "$(pg_config --bindir)" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Run Postgres migration smoke test
|
- name: Run Postgres migration smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||||
run: cargo test -p aether-data --all-features postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data postgres_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Run Postgres provider metadata migration smoke test
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
|
||||||
run: cargo test -p aether-data --all-features postgres_provider_upstream_metadata_migration_preserves_json_when_url_is_set --lib -- --nocapture
|
|
||||||
|
|
||||||
- name: Run Postgres API key lifecycle tests
|
|
||||||
env:
|
|
||||||
RUSTC_WRAPPER: sccache
|
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
|
||||||
AETHER_REQUIRE_LOCAL_POSTGRES_TESTS: "true"
|
|
||||||
run: |
|
|
||||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidates_preserve_deleted_api_key_identity --lib -- --exact --nocapture
|
|
||||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_request_candidate_migration_decouples_legacy_api_key_foreign_key --lib -- --exact --nocapture
|
|
||||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_stats_daily_api_key_migration_decouples_legacy_foreign_key --lib -- --exact --nocapture
|
|
||||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_expired_api_key_cleanup_preserves_historical_identity --lib -- --exact --nocapture
|
|
||||||
cargo test -p aether-data --all-features lifecycle::migrate::tests::postgres_api_key_leaderboard_user_filter_preserves_aggregate_history --lib -- --exact --nocapture
|
|
||||||
|
|
||||||
- name: Run Postgres core export smoke test
|
- name: Run Postgres core export smoke test
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
SCCACHE_GHA_ENABLED: "true"
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||||
run: cargo test -p aether-data --all-features postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
run: cargo test -p aether-data postgres_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run SQLite-to-Postgres import smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_POSTGRES_URL: postgres://aether:[email protected]:5432/aether_test
|
||||||
|
run: cargo test -p aether-data sqlite_core_export_reads_migrated_database_rows --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Show sccache stats
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
run: sccache --show-stats
|
||||||
|
|
||||||
|
data_db_smoke_mysql:
|
||||||
|
name: Data DB Smoke (MySQL)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: aether_test
|
||||||
|
MYSQL_USER: aether
|
||||||
|
MYSQL_PASSWORD: aether
|
||||||
|
MYSQL_ROOT_PASSWORD: aether_root
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping -h 127.0.0.1 -uaether -paether --silent"
|
||||||
|
--health-interval=5s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Show Rust toolchain
|
||||||
|
run: rustup show active-toolchain
|
||||||
|
|
||||||
|
- name: Rust cache
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
shared-key: rust-ci-${{ runner.os }}
|
||||||
|
workspaces: . -> target
|
||||||
|
|
||||||
|
- name: Setup sccache
|
||||||
|
uses: mozilla-actions/[email protected]
|
||||||
|
|
||||||
|
- name: Run MySQL migration smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_migrations_create_core_config_tables_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL usage write smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_usage_write_repository_upserts_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL usage read smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_usage_read_repository_reads_usage_contract_views_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL provider catalog smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_provider_catalog_repository_round_trips_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL core export smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_core_export_reads_migrated_database_rows_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL wallet read smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_wallet_read_repository_reads_wallet_contract_views --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL wallet daily usage aggregation smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_wallet_daily_usage_aggregation_uses_settlement_wallets_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
|
- name: Run MySQL stats aggregation smoke test
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_MYSQL_URL: mysql://aether:[email protected]:3306/aether_test
|
||||||
|
run: cargo test -p aether-data mysql_stats_aggregation_runs_after_mysql_migrations_when_url_is_set --lib -- --nocapture
|
||||||
|
|
||||||
- name: Show sccache stats
|
- name: Show sccache stats
|
||||||
if: always()
|
if: always()
|
||||||
@@ -579,12 +495,16 @@ jobs:
|
|||||||
name: Data DB Smoke
|
name: Data DB Smoke
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
|
- data_db_smoke_sqlite
|
||||||
- data_db_smoke_postgres
|
- data_db_smoke_postgres
|
||||||
|
- data_db_smoke_mysql
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify database smoke jobs
|
- name: Verify database smoke jobs
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ needs.data_db_smoke_postgres.result }}" != "success" ]; then
|
if [ "${{ needs.data_db_smoke_sqlite.result }}" != "success" ] || \
|
||||||
|
[ "${{ needs.data_db_smoke_postgres.result }}" != "success" ] || \
|
||||||
|
[ "${{ needs.data_db_smoke_mysql.result }}" != "success" ]; then
|
||||||
echo "Data DB smoke failed"
|
echo "Data DB smoke failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -597,7 +517,6 @@ jobs:
|
|||||||
- clippy
|
- clippy
|
||||||
- test
|
- test
|
||||||
- data_db_smoke
|
- data_db_smoke
|
||||||
- shell_security
|
|
||||||
if: ${{ always() }}
|
if: ${{ always() }}
|
||||||
steps:
|
steps:
|
||||||
- name: Verify required jobs
|
- name: Verify required jobs
|
||||||
@@ -605,8 +524,7 @@ jobs:
|
|||||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||||
[ "${{ needs.test.result }}" != "success" ] || \
|
[ "${{ needs.test.result }}" != "success" ] || \
|
||||||
[ "${{ needs.data_db_smoke.result }}" != "success" ] || \
|
[ "${{ needs.data_db_smoke.result }}" != "success" ]; then
|
||||||
[ "${{ needs.shell_security.result }}" != "success" ]; then
|
|
||||||
echo "Rust CI failed"
|
echo "Rust CI failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
+1
-7
@@ -1,9 +1,6 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
*.rsa
|
|
||||||
*_rsa
|
|
||||||
|
|
||||||
# AI Assistant Configuration
|
# AI Assistant Configuration
|
||||||
.codex/
|
.codex/
|
||||||
.claude/
|
.claude/
|
||||||
@@ -11,7 +8,6 @@
|
|||||||
.serena/
|
.serena/
|
||||||
.gemini*/
|
.gemini*/
|
||||||
.plans
|
.plans
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
### Python ###
|
### Python ###
|
||||||
*.db
|
*.db
|
||||||
@@ -247,6 +243,4 @@ src/_version.py
|
|||||||
# Analysis folder (third-party code for reference)
|
# Analysis folder (third-party code for reference)
|
||||||
analysis/
|
analysis/
|
||||||
new-api/
|
new-api/
|
||||||
apps/aether-tunnel/aether-tunnel.toml
|
apps/aether-proxy/aether-proxy.toml
|
||||||
# Generated by frontend/scripts/sync-vscodex.mjs.
|
|
||||||
frontend/public/aether-vscodex/
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
[tools]
|
|
||||||
rust = "latest"
|
|
||||||
Generated
+310
-1464
File diff suppressed because it is too large
Load Diff
+26
-76
@@ -1,50 +1,30 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"apps/aether-tunnel",
|
"apps/aether-proxy",
|
||||||
"crates/aether-ai/formats",
|
"crates/aether-ai-formats",
|
||||||
"crates/aether-admin",
|
"crates/aether-admin",
|
||||||
"crates/aether-admission-core",
|
"crates/aether-ai-serving",
|
||||||
"crates/aether-ai/serving",
|
"crates/aether-data-contracts",
|
||||||
"crates/aether-pool-core",
|
"crates/aether-data-schema",
|
||||||
"crates/aether-provider/core",
|
|
||||||
"crates/aether-provider/pool",
|
|
||||||
"crates/aether-routing-core",
|
|
||||||
"crates/aether-data/contracts",
|
|
||||||
"crates/aether-data/adapters/postgres",
|
|
||||||
"crates/aether-data/query",
|
|
||||||
"crates/aether-data/schema",
|
|
||||||
"crates/aether-dispatch-core",
|
"crates/aether-dispatch-core",
|
||||||
"crates/aether-cache",
|
"crates/aether-cache",
|
||||||
"crates/aether-billing",
|
"crates/aether-billing",
|
||||||
"crates/aether-wallet",
|
"crates/aether-wallet",
|
||||||
"crates/aether-crypto",
|
"crates/aether-crypto",
|
||||||
"crates/aether-contracts",
|
"crates/aether-contracts",
|
||||||
"crates/aether-data/runtime",
|
"crates/aether-data",
|
||||||
"crates/aether-model-fetch",
|
"crates/aether-model-fetch",
|
||||||
"crates/aether-oauth",
|
"crates/aether-oauth",
|
||||||
"crates/aether-provider/transport",
|
"crates/aether-provider-transport",
|
||||||
"crates/aether-scheduler-core",
|
"crates/aether-scheduler-core",
|
||||||
"crates/aether-runtime/state",
|
"crates/aether-runtime-state",
|
||||||
"crates/aether-task/runtime",
|
"crates/aether-task-runtime",
|
||||||
"crates/aether-task/core",
|
"crates/aether-usage-runtime",
|
||||||
"crates/aether-gateway/frontdoor",
|
|
||||||
"crates/aether-gateway/control",
|
|
||||||
"crates/aether-gateway/execution",
|
|
||||||
"crates/aether-gateway/workers",
|
|
||||||
"crates/aether-gateway/tunnel",
|
|
||||||
"crates/aether-testing/loadtools",
|
|
||||||
"crates/aether-testing/integration",
|
|
||||||
"crates/aether-usage/core",
|
|
||||||
"crates/aether-testing/support",
|
|
||||||
"crates/aether-usage/runtime",
|
|
||||||
"crates/aether-video-tasks-core",
|
"crates/aether-video-tasks-core",
|
||||||
"apps/aether-gateway",
|
"apps/aether-gateway",
|
||||||
"crates/aether-http",
|
"crates/aether-http",
|
||||||
"crates/aether-runtime/base",
|
"crates/aether-runtime",
|
||||||
"crates/aether-testing/testkit",
|
"crates/aether-testkit",
|
||||||
]
|
|
||||||
default-members = [
|
|
||||||
"apps/aether-gateway",
|
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -55,91 +35,61 @@ repository = "https://github.com/fawney19/Aether.git"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
aether-admin = { path = "crates/aether-admin" }
|
aether-admin = { path = "crates/aether-admin" }
|
||||||
aether-admission-core = { path = "crates/aether-admission-core" }
|
aether-ai-formats = { path = "crates/aether-ai-formats" }
|
||||||
aether-ai-formats = { path = "crates/aether-ai/formats" }
|
aether-ai-serving = { path = "crates/aether-ai-serving" }
|
||||||
aether-ai-serving = { path = "crates/aether-ai/serving" }
|
aether-data-contracts = { path = "crates/aether-data-contracts" }
|
||||||
aether-pool-core = { path = "crates/aether-pool-core" }
|
aether-data-schema = { path = "crates/aether-data-schema" }
|
||||||
aether-provider-core = { path = "crates/aether-provider/core" }
|
|
||||||
aether-provider-pool = { path = "crates/aether-provider/pool" }
|
|
||||||
aether-routing-core = { path = "crates/aether-routing-core" }
|
|
||||||
aether-data-contracts = { path = "crates/aether-data/contracts" }
|
|
||||||
aether-data-postgres = { path = "crates/aether-data/adapters/postgres" }
|
|
||||||
aether-data-query = { path = "crates/aether-data/query" }
|
|
||||||
aether-data-schema = { path = "crates/aether-data/schema" }
|
|
||||||
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
aether-dispatch-core = { path = "crates/aether-dispatch-core" }
|
||||||
aether-cache = { path = "crates/aether-cache" }
|
aether-cache = { path = "crates/aether-cache" }
|
||||||
aether-billing = { path = "crates/aether-billing" }
|
aether-billing = { path = "crates/aether-billing" }
|
||||||
aether-wallet = { path = "crates/aether-wallet" }
|
aether-wallet = { path = "crates/aether-wallet" }
|
||||||
aether-crypto = { path = "crates/aether-crypto" }
|
aether-crypto = { path = "crates/aether-crypto" }
|
||||||
aether-contracts = { path = "crates/aether-contracts" }
|
aether-contracts = { path = "crates/aether-contracts" }
|
||||||
aether-data = { path = "crates/aether-data/runtime" }
|
aether-data = { path = "crates/aether-data" }
|
||||||
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
aether-model-fetch = { path = "crates/aether-model-fetch" }
|
||||||
aether-oauth = { path = "crates/aether-oauth" }
|
aether-oauth = { path = "crates/aether-oauth" }
|
||||||
aether-provider-transport = { path = "crates/aether-provider/transport" }
|
aether-provider-transport = { path = "crates/aether-provider-transport" }
|
||||||
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
aether-scheduler-core = { path = "crates/aether-scheduler-core" }
|
||||||
aether-runtime-state = { path = "crates/aether-runtime/state" }
|
aether-runtime-state = { path = "crates/aether-runtime-state" }
|
||||||
aether-task-runtime = { path = "crates/aether-task/runtime" }
|
aether-task-runtime = { path = "crates/aether-task-runtime" }
|
||||||
aether-task-core = { path = "crates/aether-task/core" }
|
aether-usage-runtime = { path = "crates/aether-usage-runtime" }
|
||||||
aether-gateway-frontdoor = { path = "crates/aether-gateway/frontdoor" }
|
|
||||||
aether-gateway-control = { path = "crates/aether-gateway/control" }
|
|
||||||
aether-gateway-execution = { path = "crates/aether-gateway/execution" }
|
|
||||||
aether-gateway-workers = { path = "crates/aether-gateway/workers" }
|
|
||||||
aether-gateway-tunnel = { path = "crates/aether-gateway/tunnel" }
|
|
||||||
aether-loadtools = { path = "crates/aether-testing/loadtools" }
|
|
||||||
aether-integration-tests = { path = "crates/aether-testing/integration" }
|
|
||||||
aether-test-support = { path = "crates/aether-testing/support" }
|
|
||||||
aether-usage-core = { path = "crates/aether-usage/core" }
|
|
||||||
aether-usage-runtime = { path = "crates/aether-usage/runtime" }
|
|
||||||
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
aether-video-tasks-core = { path = "crates/aether-video-tasks-core" }
|
||||||
aether-gateway = { path = "apps/aether-gateway" }
|
aether-gateway = { path = "apps/aether-gateway" }
|
||||||
aether-http = { path = "crates/aether-http" }
|
aether-http = { path = "crates/aether-http" }
|
||||||
aether-runtime = { path = "crates/aether-runtime/base" }
|
aether-runtime = { path = "crates/aether-runtime" }
|
||||||
aether-testkit = { path = "crates/aether-testing/testkit" }
|
aether-testkit = { path = "crates/aether-testkit" }
|
||||||
aes = "0.8"
|
aes = "0.8"
|
||||||
aes-gcm = "0.10"
|
|
||||||
aws-lc-rs = { version = "1.16.2", default-features = false, features = ["alloc", "aws-lc-sys"] }
|
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bcrypt = "0.16"
|
bcrypt = "0.16"
|
||||||
brotli = "8"
|
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
cbc = "0.1"
|
cbc = "0.1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
chrono-tz = "0.10"
|
chrono-tz = "0.10"
|
||||||
crypto_box = { version = "0.9", features = ["seal"] }
|
|
||||||
ed25519-dalek = { version = "2.2", features = ["pkcs8"] }
|
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
http = "1"
|
http = "1"
|
||||||
object_store = { version = "0.14.1", default-features = false, features = ["aws"] }
|
|
||||||
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
|
||||||
percent-encoding = "2"
|
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "http2", "socks"] }
|
||||||
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams", "connection-manager"] }
|
redis = { version = "0.28", default-features = false, features = ["tokio-comp", "script", "streams"] }
|
||||||
regex = "1"
|
regex = "1"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
semver = "1"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1", features = ["preserve_order"] }
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
serde_path_to_error = "0.1"
|
serde_path_to_error = "0.1"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
socket2 = "0.6"
|
sqlx = { version = "0.8", default-features = false, features = ["postgres", "mysql", "sqlite", "runtime-tokio-rustls", "chrono"] }
|
||||||
tar = "0.4"
|
|
||||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "chrono"] }
|
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
|
||||||
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
tokio-util = { version = "0.7", features = ["codec", "io-util"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
uuid = { version = "1", features = ["serde", "v4", "v5", "v7"] }
|
uuid = { version = "1", features = ["serde", "v4", "v5"] }
|
||||||
webpki-roots = "0.26"
|
webpki-roots = "0.26"
|
||||||
wreq = { version = "6.0.0-rc.28", default-features = false, features = ["json", "stream", "socks", "webpki-roots", "ws"] }
|
|
||||||
wreq-util = "3.0.0-rc.10"
|
|
||||||
url = "2"
|
url = "2"
|
||||||
zstd = "0.13"
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
# Keep file/line information for backtraces while avoiding full debug info
|
# Keep file/line information for backtraces while avoiding full debug info
|
||||||
|
|||||||
+16
-33
@@ -1,48 +1,31 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Aether Gateway runtime image (cross-compilation)
|
# Aether Gateway 运行时镜像(交叉编译方案)
|
||||||
# Binary and frontend assets are pre-built by CI; this Dockerfile only packages them.
|
# 二进制和前端产物均由 CI 预先构建,此 Dockerfile 仅做打包
|
||||||
# Usage: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
# 用法: docker buildx build --platform linux/amd64,linux/arm64 -f Dockerfile.app .
|
||||||
#
|
#
|
||||||
# Build context must contain:
|
# 构建上下文中须包含:
|
||||||
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl cross-compiled binary)
|
# dist/aether-gateway-amd64 (x86_64-unknown-linux-musl 交叉编译产物)
|
||||||
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl cross-compiled binary)
|
# dist/aether-gateway-arm64 (aarch64-unknown-linux-musl 交叉编译产物)
|
||||||
# dist/frontend/ (npm run build output)
|
# dist/frontend/ (npm run build 产物)
|
||||||
|
|
||||||
# --- layout stage: create /opt/aether directory structure with symlink ---
|
FROM gcr.io/distroless/static-debian12
|
||||||
# distroless has no shell, so we use busybox to set up the symlink.
|
|
||||||
FROM busybox:1.37.0-musl@sha256:fc6dddc4c44b1bfe37f41cae8e67d1693828e8f42a91862816d7953e2c9d3f23 AS layout
|
|
||||||
|
|
||||||
|
# TARGETARCH 由 buildx 自动注入: amd64 或 arm64
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN mkdir -p /opt/aether/releases/image/bin /opt/aether/releases/image/frontend /opt/aether/logs
|
COPY dist/aether-gateway-${TARGETARCH} /usr/local/bin/aether-gateway
|
||||||
|
COPY dist/frontend/ /srv/frontend
|
||||||
|
|
||||||
COPY dist/aether-gateway-${TARGETARCH} /opt/aether/releases/image/bin/aether-gateway
|
WORKDIR /app
|
||||||
COPY dist/frontend/ /opt/aether/releases/image/frontend/
|
|
||||||
|
|
||||||
# Keep the immutable release root-owned while guaranteeing that the runtime
|
|
||||||
# identity can traverse and read every packaged asset.
|
|
||||||
RUN chmod -R u=rwX,go=rX /opt/aether/releases/image \
|
|
||||||
&& chmod 0755 /opt/aether/releases/image/bin/aether-gateway
|
|
||||||
|
|
||||||
RUN ln -s /opt/aether/releases/image /opt/aether/current
|
|
||||||
|
|
||||||
# --- final stage: distroless runtime ---
|
|
||||||
FROM gcr.io/distroless/static-debian12@sha256:6447365a6337c3732f412d1b74357b30a633831955b2bc45552b0086be907687
|
|
||||||
|
|
||||||
COPY --from=layout /opt/aether /opt/aether
|
|
||||||
|
|
||||||
WORKDIR /opt/aether
|
|
||||||
|
|
||||||
ENV RUST_LOG=aether_gateway=info \
|
ENV RUST_LOG=aether_gateway=info \
|
||||||
APP_PORT=8084 \
|
APP_PORT=8084 \
|
||||||
HOME=/tmp/aether-home \
|
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||||
AETHER_UPDATE_STRATEGY=docker \
|
|
||||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
|
||||||
|
|
||||||
EXPOSE 8084
|
EXPOSE 8084
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||||
|
|
||||||
USER 65532:65532
|
USER root
|
||||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||||
|
|||||||
+9
-38
@@ -1,42 +1,23 @@
|
|||||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
# Aether 运行镜像:Rust gateway 直接服务 API + 前端静态文件(国内镜像源版本)
|
||||||
# 构建命令: docker build --build-arg AETHER_BUILD_VERSION=v0.7.2 -f Dockerfile.app.local -t aether-app:latest .
|
# 构建命令: docker build -f Dockerfile.app.local -t aether-app:latest .
|
||||||
|
|
||||||
ARG RUST_VERSION=1.95.0
|
|
||||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
|
||||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
|
||||||
|
|
||||||
# ==================== 前端构建 ====================
|
# ==================== 前端构建 ====================
|
||||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
FROM node:22-slim AS frontend-builder
|
||||||
ARG AETHER_BUILD_VERSION
|
|
||||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
|
||||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
|
||||||
WORKDIR /app/aether-vscodex/web
|
|
||||||
COPY aether-vscodex/web/package*.json ./
|
|
||||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
|
||||||
npm config set registry https://registry.npmmirror.com && \
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
|
||||||
COPY aether-vscodex/web/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
RUN npm config set registry https://registry.npmmirror.com && npm ci
|
||||||
npm config set registry https://registry.npmmirror.com && \
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ==================== Rust gateway 构建 ====================
|
# ==================== Rust gateway 构建 ====================
|
||||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
FROM rust:1.94.1-slim AS gateway-base
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# 生产级 release 构建:保留 thin LTO,同时用 lld 缩短最终链接阶段。
|
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。
|
||||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
||||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
CARGO_PROFILE_RELEASE_LTO=thin \
|
||||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 \
|
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||||
RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=lld"
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||||
@@ -44,12 +25,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
clang \
|
|
||||||
cmake \
|
|
||||||
git \
|
|
||||||
libclang-dev \
|
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
lld \
|
|
||||||
pkg-config \
|
pkg-config \
|
||||||
perl
|
perl
|
||||||
|
|
||||||
@@ -64,14 +40,11 @@ COPY crates/ ./crates/
|
|||||||
RUN cargo chef prepare --recipe-path recipe.json
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
FROM gateway-base AS gateway-builder
|
FROM gateway-base AS gateway-builder
|
||||||
ARG AETHER_BUILD_VERSION
|
|
||||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
|
||||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
|
||||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
||||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --recipe-path recipe.json
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY apps/ ./apps/
|
COPY apps/ ./apps/
|
||||||
@@ -79,8 +52,7 @@ COPY crates/ ./crates/
|
|||||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||||
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
--mount=type=cache,id=aether-cargo-target-local,target=/build/target,sharing=locked \
|
||||||
set -eux; \
|
cargo build --release --locked -p aether-gateway && \
|
||||||
cargo build --release --locked -p aether-gateway --bin aether-gateway --features jemalloc; \
|
|
||||||
cp target/release/aether-gateway /tmp/aether-gateway
|
cp target/release/aether-gateway /tmp/aether-gateway
|
||||||
|
|
||||||
# ==================== 最小运行时打包 ====================
|
# ==================== 最小运行时打包 ====================
|
||||||
@@ -149,7 +121,6 @@ ENV LANG=C.UTF-8 \
|
|||||||
LC_ALL=C.UTF-8 \
|
LC_ALL=C.UTF-8 \
|
||||||
RUST_LOG=aether_gateway=info \
|
RUST_LOG=aether_gateway=info \
|
||||||
APP_PORT=8084 \
|
APP_PORT=8084 \
|
||||||
AETHER_UPDATE_STRATEGY=manual \
|
|
||||||
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
AETHER_GATEWAY_STATIC_DIR=/srv/frontend
|
||||||
|
|
||||||
EXPOSE 8084
|
EXPOSE 8084
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
# syntax=docker.m.daocloud.io/docker/dockerfile:1
|
|
||||||
# Aether 本地发布版联调镜像
|
|
||||||
# 作用:用当前源码构建一个 release-layout 容器,专门测试管理后台在线更新流程。
|
|
||||||
|
|
||||||
ARG RUST_VERSION=1.95.0
|
|
||||||
ARG NODE_BASE_IMAGE=docker.m.daocloud.io/library/node:22-slim
|
|
||||||
ARG RUST_BASE_IMAGE=docker.m.daocloud.io/library/rust:${RUST_VERSION}-slim
|
|
||||||
|
|
||||||
# ==================== 前端构建 ====================
|
|
||||||
FROM ${NODE_BASE_IMAGE} AS frontend-builder
|
|
||||||
ARG AETHER_BUILD_VERSION
|
|
||||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
|
||||||
AETHER_VERSION=${AETHER_BUILD_VERSION}
|
|
||||||
WORKDIR /app/aether-vscodex/web
|
|
||||||
COPY aether-vscodex/web/package*.json ./
|
|
||||||
RUN --mount=type=cache,id=aether-vscodex-npm-cache,target=/root/.npm,sharing=locked \
|
|
||||||
npm config set registry https://registry.npmmirror.com && \
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
COPY aether-vscodex/public /app/aether-vscodex/public
|
|
||||||
COPY aether-vscodex/web/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
WORKDIR /app/frontend
|
|
||||||
COPY frontend/package*.json ./
|
|
||||||
RUN --mount=type=cache,id=aether-npm-cache,target=/root/.npm,sharing=locked \
|
|
||||||
npm config set registry https://registry.npmmirror.com && \
|
|
||||||
npm ci --no-audit --no-fund
|
|
||||||
COPY frontend/ ./
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# ==================== Rust gateway 构建 ====================
|
|
||||||
FROM ${RUST_BASE_IMAGE} AS gateway-base
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse \
|
|
||||||
CARGO_PROFILE_RELEASE_LTO=thin \
|
|
||||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
|
||||||
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 --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
ca-certificates \
|
|
||||||
cmake \
|
|
||||||
git \
|
|
||||||
libclang-dev \
|
|
||||||
libssl-dev \
|
|
||||||
pkg-config \
|
|
||||||
perl
|
|
||||||
|
|
||||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
|
||||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
|
||||||
cargo install cargo-chef --locked
|
|
||||||
|
|
||||||
FROM gateway-base AS gateway-planner
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY apps/ ./apps/
|
|
||||||
COPY crates/ ./crates/
|
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
FROM gateway-base AS gateway-builder
|
|
||||||
ARG AETHER_BUILD_VERSION
|
|
||||||
ARG AETHER_BUILD_TYPE=release
|
|
||||||
ENV AETHER_BUILD_VERSION=${AETHER_BUILD_VERSION} \
|
|
||||||
AETHER_VERSION=${AETHER_BUILD_VERSION} \
|
|
||||||
AETHER_BUILD_TYPE=${AETHER_BUILD_TYPE}
|
|
||||||
COPY --from=gateway-planner /build/recipe.json ./recipe.json
|
|
||||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
|
||||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
|
||||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
|
||||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --features jemalloc --recipe-path recipe.json
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY apps/ ./apps/
|
|
||||||
COPY crates/ ./crates/
|
|
||||||
RUN --mount=type=cache,id=aether-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
|
||||||
--mount=type=cache,id=aether-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
|
||||||
--mount=type=cache,id=aether-cargo-target-release-local,target=/build/target,sharing=locked \
|
|
||||||
cargo build --release --locked -p aether-gateway --features jemalloc && \
|
|
||||||
cp target/release/aether-gateway /tmp/aether-gateway
|
|
||||||
|
|
||||||
# ==================== 最小运行时打包 ====================
|
|
||||||
FROM gateway-builder AS runtime-prep
|
|
||||||
RUN set -eux; \
|
|
||||||
mkdir -p \
|
|
||||||
/runtime-root/app/data \
|
|
||||||
/runtime-root/etc \
|
|
||||||
/runtime-root/etc/ssl \
|
|
||||||
/runtime-root/lib \
|
|
||||||
/runtime-root/lib64 \
|
|
||||||
/runtime-root/usr/lib \
|
|
||||||
/runtime-root/opt/aether/logs \
|
|
||||||
/runtime-root/opt/aether/releases/image/bin \
|
|
||||||
/runtime-root/opt/aether/releases/image/frontend; \
|
|
||||||
cp /tmp/aether-gateway /runtime-root/opt/aether/releases/image/bin/aether-gateway; \
|
|
||||||
ln -s /opt/aether/releases/image /runtime-root/opt/aether/current; \
|
|
||||||
: > /tmp/runtime-libs.txt; \
|
|
||||||
: > /tmp/runtime-scan-queue.txt; \
|
|
||||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
|
||||||
while [ -s /tmp/runtime-scan-queue.txt ]; do \
|
|
||||||
current="$(head -n1 /tmp/runtime-scan-queue.txt)"; \
|
|
||||||
sed -i '1d' /tmp/runtime-scan-queue.txt; \
|
|
||||||
ldd "$current" | awk '/=>/ { print $3 } $1 ~ /^\// { print $1 }' | while read -r lib; do \
|
|
||||||
[ -n "$lib" ]; \
|
|
||||||
if ! grep -Fxq "$lib" /tmp/runtime-libs.txt; then \
|
|
||||||
printf '%s\n' "$lib" >> /tmp/runtime-libs.txt; \
|
|
||||||
printf '%s\n' "$lib" >> /tmp/runtime-scan-queue.txt; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
done; \
|
|
||||||
sort -u /tmp/runtime-libs.txt -o /tmp/runtime-libs.txt; \
|
|
||||||
while read -r lib; do \
|
|
||||||
[ -n "$lib" ]; \
|
|
||||||
dest="/runtime-root$(dirname "$lib")"; \
|
|
||||||
mkdir -p "$dest"; \
|
|
||||||
cp -L "$lib" "$dest/"; \
|
|
||||||
done < /tmp/runtime-libs.txt; \
|
|
||||||
for lib in \
|
|
||||||
/lib/x86_64-linux-gnu/libnss_dns.so.2 \
|
|
||||||
/lib/x86_64-linux-gnu/libnss_files.so.2 \
|
|
||||||
/lib/x86_64-linux-gnu/libresolv.so.2; do \
|
|
||||||
if [ -f "$lib" ]; then \
|
|
||||||
dest="/runtime-root$(dirname "$lib")"; \
|
|
||||||
mkdir -p "$dest"; \
|
|
||||||
cp -L "$lib" "$dest/"; \
|
|
||||||
fi; \
|
|
||||||
done; \
|
|
||||||
cp -a /usr/lib/ssl /runtime-root/usr/lib/; \
|
|
||||||
cp -a /etc/ssl/certs /runtime-root/etc/ssl/; \
|
|
||||||
if [ -f /etc/ssl/openssl.cnf ]; then \
|
|
||||||
cp /etc/ssl/openssl.cnf /runtime-root/etc/ssl/openssl.cnf; \
|
|
||||||
fi; \
|
|
||||||
if [ -f /etc/nsswitch.conf ]; then \
|
|
||||||
cp /etc/nsswitch.conf /runtime-root/etc/nsswitch.conf; \
|
|
||||||
fi
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist /runtime-root/opt/aether/releases/image/frontend
|
|
||||||
|
|
||||||
# ==================== 运行时镜像 ====================
|
|
||||||
FROM scratch
|
|
||||||
|
|
||||||
COPY --from=runtime-prep /runtime-root/ /
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV LANG=C.UTF-8 \
|
|
||||||
LC_ALL=C.UTF-8 \
|
|
||||||
RUST_LOG=aether_gateway=info \
|
|
||||||
APP_PORT=8084 \
|
|
||||||
AETHER_BASE_DIR=/opt/aether \
|
|
||||||
AETHER_UPDATE_STRATEGY=self \
|
|
||||||
AETHER_GATEWAY_STATIC_DIR=/opt/aether/current/frontend
|
|
||||||
|
|
||||||
EXPOSE 8084
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD ["/opt/aether/current/bin/aether-gateway", "--healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/opt/aether/current/bin/aether-gateway"]
|
|
||||||
@@ -1,582 +0,0 @@
|
|||||||
SHELL := /bin/bash
|
|
||||||
|
|
||||||
DEV_RUST_LOG := info,executor::candidate_loop=debug,stream::execution=debug
|
|
||||||
ifeq ($(origin RUST_LOG), command line)
|
|
||||||
DEV_RUST_LOG := $(RUST_LOG)
|
|
||||||
endif
|
|
||||||
export DEV_RUST_LOG
|
|
||||||
|
|
||||||
.PHONY: dev dev-backend dev-frontend db-status db-prepare migration backfill
|
|
||||||
|
|
||||||
define DEV_BACKEND_SCRIPT
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
if [[ -n "$${ADMIN_EMAIL:-}" || -n "$${ADMIN_USERNAME:-}" || -n "$${ADMIN_PASSWORD:-}" ]]; then
|
|
||||||
if [[ -z "$${ADMIN_USERNAME:-}" || -z "$${ADMIN_PASSWORD:-}" ]]; then
|
|
||||||
echo "=> 管理员自举配置不完整,请在 .env 中设置 ADMIN_USERNAME 和 ADMIN_PASSWORD"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
dotenv_has_key() {
|
|
||||||
local key="$$1"
|
|
||||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
|
||||||
}
|
|
||||||
|
|
||||||
lowercase() {
|
|
||||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
|
||||||
}
|
|
||||||
|
|
||||||
dev_uses_postgres_database() {
|
|
||||||
local driver
|
|
||||||
local url
|
|
||||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
|
||||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
|
||||||
|
|
||||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
dev_uses_redis_runtime() {
|
|
||||||
local backend
|
|
||||||
backend="$$(lowercase "$${AETHER_RUNTIME_BACKEND:-}")"
|
|
||||||
|
|
||||||
if [[ "$${backend}" == "memory" ]]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
if [[ "$${backend}" == "redis" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
print_dev_infra_hint() {
|
|
||||||
echo "=> 本地开发依赖未就绪。"
|
|
||||||
echo "=> 可手动启动 Postgres / Redis:"
|
|
||||||
echo "=> docker compose up -d postgres redis"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_postgres_ready() {
|
|
||||||
local host="$$1"
|
|
||||||
local port="$$2"
|
|
||||||
|
|
||||||
if command -v pg_isready >/dev/null 2>&1; then
|
|
||||||
pg_isready -h "$${host}" -p "$${port}" >/dev/null 2>&1
|
|
||||||
return $$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v nc >/dev/null 2>&1; then
|
|
||||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
|
||||||
return $$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
check_redis_ready() {
|
|
||||||
local host="$$1"
|
|
||||||
local port="$$2"
|
|
||||||
local password="$$3"
|
|
||||||
|
|
||||||
if command -v redis-cli >/dev/null 2>&1; then
|
|
||||||
REDISCLI_AUTH="$${password}" redis-cli -h "$${host}" -p "$${port}" ping >/dev/null 2>&1
|
|
||||||
return $$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v nc >/dev/null 2>&1; then
|
|
||||||
nc -z "$${host}" "$${port}" >/dev/null 2>&1
|
|
||||||
return $$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
is_local_host() {
|
|
||||||
case "$$1" in
|
|
||||||
localhost|127.0.0.1|::1)
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_dev_infra() {
|
|
||||||
local postgres_host="$${DB_HOST:-localhost}"
|
|
||||||
local postgres_port="$${DB_PORT:-5432}"
|
|
||||||
local redis_host="$${REDIS_HOST:-localhost}"
|
|
||||||
local redis_port="$${REDIS_PORT:-6379}"
|
|
||||||
local redis_password="$${REDIS_PASSWORD:-}"
|
|
||||||
local need_postgres=false
|
|
||||||
local need_redis=false
|
|
||||||
local services=()
|
|
||||||
|
|
||||||
if dev_uses_postgres_database; then
|
|
||||||
if ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
|
||||||
if is_local_host "$${postgres_host}"; then
|
|
||||||
need_postgres=true
|
|
||||||
services+=(postgres)
|
|
||||||
else
|
|
||||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
|
||||||
print_dev_infra_hint
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if dev_uses_redis_runtime; then
|
|
||||||
if ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
|
||||||
if is_local_host "$${redis_host}"; then
|
|
||||||
need_redis=true
|
|
||||||
services+=(redis)
|
|
||||||
else
|
|
||||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
|
||||||
print_dev_infra_hint
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$${#services[@]}" -eq 0 ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v docker >/dev/null 2>&1; then
|
|
||||||
echo "=> 未找到 docker,无法自动启动本地开发依赖。"
|
|
||||||
print_dev_infra_hint
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=> 本地开发依赖未就绪,正在启动: docker compose up -d $${services[*]}"
|
|
||||||
if ! docker compose up -d "$${services[@]}"; then
|
|
||||||
echo "=> docker compose 启动本地开发依赖失败。"
|
|
||||||
print_dev_infra_hint
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for _ in {1..100}; do
|
|
||||||
local ready=true
|
|
||||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
|
||||||
ready=false
|
|
||||||
fi
|
|
||||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
|
||||||
ready=false
|
|
||||||
fi
|
|
||||||
if [ "$${ready}" = "true" ]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$${need_postgres}" = "true" ] && ! check_postgres_ready "$${postgres_host}" "$${postgres_port}"; then
|
|
||||||
echo "=> PostgreSQL 不可用: $${postgres_host}:$${postgres_port}"
|
|
||||||
fi
|
|
||||||
if [ "$${need_redis}" = "true" ] && ! check_redis_ready "$${redis_host}" "$${redis_port}" "$${redis_password}"; then
|
|
||||||
echo "=> Redis 不可用: $${redis_host}:$${redis_port}"
|
|
||||||
fi
|
|
||||||
print_dev_infra_hint
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
print_startup_failure_hint() {
|
|
||||||
local log_file="$$1"
|
|
||||||
|
|
||||||
if [ -n "$${log_file}" ] && [ -f "$${log_file}" ]; then
|
|
||||||
if grep -Eq "database schema is behind" "$${log_file}"; then
|
|
||||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eq "database backfills are behind" "$${log_file}"; then
|
|
||||||
echo "=> 检测到数据库尚未准备完成,请执行: make db-prepare"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if grep -Eq "bootstrap admin env is partially configured.*ADMIN_PASSWORD" "$${log_file}"; then
|
|
||||||
echo "=> 首次启动需要管理员密码,请在 .env 中设置 ADMIN_PASSWORD"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=> 未识别到明确的修复动作,请根据上面的日志继续排查。"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_startup() {
|
|
||||||
local pid="$$1"
|
|
||||||
local timeout_seconds="$$2"
|
|
||||||
local service_name="$$3"
|
|
||||||
shift 3
|
|
||||||
|
|
||||||
STARTUP_WAIT_EARLY_EXIT=false
|
|
||||||
|
|
||||||
local attempts=$$((timeout_seconds * 10))
|
|
||||||
if [ "$${attempts}" -lt 1 ]; then
|
|
||||||
attempts=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
for ((i = 0; i < attempts; i++)); do
|
|
||||||
if "$$@" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
|
||||||
STARTUP_WAIT_EARLY_EXIT=true
|
|
||||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
|
||||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
if "$$@" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$${pid}" >/dev/null 2>&1; then
|
|
||||||
STARTUP_WAIT_EARLY_EXIT=true
|
|
||||||
echo "=> $${service_name} 启动进程已提前退出,请检查上面的日志。"
|
|
||||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=> $${service_name} 在 $${timeout_seconds}s 内未通过启动检查。"
|
|
||||||
echo "=> 如果这是冷编译或存在并发 cargo 构建,可调大启动超时后重试。"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
create_gateway_log_file() {
|
|
||||||
local tmp_root="$${TMPDIR:-/tmp}"
|
|
||||||
tmp_root="$${tmp_root%/}"
|
|
||||||
|
|
||||||
GATEWAY_LOG_DIR="$$(mktemp -d "$${tmp_root}/aether-dev-startup.XXXXXX")"
|
|
||||||
GATEWAY_LOG_FILE="$${GATEWAY_LOG_DIR}/gateway.log"
|
|
||||||
: > "$${GATEWAY_LOG_FILE}"
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
local status="$${1:-0}"
|
|
||||||
trap - INT TERM EXIT
|
|
||||||
|
|
||||||
if [ -n "$${GATEWAY_PID:-}" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "=> 停止 aether-gateway..."
|
|
||||||
kill "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
|
||||||
wait "$${GATEWAY_PID}" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$${GATEWAY_LOG_FILE:-}" ] && [ -f "$${GATEWAY_LOG_FILE}" ]; then
|
|
||||||
rm -f "$${GATEWAY_LOG_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -n "$${GATEWAY_LOG_DIR:-}" ] && [ -d "$${GATEWAY_LOG_DIR}" ]; then
|
|
||||||
rmdir "$${GATEWAY_LOG_DIR}" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$${status}"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap 'cleanup 130' INT
|
|
||||||
trap 'cleanup 143' TERM
|
|
||||||
trap 'cleanup $$?' EXIT
|
|
||||||
|
|
||||||
export APP_PORT="$${APP_PORT:-8084}"
|
|
||||||
export RUST_LOG="$${DEV_RUST_LOG}"
|
|
||||||
RUST_SERVICE_STARTUP_TIMEOUT_SECONDS="$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS:-180}"
|
|
||||||
GATEWAY_STARTUP_TIMEOUT_SECONDS="$${GATEWAY_STARTUP_TIMEOUT_SECONDS:-$${RUST_SERVICE_STARTUP_TIMEOUT_SECONDS}}"
|
|
||||||
export AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE="$${AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE:-rust-authoritative}"
|
|
||||||
|
|
||||||
if dev_uses_postgres_database; then
|
|
||||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
|
||||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
|
||||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if dev_uses_redis_runtime; then
|
|
||||||
export REDIS_URL="redis://:$${REDIS_PASSWORD:-}@$${REDIS_HOST:-localhost}:$${REDIS_PORT:-6379}/0"
|
|
||||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_REDIS_URL"; then
|
|
||||||
export AETHER_GATEWAY_DATA_REDIS_URL="$${REDIS_URL}"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
unset REDIS_URL
|
|
||||||
unset AETHER_GATEWAY_DATA_REDIS_URL
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
|
||||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
export DB_POOL_SIZE="$${DB_POOL_SIZE:-5}"
|
|
||||||
export DB_MAX_OVERFLOW="$${DB_MAX_OVERFLOW:-5}"
|
|
||||||
export HTTP_MAX_CONNECTIONS="$${HTTP_MAX_CONNECTIONS:-20}"
|
|
||||||
export HTTP_KEEPALIVE_CONNECTIONS="$${HTTP_KEEPALIVE_CONNECTIONS:-5}"
|
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
|
||||||
echo "=> 未找到 cargo,无法启动 aether-gateway。请先安装 Rust toolchain。"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v curl >/dev/null 2>&1; then
|
|
||||||
echo "=> 未找到 curl,无法检查 aether-gateway 健康状态。请先安装 curl。"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$${RUSTC_WRAPPER:-}" ] && command -v sccache >/dev/null 2>&1; then
|
|
||||||
export RUSTC_WRAPPER="$$(command -v sccache)"
|
|
||||||
echo "=> 启用 Rust 编译缓存: $${RUSTC_WRAPPER}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! ensure_dev_infra; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=> 编译 aether-gateway..."
|
|
||||||
cargo build -p aether-gateway --bin aether-gateway
|
|
||||||
|
|
||||||
GATEWAY_PID=""
|
|
||||||
GATEWAY_LOG_DIR=""
|
|
||||||
GATEWAY_LOG_FILE=""
|
|
||||||
STARTUP_WAIT_EARLY_EXIT=false
|
|
||||||
create_gateway_log_file
|
|
||||||
|
|
||||||
echo "=> 启动 aether-gateway (Rust frontdoor: 0.0.0.0:$${APP_PORT})..."
|
|
||||||
echo "=> 日志过滤: $${RUST_LOG}"
|
|
||||||
echo "=> 执行命令: target/debug/aether-gateway --app-port $${APP_PORT}"
|
|
||||||
target/debug/aether-gateway --app-port "$${APP_PORT}" > >(
|
|
||||||
tee -a "$${GATEWAY_LOG_FILE}"
|
|
||||||
) 2>&1 &
|
|
||||||
GATEWAY_PID=$$!
|
|
||||||
|
|
||||||
if ! wait_for_startup "$${GATEWAY_PID}" "$${GATEWAY_STARTUP_TIMEOUT_SECONDS}" "aether-gateway" curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health"; then
|
|
||||||
if [ "$${STARTUP_WAIT_EARLY_EXIT}" = "true" ]; then
|
|
||||||
GATEWAY_PID=""
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if wait "$${GATEWAY_PID}"; then
|
|
||||||
gateway_exit_code=0
|
|
||||||
else
|
|
||||||
gateway_exit_code=$$?
|
|
||||||
fi
|
|
||||||
|
|
||||||
GATEWAY_PID=""
|
|
||||||
|
|
||||||
if [ "$${gateway_exit_code}" -ne 130 ] && [ "$${gateway_exit_code}" -ne 143 ]; then
|
|
||||||
echo "=> aether-gateway 运行失败并已退出,请检查上面的日志。"
|
|
||||||
print_startup_failure_hint "$${GATEWAY_LOG_FILE}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$${gateway_exit_code}"
|
|
||||||
endef
|
|
||||||
export DEV_BACKEND_SCRIPT
|
|
||||||
|
|
||||||
define DEV_SCRIPT
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
backend_pid=""
|
|
||||||
frontend_pid=""
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
local status="$${1:-0}"
|
|
||||||
trap - INT TERM EXIT
|
|
||||||
|
|
||||||
if [ -n "$${backend_pid}" ] || [ -n "$${frontend_pid}" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "=> 停止本地开发服务..."
|
|
||||||
if [ -n "$${backend_pid}" ]; then
|
|
||||||
kill "$${backend_pid}" >/dev/null 2>&1 || true
|
|
||||||
wait "$${backend_pid}" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
if [ -n "$${frontend_pid}" ]; then
|
|
||||||
kill "$${frontend_pid}" >/dev/null 2>&1 || true
|
|
||||||
wait "$${frontend_pid}" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$${status}"
|
|
||||||
}
|
|
||||||
|
|
||||||
wait_for_backend_ready() {
|
|
||||||
while :; do
|
|
||||||
if curl -sf "http://127.0.0.1:$${APP_PORT}/_gateway/health" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
|
||||||
if wait "$${backend_pid}"; then
|
|
||||||
status=0
|
|
||||||
else
|
|
||||||
status=$$?
|
|
||||||
fi
|
|
||||||
if [ "$${status}" -ne 0 ]; then
|
|
||||||
echo "=> 后端进程已退出 (status $${status})"
|
|
||||||
else
|
|
||||||
echo "=> 后端进程已退出"
|
|
||||||
fi
|
|
||||||
backend_pid=""
|
|
||||||
cleanup "$${status}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 0.2
|
|
||||||
done
|
|
||||||
}
|
|
||||||
|
|
||||||
trap 'cleanup 130' INT
|
|
||||||
trap 'cleanup 143' TERM
|
|
||||||
trap 'cleanup $$?' EXIT
|
|
||||||
|
|
||||||
if [ -f .env ]; then
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
export APP_PORT="$${APP_PORT:-8084}"
|
|
||||||
|
|
||||||
echo "=> 启动后端: 先编译 aether-gateway,再运行 target/debug/aether-gateway --app-port $${APP_PORT:-8084}"
|
|
||||||
/bin/bash -euo pipefail -c "$$DEV_BACKEND_SCRIPT" &
|
|
||||||
backend_pid=$$!
|
|
||||||
|
|
||||||
echo "=> 等待后端健康检查: http://127.0.0.1:$${APP_PORT}/_gateway/health"
|
|
||||||
wait_for_backend_ready
|
|
||||||
|
|
||||||
echo "=> 启动前端: cd frontend && npm run dev"
|
|
||||||
( cd frontend && exec npm run dev ) &
|
|
||||||
frontend_pid=$$!
|
|
||||||
|
|
||||||
while :; do
|
|
||||||
if ! kill -0 "$${backend_pid}" >/dev/null 2>&1; then
|
|
||||||
if wait "$${backend_pid}"; then
|
|
||||||
status=0
|
|
||||||
else
|
|
||||||
status=$$?
|
|
||||||
fi
|
|
||||||
if [ "$${status}" -ne 0 ]; then
|
|
||||||
echo "=> 后端进程已退出 (status $${status})"
|
|
||||||
else
|
|
||||||
echo "=> 后端进程已退出"
|
|
||||||
fi
|
|
||||||
backend_pid=""
|
|
||||||
cleanup "$${status}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! kill -0 "$${frontend_pid}" >/dev/null 2>&1; then
|
|
||||||
if wait "$${frontend_pid}"; then
|
|
||||||
status=0
|
|
||||||
else
|
|
||||||
status=$$?
|
|
||||||
fi
|
|
||||||
if [ "$${status}" -ne 0 ]; then
|
|
||||||
echo "=> 前端进程已退出 (status $${status})"
|
|
||||||
else
|
|
||||||
echo "=> 前端进程已退出"
|
|
||||||
fi
|
|
||||||
frontend_pid=""
|
|
||||||
cleanup "$${status}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
endef
|
|
||||||
export DEV_SCRIPT
|
|
||||||
|
|
||||||
define DB_TASK_SCRIPT
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ -z "$${DB_TASK_COMMAND:-}" ] || [ -z "$${DB_TASK_LABEL:-}" ]; then
|
|
||||||
echo "=> 内部错误: DB_TASK_COMMAND / DB_TASK_LABEL 未设置"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
read -r -a db_task_args <<< "$${DB_TASK_COMMAND}"
|
|
||||||
if [ "$${#db_task_args[@]}" -eq 0 ]; then
|
|
||||||
echo "=> 内部错误: DB_TASK_COMMAND 为空"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
|
||||||
echo "=> 未找到 .env,请先执行: cp .env.example .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
|
|
||||||
dotenv_has_key() {
|
|
||||||
local key="$$1"
|
|
||||||
grep -Eq "^[[:space:]]*$${key}=" .env
|
|
||||||
}
|
|
||||||
|
|
||||||
lowercase() {
|
|
||||||
printf '%s' "$$1" | tr '[:upper:]' '[:lower:]'
|
|
||||||
}
|
|
||||||
|
|
||||||
uses_postgres_database() {
|
|
||||||
local driver
|
|
||||||
local url
|
|
||||||
driver="$$(lowercase "$${AETHER_DATABASE_DRIVER:-}")"
|
|
||||||
url="$${AETHER_DATABASE_URL:-$${DATABASE_URL:-}}"
|
|
||||||
|
|
||||||
if [[ -z "$${driver}" && -z "$${url}" ]]; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
[[ "$${driver}" == "postgres" || "$${driver}" == "postgresql" || "$${url}" == postgres:* || "$${url}" == postgresql:* ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
if uses_postgres_database; then
|
|
||||||
export DATABASE_URL="postgresql://$${DB_USER:-postgres}:$${DB_PASSWORD:-}@$${DB_HOST:-localhost}:$${DB_PORT:-5432}/$${DB_NAME:-aether}"
|
|
||||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_POSTGRES_URL"; then
|
|
||||||
export AETHER_GATEWAY_DATA_POSTGRES_URL="$${DATABASE_URL}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! dotenv_has_key "AETHER_GATEWAY_DATA_ENCRYPTION_KEY"; then
|
|
||||||
export AETHER_GATEWAY_DATA_ENCRYPTION_KEY="$${ENCRYPTION_KEY:-}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
|
||||||
echo "=> 未找到 cargo,无法执行 $${DB_TASK_LABEL}。请先安装 Rust toolchain。"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=> 执行 $${DB_TASK_LABEL}: cargo run -p aether-gateway --bin aether-gateway -- $${db_task_args[*]}"
|
|
||||||
exec cargo run -p aether-gateway --bin aether-gateway -- "$${db_task_args[@]}"
|
|
||||||
endef
|
|
||||||
export DB_TASK_SCRIPT
|
|
||||||
|
|
||||||
dev:
|
|
||||||
@$(SHELL) -euo pipefail -c "$$DEV_SCRIPT"
|
|
||||||
|
|
||||||
dev-backend:
|
|
||||||
@$(SHELL) -euo pipefail -c "$$DEV_BACKEND_SCRIPT"
|
|
||||||
|
|
||||||
dev-frontend:
|
|
||||||
@cd frontend && npm run dev
|
|
||||||
|
|
||||||
db-status:
|
|
||||||
@DB_TASK_COMMAND="db status" DB_TASK_LABEL="数据库状态检查" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
|
||||||
|
|
||||||
db-prepare:
|
|
||||||
@DB_TASK_COMMAND="db prepare" DB_TASK_LABEL="数据库准备" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
|
||||||
|
|
||||||
migration:
|
|
||||||
@DB_TASK_COMMAND="--migrate" DB_TASK_LABEL="数据库迁移" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
|
||||||
|
|
||||||
backfill:
|
|
||||||
@DB_TASK_COMMAND="--apply-backfills" DB_TASK_LABEL="数据库 backfill" $(SHELL) -euo pipefail -c "$$DB_TASK_SCRIPT"
|
|
||||||
@@ -44,193 +44,214 @@ cd Aether
|
|||||||
|
|
||||||
# 2. 配置环境变量
|
# 2. 配置环境变量
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# .env 包含数据库、JWT 和数据加密密钥,先限制为仅当前用户可读写
|
./generate_keys.sh # 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||||
chmod 600 .env
|
|
||||||
# 生成 JWT / 加密 / Postgres / Redis 独立随机密钥,并填入 .env
|
|
||||||
./generate_keys.sh
|
|
||||||
# 编辑 .env 设置 ADMIN_PASSWORD
|
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||||
|
|
||||||
# 3. 首次部署 / 更新 (从以下部署形态任选其一)
|
# 3. 首次部署 / 更新
|
||||||
# Postgres + Redis (推荐)
|
|
||||||
docker compose pull && docker compose up -d
|
docker compose pull && docker compose up -d
|
||||||
# Single Node:同样使用 PostgreSQL + Redis,无需挂载本地数据库文件
|
|
||||||
docker compose -f docker-compose.single-node.yml pull && docker compose -f docker-compose.single-node.yml up -d
|
# 4. 默认会在 app 启动前自动执行挂起的 migration / backfill
|
||||||
|
# 如需手工控制,可在 .env 中设 AETHER_GATEWAY_AUTO_PREPARE_DATABASE=false
|
||||||
|
# 然后按需执行:
|
||||||
|
docker compose run --rm app --migrate
|
||||||
|
docker compose run --rm app --apply-backfills
|
||||||
|
|
||||||
|
# 5. 升级前备份 (可选)
|
||||||
|
docker compose exec postgres pg_dump -U postgres aether | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
应用镜像默认以固定非 root 身份 `65532:65532` 运行;Compose 移除全部 Linux capabilities、禁止提权、启用只读根文件系统,并提供带 `nosuid,nodev,noexec` 的 `/tmp`。如需使用其他身份,可在 `.env` 中设置非零的 `AETHER_CONTAINER_UID` / `AETHER_CONTAINER_GID`。数据库使用独立 PostgreSQL 容器和 named volume,不再需要调整应用数据库目录的权限。
|
### Docker Compose(本地构建镜像)
|
||||||
|
|
||||||
|
|
||||||
### 一键更新
|
|
||||||
|
|
||||||
Docker Compose 部署后,可在部署目录直接执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./update.sh
|
# 1. 克隆代码
|
||||||
```
|
git clone https://github.com/fawney19/Aether.git
|
||||||
|
cd Aether
|
||||||
|
|
||||||
`update.sh` 会拉取最新 `app` 镜像并重建 `app` 容器,Docker named volumes、`./data` 和 `./logs` 不会被删除。Single Node 部署也可显式指定:
|
# 2. 配置环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
./generate_keys.sh # 生成 JWT_SECRET_KEY / ENCRYPTION_KEY, 并填入 .env
|
||||||
|
# 编辑 .env 设置 ADMIN_PASSWORD
|
||||||
|
|
||||||
```bash
|
# 3. 部署 / 更新(自动构建并启动)
|
||||||
./update.sh --mode single-node
|
git pull
|
||||||
```
|
|
||||||
|
|
||||||
现在仅支持 PostgreSQL。标准和单节点 Docker Compose 均部署 PostgreSQL + Redis;原生 systemd / launchd 安装需要显式提供 PostgreSQL `DATABASE_URL`,例如 `DATABASE_URL=postgresql://user:password@host:5432/aether`。旧数据库不会自动迁移或清空。升级时保留原有 PostgreSQL 密码、`JWT_SECRET_KEY` 和 `ENCRYPTION_KEY`,不要重新生成整个 `.env`。
|
|
||||||
|
|
||||||
仓库自带的 Docker Compose 默认把应用日志输出到容器 `stdout/stderr`,直接用 `docker compose logs -f app` 查看,并由 Docker 轮转日志,避免非 root 用户被宿主机日志目录权限拖垮启动。如果你确实需要文件日志,需要在 compose 里把 `AETHER_LOG_DESTINATION` 改成 `file|both`,额外挂载目录到 `/opt/aether/logs`,并让它归 `.env` 中配置的容器 UID/GID 所有;只读根文件系统不会阻止显式可写挂载。
|
|
||||||
|
|
||||||
管理后台右上角“版本信息”会检测新版本。Docker Compose 部署只提示版本,实际更新继续执行 `./update.sh`;systemd / launchd / 二进制部署才使用后台自更新,流程是下载对应平台的 GitHub Release 包、强制校验 `SHA256SUMS`、解压到 `/opt/aether/releases/<version>`,再切换 `/opt/aether/current` 并退出进程,交给 systemd / launchd 拉起新版本。
|
|
||||||
|
|
||||||
正式 Release 还会发布由 GitHub Actions OIDC / Sigstore 签发的 SLSA build provenance。需要验证发布者身份时,下载目标 tarball 和 `AETHER_RELEASE_PROVENANCE.sigstore.json`,并把 `TAG` 设置为对应 Release tag:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh attestation verify "aether-${TAG}-linux-amd64.tar.gz" \
|
|
||||||
--repo fawney19/Aether \
|
|
||||||
--signer-workflow fawney19/Aether/.github/workflows/release.yml \
|
|
||||||
--source-ref "refs/tags/${TAG}" \
|
|
||||||
--bundle AETHER_RELEASE_PROVENANCE.sigstore.json
|
|
||||||
```
|
|
||||||
|
|
||||||
`docker-compose.yml` 中的官方 PostgreSQL 和 Redis 镜像均固定到多架构 OCI index digest。升级这些依赖时应在发布变更中显式更新 digest,避免同名 tag 在无人审查的情况下改变部署内容。
|
|
||||||
|
|
||||||
正式发布到 GHCR 和 Docker Hub 的多架构 Aether 镜像也带有同一 GitHub Actions OIDC / Sigstore provenance;生产 `Dockerfile.app` 的 BusyBox 与 Distroless 基础镜像同样固定到多架构 OCI index digest。
|
|
||||||
|
|
||||||
源码或本地构建版本不会启用后台在线更新,请继续使用源码更新流程。Docker Compose 用户如果希望“容器重建后也保持镜像层面的新版本”,仍建议定期运行 `./update.sh` 拉取并重建 app 镜像。服务器访问 GitHub 需要代理时,可设置 `AETHER_UPDATE_PROXY_URL`,也兼容 `UPDATE_PROXY_URL`、`HTTPS_PROXY`、`ALL_PROXY`、`HTTP_PROXY` 以及 `NO_PROXY`。共享出口触发 GitHub API 限流时,可设置只读 `AETHER_UPDATE_GITHUB_TOKEN`,也兼容 `GITHUB_TOKEN` / `GH_TOKEN`。下载总超时默认 600 秒,连续无响应/无数据默认 30 秒,可通过 `AETHER_UPDATE_DOWNLOAD_TIMEOUT_SECS` 和 `AETHER_UPDATE_DOWNLOAD_IDLE_TIMEOUT_SECS` 调整。
|
|
||||||
|
|
||||||
标准和 Single Node Docker Compose 均使用 Docker named volume 存放 PostgreSQL 数据。
|
|
||||||
|
|
||||||
如果是本地源码构建镜像的部署,继续使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
如果要在本机联调“管理后台在线更新”本身,可启动仓库内置的 release-layout 测试环境:
|
### 一键安装(可选部署方式)
|
||||||
|
|
||||||
|
安装脚本先从 `aether-rust-pioneer` 分支下载,不依赖 GitHub Release 的 `latest` 脚本地址。运行后会先选择语言,再选择版本和部署方式。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.release-local.yml up -d --build
|
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/aether-rust-pioneer/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
这套环境会用当前源码构建一个本地测试镜像,但编译为 `release` 类型,并默认伪装成 `v0.7.0`,这样后台会按正式发布版逻辑开放“立即更新”。默认监听 `http://127.0.0.1:18085`,数据目录使用 `./data-release-local`;日志默认走 `docker logs`,不会影响你正在跑的源码构建容器。
|
运行后按提示输入语言、版本和部署方式。固定安装某个 tag 时,版本选择选 `2`,再输入类似 `v0.7.0-rc23` 的 tag。默认会安装最新预发布版本;Docker Compose 模式默认使用 `pre` 镜像通道。
|
||||||
|
systemd 二进制安装在下载 Release 压缩包前会询问是否使用下载加速源;选择使用时会先打印原始 GitHub URL,再要求输入新的压缩包下载 URL。非交互式安装可用 `AETHER_RELEASE_ARCHIVE_URL` 指定压缩包 URL。
|
||||||
|
如果安装目录里已经有配置,脚本会优先复用:Docker Compose 保留已有 `.env`,systemd 保留已有 `/etc/aether/aether-gateway.env`。只有首次生成新配置时才会提示输入管理员密码。
|
||||||
|
|
||||||
如果这套容器在 `prepare-update` 时访问 GitHub 失败,而你本机是通过代理出网,请在 `.env` 里把 `AETHER_UPDATE_PROXY_URL` 写成宿主机地址,例如 `http://host.docker.internal:7890`;容器内的 `127.0.0.1` 指向容器自身,不是宿主机。
|
```text
|
||||||
|
请选择安装语言 / Choose installer language:
|
||||||
|
1) 中文
|
||||||
|
2) 英语 / English
|
||||||
|
|
||||||
如果想重置这套联调环境(包括 `/opt/aether/current` 和已下载的历史版本),执行:
|
请输入选项 / Enter choice [1]:
|
||||||
|
|
||||||
|
请选择 Aether 版本:
|
||||||
|
1) 最新预发布版本
|
||||||
|
2) 指定 tag,例如 v0.7.0-rc23
|
||||||
|
|
||||||
|
请输入选项 [1]:
|
||||||
|
|
||||||
|
请选择 Aether 部署模式:
|
||||||
|
1) Docker Compose: 应用 + Postgres + Redis
|
||||||
|
2) 单机服务: systemd + SQLite + 进程内运行时
|
||||||
|
3) 集群节点服务: systemd + 共享数据库 + Redis
|
||||||
|
|
||||||
|
请输入选项 [2]:
|
||||||
|
|
||||||
|
是否使用下载加速源?
|
||||||
|
1) 否,使用原始 GitHub 地址
|
||||||
|
2) 是,手动填写新的下载 URL
|
||||||
|
|
||||||
|
请输入选项 [1]:
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后的常用命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.release-local.yml down -v
|
sudo systemctl status aether-gateway --no-pager
|
||||||
|
sudo journalctl -u aether-gateway -f
|
||||||
|
sudo systemctl restart aether-gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
可选变量:
|
默认单机数据和日志都在安装目录内:
|
||||||
|
|
||||||
- `AETHER_RELEASE_LOCAL_VERSION`:本地联调镜像对外声明的当前版本,默认 `v0.7.0`
|
```text
|
||||||
- `AETHER_RELEASE_LOCAL_PORT`:本地联调端口,默认 `18085`
|
/opt/aether/data/aether.db
|
||||||
- `LOCAL_RELEASE_APP_IMAGE`:本地联调镜像名,默认 `aether-app:release-local`
|
/opt/aether/logs
|
||||||
|
```
|
||||||
|
|
||||||
### 一键安装(PostgreSQL + Redis)
|
多节点不能使用 SQLite 或 `AETHER_RUNTIME_BACKEND=memory`。如果先只生成了多节点模板,编辑 `/etc/aether/aether-gateway.env` 后重跑安装脚本即可:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
|
||||||
|
AETHER_GATEWAY_NODE_ROLE=frontdoor
|
||||||
|
DATABASE_URL=postgresql://...
|
||||||
|
REDIS_URL=redis://...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/fawney19/Aether.git
|
# 启动依赖
|
||||||
cd Aether
|
docker compose -f docker-compose.build.yml up -d postgres redis
|
||||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose
|
|
||||||
|
# 数据库迁移(仅在已有数据库引入新 migration 时需要)
|
||||||
|
./dev.sh --migrate
|
||||||
|
|
||||||
|
# 数据回填
|
||||||
|
./dev.sh --apply-backfills
|
||||||
|
|
||||||
|
# 后端
|
||||||
|
./dev.sh
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
原生 Linux systemd / macOS launchd 安装需先准备 PostgreSQL,将连接串通过 `DATABASE_URL` 传给安装进程,并选择 `--mode single-node`;不再自动创建本地数据库文件。
|
`./dev.sh` 现在只保留一种本地模式:
|
||||||
|
|
||||||
### Nightly(每日 main 构建)
|
| 角色 | 本地地址 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| Rust frontdoor | 默认 `http://localhost:8084` | `aether-gateway`,本地唯一公开入口;实际端口由 `APP_PORT` 控制 |
|
||||||
|
|
||||||
Nightly workflow 每天从 `main` 的固定 commit 构建并发布滚动的 GitHub Release `nightly`,同时推送多架构 GHCR 镜像 `ghcr.io/fawney19/aether:nightly`。Nightly 是预发布版本,适合验证最新代码,不保证与正式版相同的稳定性。滚动 Release 需要仓库保持关闭 GitHub Release immutability。
|
本地默认链路是:
|
||||||
|
|
||||||
安装最新 nightly(PostgreSQL + Redis):
|
```text
|
||||||
|
client -> rust frontdoor (aether-gateway) -> execution_runtime/provider transport
|
||||||
```bash
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/fawney19/Aether/main/install.sh | sudo bash -s -- --mode compose --channel nightly
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker Compose 用户可在部署目录的 `.env` 中设置 `APP_IMAGE=ghcr.io/fawney19/aether:nightly`,然后运行 `./update.sh` 获取下一次 nightly。二进制部署请沿用已有 PostgreSQL 环境配置,并使用 `--mode single-node --channel nightly` 重新运行安装脚本升级;当前管理后台的在线更新列表只跟踪正式版/RC/Beta,不会自动提示下一次 nightly。
|
其中:
|
||||||
|
|
||||||
## 本地开发
|
- `aether-gateway` 负责公开入口、健康检查、格式转换、本地执行 runtime,以及当前已迁到 Rust 的 frontdoor/control/background 路径。
|
||||||
|
- `./dev.sh` 不再启动 Python 宿主;未下沉到 Rust 的 legacy 路由会直接失败。
|
||||||
|
- `./dev.sh --migrate` 会复用 `.env` 里的数据库配置,显式执行一次数据库迁移后退出。
|
||||||
|
- `./dev.sh` 默认把 `AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE` 设为 `rust-authoritative`,避免本地还依赖 Python sync report 语义。
|
||||||
|
- 空库首次启动会自动初始化到当前 baseline。
|
||||||
|
- `aether-gateway` 默认启动不会自动应用后续 schema migration;如果数据库版本落后,服务会拒绝启动,并提示先执行 `aether-gateway --migrate`。
|
||||||
|
- 仓库自带的 `docker-compose.yml` 和 `docker-compose.build.yml` 都已把 `AETHER_GATEWAY_AUTO_PREPARE_DATABASE` 设为默认开启,因此无论是预构建镜像部署还是 `./deploy.sh` / 本地构建 compose,常规启动都会在监听端口前自动执行挂起的 migration 和 backfill。
|
||||||
|
|
||||||
依赖 Docker、Rust toolchain、Node.js 和 make。
|
## Aether Proxy (可选)
|
||||||
首次启动前需要在 `.env` 中设置 `ADMIN_PASSWORD`,用于创建本地管理员。
|
|
||||||
|
|
||||||
```bash
|
Aether Proxy 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。或者部署在其他服务器为指定的提供商、账号、Key使用不同的节点访问。支持 TUI 向导一键配置、systemd 服务管理、TLS 加密、DNS 缓存及连接池调优。
|
||||||
make dev
|
|
||||||
```
|
|
||||||
|
|
||||||
`make dev` 会同时启动后端 `aether-gateway` 和前端 `frontend` 的 Vite dev server。需要单独启动时可使用 `make dev-backend` 或 `make dev-frontend`。
|
|
||||||
Postgres / Redis 本地依赖未就绪时,`make dev` 会自动执行 `docker compose up -d postgres redis`。
|
|
||||||
`make dev` 会先完成后端编译,再开始计算服务健康检查超时。数据库 schema 和必要的派生数据准备也会在启动时自动完成;通常不需要手动区分 migration 与 backfill。升级不会主动重写或清除已有业务历史记录,新写入会直接遵循当前的数据持久化策略。排查或部署前预执行时可使用:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make db-status
|
|
||||||
make db-prepare
|
|
||||||
```
|
|
||||||
|
|
||||||
## Codex 远程协同
|
|
||||||
|
|
||||||
`aether-vscodex/` 是独立的 VS Code Codex 协同模块:同步模式跟随 VS Code 官方 Codex 面板当前会话且不另起进程;异步模式使用独立 app-server,让浏览器自行列出、恢复、新建和切换会话。两种模式都能从本机 URL 或 Aether 云端查看输出、发送消息和处理授权,模块内的 Vue 前端提供中英文界面。
|
|
||||||
|
|
||||||
安装、云端配对和安全边界请参阅 [`aether-vscodex/README.md`](aether-vscodex/README.md)。
|
|
||||||
|
|
||||||
## Aether Tunnel (可选)
|
|
||||||
|
|
||||||
Aether Tunnel 是配套的正向代理节点,部署在海外 VPS 上,为墙内的 Aether 实例中转 API 流量。
|
|
||||||
|
|
||||||
- Docker Compose 部署或下载预编译二进制直接运行
|
- Docker Compose 部署或下载预编译二进制直接运行
|
||||||
- 提供 macOS/Linux 与 Windows 一键脚本,自动下载最新 `tunnel-v*` 制品并向现有 `aether-tunnel.toml` 追加 `[[servers]]`
|
- 通过 `aether-proxy setup` 完成交互式配置,自动注册为系统服务
|
||||||
- 通过 `aether-tunnel setup` 完成交互式配置,自动注册为系统服务
|
- 详细文档见 [apps/aether-proxy/README.md](apps/aether-proxy/README.md)
|
||||||
- 详细文档见 [apps/aether-tunnel/README.md](apps/aether-tunnel/README.md)
|
|
||||||
|
|
||||||
## API 文档
|
## API 文档
|
||||||
|
|
||||||
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
- Embeddings: [OpenAI compatible `POST /v1/embeddings`](docs/api/embeddings.md)
|
||||||
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
- Rerank: [OpenAI/Jina compatible `POST /v1/rerank`](docs/api/rerank.md)
|
||||||
- Responses WebSocket mode: [protocol and Aether behavior](docs/WebSocket-Mode.md)
|
|
||||||
- WebSocket probes: [Codex](docs/operations/codex-responses-websocket-probe.md) · [OpenAI Responses](docs/operations/openai-responses-websocket-probe.md)
|
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
|
部署建议:
|
||||||
|
|
||||||
|
- Docker Compose:根目录 [`.env.example`](.env.example)
|
||||||
|
- systemd 二进制部署:使用根目录 `install.sh` 生成 `/etc/aether/aether-gateway.env`
|
||||||
|
|
||||||
|
当前主链路真正要关注的是这组变量:
|
||||||
|
|
||||||
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
- `APP_PORT`:`aether-gateway` 唯一监听端口,固定绑定 `0.0.0.0:${APP_PORT}`
|
||||||
- `DATABASE_URL`:PostgreSQL 连接串,例如 `postgresql://USER:PASSWORD@HOST:5432/aether`
|
- `AETHER_DATABASE_DRIVER` / `AETHER_DATABASE_URL`:二进制单机部署可用 `sqlite`,例如 `sqlite:///opt/aether/data/aether.db`
|
||||||
- `AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS` / `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`:数据库连接池手动覆盖值;未配置时 PostgreSQL 按每核 `4` 条自动推导,总池范围为 `32-100`。该预算按进程计算,多实例部署应按数据库连接上限显式分配
|
- `DATABASE_URL` / `REDIS_URL`:共享后端连接串;多节点必须配置共享数据库和 Redis
|
||||||
- `AETHER_GATEWAY_MAX_IN_FLIGHT_REQUESTS`:单实例请求并发上限;未配置时按 CPU 自动推导(基础范围 `512-65536`),低文件描述符预算时会进一步下调
|
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。单机 SQLite 默认用 `memory`,不会连接 Redis;显式设为 `redis` 或多节点部署才会把 `REDIS_URL` 注入运行时 Redis 后端
|
||||||
- `AETHER_GATEWAY_REQUEST_BODY_BUFFER_BUDGET_MB`:单实例同时读取和解压请求体的加权内存预算,默认 `256MB`
|
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:常规启动前自动执行挂起的 schema migration 和 backfill;仓库自带的 `docker-compose.yml` 和 `docker-compose.build.yml` 默认开启
|
||||||
- `AETHER_GATEWAY_REQUEST_BODY_READ_TIMEOUT_MS`:可选的请求体完整读取超时;默认或显式设为 `0` 时关闭,非零值限制在 `1000-600000ms`
|
|
||||||
- `AETHER_MAX_REQUEST_BODY_MB`:单请求解压后请求体上限,默认 `256MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
|
||||||
- `AETHER_MAX_INTERNAL_BUFFERED_BODY_MB`:heartbeat、管理探测等内部整包响应体上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
|
||||||
- `AETHER_TUNNEL_NODE_STATUS_QUEUE_CAPACITY`:隧道节点状态上报队列容量,默认 `1024`;满载时拒绝新事件,避免控制面故障导致无界内存增长
|
|
||||||
- `AETHER_TUNNEL_RELAY_ALLOW_PRIVATE_TARGETS`:跨网关 owner relay 解析到私有/保留地址时的显式运维开关,默认关闭;仅当多网关 relay URL 是受控的内网 HTTPS 地址时设置为 `true`。它不改变普通 provider 请求的 DNS/代理策略,也不允许明文 HTTP 非 loopback relay
|
|
||||||
- `AETHER_TUNNEL_RELAY_PRIVATE_HOST_ALLOWLIST`:更窄的 owner relay 私网例外,填写逗号分隔的精确主机名(例如 `gateway-a.internal,gateway-b.internal`,忽略大小写和末尾点);仅这些主机解析出的私有地址会被允许,并且请求仍使用解析后地址 pin。不要填写通配符或 `.internal` 这类后缀
|
|
||||||
- `AETHER_INTERNAL_GATEWAY_AUTH_SECRET`:旧版 `/api/internal/gateway/*` 高权限控制面的独立 HMAC 密钥,至少 `32` 字节;未配置时该控制面返回 `404`。不要复用 JWT、数据加密或 tunnel relay 密钥,多节点必须使用同一值及共享 Redis 防重放
|
|
||||||
- `AETHER_GATEWAY_SECURITY_CACHE_TTL_MS`:IP 黑白名单本地缓存时间,默认 `1000ms`,写操作会主动失效相关缓存
|
|
||||||
- `AETHER_MAX_REDACTED_SYNC_RESPONSE_BODY_MB`:PII 恢复同步响应缓冲上限,默认 `64MB`;显式设为 `0` 表示不再收紧默认值,但仍受 `256MB` 安全硬上限约束
|
|
||||||
- `REDIS_URL`:Redis 连接串;仅 Postgres + Redis 的 Docker Compose 部署需要配置
|
|
||||||
- `AETHER_RUNTIME_BACKEND=memory|redis`:运行时缓存/协调后端。配置 Redis 时使用 `redis`,否则使用 `memory`;多节点部署和需要跨 gateway 重启恢复 OpenAI Responses continuation history 的部署必须使用共享 Redis
|
|
||||||
- `AETHER_GATEWAY_DATABASE_MODE=auto|verify-only`:数据库启动策略,默认 `auto`,自动完成挂起的 schema migration 和 backfill;`verify-only` 仅检查并在数据库落后时拒绝启动
|
|
||||||
- `AETHER_GATEWAY_AUTO_PREPARE_DATABASE`:旧版兼容开关;新配置请使用 `AETHER_GATEWAY_DATABASE_MODE`
|
|
||||||
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
- `JWT_SECRET_KEY` / `ENCRYPTION_KEY`:认证和敏感数据加密所需密钥
|
||||||
- `AETHER_BACKUP_ENCRYPTION_KEY`:推荐的 S3 备份独立加密密钥;缺省回退到 `ENCRYPTION_KEY`。新备份使用带 key ID 的 AES-256-GCM v2 envelope,轮换前必须保留旧密钥
|
|
||||||
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
- `API_KEY_PREFIX`:用户和管理员新建 API Key 时使用的前缀,默认 `sk`
|
||||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` / `ADMIN_EMAIL`:首次启动时自举首个本地管理员;`install.sh` 会提示输入管理员密码
|
||||||
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
- `CORS_ORIGINS` / `CORS_ALLOW_CREDENTIALS`:前端跨域来源控制;如果要跨域带登录 Cookie,`CORS_ORIGINS` 不能写 `*`
|
||||||
|
- `AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=single-node|multi-node`
|
||||||
|
- `AETHER_GATEWAY_NODE_ROLE=all|frontdoor|background`
|
||||||
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
- `RUST_LOG`:Rust 日志过滤,例如 `aether_gateway=info`、`aether_gateway=debug,sqlx=warn`
|
||||||
- `DB_PASSWORD` / `REDIS_PASSWORD`:Docker Compose 后端密码,首次安装时分别随机生成;手工部署必须替换示例占位值,不要互相复用
|
- Docker Compose 的 `DB_PASSWORD` / `REDIS_PASSWORD` 默认使用 `aether`
|
||||||
|
|
||||||
### S3 备份离线恢复
|
systemd 的 `.env` 必须保持简单 `KEY=VALUE` 形式,不要写 `export`、`${VAR}` 或命令替换。
|
||||||
|
|
||||||
先从 S3 下载完整的 `.json.zst.aes256gcm` 对象,再使用原始的完整 S3 object key 做认证解密。恢复工具只验证并输出本地 JSON,不会直接写数据库;数据库导入仍应在维护窗口通过管理端完成。
|
## Q&A
|
||||||
|
|
||||||
|
### Q: 如何开启/关闭请求体记录?
|
||||||
|
|
||||||
|
管理员在 **系统设置** 中配置日志记录的详细程度:
|
||||||
|
|
||||||
|
| 级别 | 记录内容 |
|
||||||
|
|------|----------|
|
||||||
|
| Base | 基本请求信息 |
|
||||||
|
| Headers | Base + 请求头 |
|
||||||
|
| Full | Headers + 请求体 |
|
||||||
|
|
||||||
|
### Q: 更新出问题如何回滚?
|
||||||
|
|
||||||
|
**有备份的情况(推荐):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AETHER_BACKUP_ENCRYPTION_KEY='原备份密钥' \
|
# Docker Compose:
|
||||||
cargo run -p aether-gateway --bin aether-backup-restore -- \
|
# 1. 切回旧镜像 tag / digest
|
||||||
--input ./backup.json.zst.aes256gcm \
|
# 2. 恢复 Postgres 备份
|
||||||
--object-key 'aether/backups/aether-data-backup-20260822-010000.json.zst.aes256gcm' \
|
# 3. 再启动 app
|
||||||
--output ./restored-backup.json
|
|
||||||
|
# systemd:
|
||||||
|
# 1. 把 /opt/aether/current 切回旧 release
|
||||||
|
# 2. systemctl restart aether-gateway
|
||||||
|
# 3. 如果升级包含数据库结构变更,再恢复 Postgres 备份
|
||||||
```
|
```
|
||||||
|
|
||||||
工具默认拒绝覆盖,输出采用原子写并在 Unix 上设置为 `0600`;Unix 可用 `--overwrite` 原子替换,Windows 为避免非原子删除窗口会要求选择新输出路径。密钥不能作为命令行参数。可使用 `AETHER_BACKUP_ENCRYPTION_KEY`、兼容用 `AETHER_GATEWAY_DATA_ENCRYPTION_KEY` / `ENCRYPTION_KEY`、受保护的 `--key-file`,或 `AETHER_BACKUP_KEYRING_FILE`。Keyring JSON 格式为 `{"version":1,"keys":["当前或历史 v2 secret"],"legacy_v1":["旧 v1 secret"]}`;条目也可写成 `{"secret":"..."}`(兼容字段名 `key`)。也可由 `AETHER_BACKUP_HISTORICAL_KEYS_JSON` 提供同一结构。密钥文件必须是非符号链接的普通文件,Unix 下权限需为 `0600` 或更严格。
|
> 可以在升级前通过 `docker inspect ghcr.io/fawney19/aether:latest --format '{{index .RepoDigests 0}}'` 记录当前镜像 digest,方便回滚时使用。
|
||||||
|
|
||||||
默认限制密文为 `512MiB`、解压后 JSON 为 `1GiB`,可通过受限的 `--max-encrypted-mib` / `--max-json-mib` 调整。网关最多扫描同一备份前缀下 10,000 个对象,并且不会自动删除 S3 对象:`backup_s3_retention_count` 只用于报告超出保留数量的清理候选。旧明文备份在创建并验证加密副本后仍会保留,必须通过 bucket lifecycle 或支持版本条件的外部清理工具移除;启用 Versioning 时还需清理 noncurrent versions,Object Lock/retention 可能阻止物理删除。
|
**没有备份的情况:**
|
||||||
|
|
||||||
|
当前不应该再依赖旧的 `alembic downgrade` 路线。空库首次启动会自动初始化;如果开启 `AETHER_GATEWAY_AUTO_PREPARE_DATABASE=true`(仓库自带的两份 compose 默认都如此),常规服务启动也会自动应用挂起的 migration/backfill。无论是否自动执行,只要本次发布带来了不可逆的数据结构变化,没有备份就不能保证安全回滚。因此升级前强烈建议先备份 `Postgres`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -248,4 +269,4 @@ AETHER_BACKUP_ENCRYPTION_KEY='原备份密钥' \
|
|||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/?repos=fawney19%2FAether&type=date&legend=top-left)
|
[](https://star-history.com/#fawney19/Aether&Date)
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""AI Proxy
|
||||||
|
|
||||||
|
A proxy server that enables AI models to work with multiple API providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 注意: dotenv 加载已统一移至 src/config/settings.py
|
||||||
|
# 不要在此处重复加载
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src._version import __version__
|
||||||
|
except ImportError:
|
||||||
|
__version__ = "0.0.0.dev0"
|
||||||
|
|
||||||
|
__author__ = "AI Proxy"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# file generated by vcs-versioning
|
||||||
|
# don't change, don't track in version control
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"__version_tuple__",
|
||||||
|
"version",
|
||||||
|
"version_tuple",
|
||||||
|
"__commit_id__",
|
||||||
|
"commit_id",
|
||||||
|
]
|
||||||
|
|
||||||
|
version: str
|
||||||
|
__version__: str
|
||||||
|
__version_tuple__: tuple[int | str, ...]
|
||||||
|
version_tuple: tuple[int | str, ...]
|
||||||
|
commit_id: str | None
|
||||||
|
__commit_id__: str | None
|
||||||
|
|
||||||
|
__version__ = version = '0.6.4.dev6+gddf18fed9.d20260331'
|
||||||
|
__version_tuple__ = version_tuple = (0, 6, 4, 'dev6', 'gddf18fed9.d20260331')
|
||||||
|
|
||||||
|
__commit_id__ = commit_id = None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Alembic 配置文件
|
||||||
|
# 用于数据库版本化迁移
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# 迁移脚本存放目录
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# 模板文件
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# 时区(用于生成迁移文件的时间戳)
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
# 数据库连接 URL(会被 env.py 从环境变量覆盖)
|
||||||
|
# Docker 环境中会从 DATABASE_URL 环境变量读取
|
||||||
|
sqlalchemy.url = postgresql://postgres:${DB_PASSWORD}@localhost:5432/aether
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Alembic 环境配置
|
||||||
|
用于数据库迁移的运行时环境设置
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool, text
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# 添加项目根目录到 Python 路径
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# 加载 .env 文件(本地开发时需要)
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
env_file = Path(__file__).parent.parent / ".env"
|
||||||
|
if env_file.exists():
|
||||||
|
load_dotenv(env_file)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 导入所有数据库模型(确保 Alembic 能检测到所有表)
|
||||||
|
from src.models.database import Base
|
||||||
|
|
||||||
|
# Alembic Config 对象
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# 从环境变量获取数据库 URL
|
||||||
|
# 优先使用 DATABASE_URL,否则从 DB_PASSWORD 自动构建(与 docker compose 保持一致)
|
||||||
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
if not database_url:
|
||||||
|
db_password = os.getenv("DB_PASSWORD", "")
|
||||||
|
db_host = os.getenv("DB_HOST", "localhost")
|
||||||
|
db_port = os.getenv("DB_PORT", "5432")
|
||||||
|
db_name = os.getenv("DB_NAME", "aether")
|
||||||
|
db_user = os.getenv("DB_USER", "postgres")
|
||||||
|
database_url = f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# 目标元数据(包含所有表定义)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
|
||||||
|
# 使用会话级 advisory lock(pg_advisory_lock),在迁移完成后手动释放。
|
||||||
|
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
|
||||||
|
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""
|
||||||
|
离线模式运行迁移
|
||||||
|
|
||||||
|
在离线模式下,不需要连接数据库,
|
||||||
|
只生成 SQL 脚本
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
compare_type=True, # 比较列类型变更
|
||||||
|
compare_server_default=True, # 比较默认值变更
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""
|
||||||
|
在线模式运行迁移
|
||||||
|
|
||||||
|
在线模式下,直接连接数据库执行迁移
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
try:
|
||||||
|
# 使用会话级 advisory lock(非事务级),避免干扰 Alembic 的事务管理。
|
||||||
|
# pg_advisory_lock 在会话结束时自动释放,不受 COMMIT/ROLLBACK 影响。
|
||||||
|
if connection.dialect.name == "postgresql":
|
||||||
|
connection.execute(
|
||||||
|
text("SELECT pg_advisory_lock(:lock_id)"),
|
||||||
|
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
|
||||||
|
)
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
compare_server_default=True,
|
||||||
|
transaction_per_migration=True, # 每个迁移文件独立事务,完成即提交
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
except Exception:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 根据模式选择运行方式
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:升级到新版本"""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:降级到旧版本"""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,775 @@
|
|||||||
|
"""Baseline migration - all tables consolidated
|
||||||
|
|
||||||
|
Revision ID: 20251210_baseline
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-12-10
|
||||||
|
|
||||||
|
This is the consolidated baseline migration that creates all tables from scratch.
|
||||||
|
Includes all schema changes up to circuit breaker v2.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers
|
||||||
|
revision = "20251210_baseline"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create ENUM types (with IF NOT EXISTS for idempotency)
|
||||||
|
op.execute("DO $$ BEGIN CREATE TYPE userrole AS ENUM ('admin', 'user'); EXCEPTION WHEN duplicate_object THEN NULL; END $$")
|
||||||
|
op.execute(
|
||||||
|
"DO $$ BEGIN CREATE TYPE providerbillingtype AS ENUM ('monthly_quota', 'pay_as_you_go', 'free_tier'); EXCEPTION WHEN duplicate_object THEN NULL; END $$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== users ====================
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("email", sa.String(255), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("username", sa.String(100), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"role",
|
||||||
|
postgresql.ENUM("admin", "user", name="userrole", create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default="user",
|
||||||
|
),
|
||||||
|
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||||
|
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||||
|
sa.Column("model_capability_settings", sa.JSON, nullable=True),
|
||||||
|
sa.Column("quota_usd", sa.Float, nullable=True),
|
||||||
|
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("total_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("is_deleted", sa.Boolean, server_default="false", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== providers ====================
|
||||||
|
op.create_table(
|
||||||
|
"providers",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("website", sa.String(500), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"billing_type",
|
||||||
|
postgresql.ENUM(
|
||||||
|
"monthly_quota", "pay_as_you_go", "free_tier", name="providerbillingtype", create_type=False
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pay_as_you_go",
|
||||||
|
),
|
||||||
|
sa.Column("monthly_quota_usd", sa.Float, nullable=True),
|
||||||
|
sa.Column("monthly_used_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("quota_reset_day", sa.Integer, server_default="30"),
|
||||||
|
sa.Column("quota_last_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("quota_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("rpm_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("rpm_used", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("provider_priority", sa.Integer, server_default="100"),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("concurrent_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("config", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== global_models ====================
|
||||||
|
op.create_table(
|
||||||
|
"global_models",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("name", sa.String(100), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("display_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("icon_url", sa.String(500), nullable=True),
|
||||||
|
sa.Column("official_url", sa.String(500), nullable=True),
|
||||||
|
sa.Column("default_price_per_request", sa.Float, nullable=True),
|
||||||
|
sa.Column("default_tiered_pricing", sa.JSON, nullable=False),
|
||||||
|
sa.Column("default_supports_vision", sa.Boolean, server_default="false", nullable=True),
|
||||||
|
sa.Column("default_supports_function_calling", sa.Boolean, server_default="false", nullable=True),
|
||||||
|
sa.Column("default_supports_streaming", sa.Boolean, server_default="true", nullable=True),
|
||||||
|
sa.Column("default_supports_extended_thinking", sa.Boolean, server_default="false", nullable=True),
|
||||||
|
sa.Column("default_supports_image_generation", sa.Boolean, server_default="false", nullable=True),
|
||||||
|
sa.Column("supported_capabilities", sa.JSON, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("usage_count", sa.Integer, server_default="0", nullable=False, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== api_keys ====================
|
||||||
|
op.create_table(
|
||||||
|
"api_keys",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("key_hash", sa.String(64), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("key_encrypted", sa.Text, nullable=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=True),
|
||||||
|
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("balance_used_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("current_balance_usd", sa.Float, nullable=True),
|
||||||
|
sa.Column("is_standalone", sa.Boolean, server_default="false", nullable=False),
|
||||||
|
sa.Column("allowed_providers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("allowed_endpoints", sa.JSON, nullable=True),
|
||||||
|
sa.Column("allowed_api_formats", sa.JSON, nullable=True),
|
||||||
|
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||||
|
sa.Column("rate_limit", sa.Integer, server_default="100"),
|
||||||
|
sa.Column("concurrent_limit", sa.Integer, server_default="5", nullable=True),
|
||||||
|
sa.Column("force_capabilities", sa.JSON, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("auto_delete_on_expiry", sa.Boolean, server_default="false", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== provider_endpoints ====================
|
||||||
|
op.create_table(
|
||||||
|
"provider_endpoints",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("api_format", sa.String(50), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(500), nullable=False),
|
||||||
|
sa.Column("headers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("timeout", sa.Integer, server_default="300"),
|
||||||
|
sa.Column("max_retries", sa.Integer, server_default="3"),
|
||||||
|
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||||
|
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("custom_path", sa.String(200), nullable=True),
|
||||||
|
sa.Column("config", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("provider_id", "api_format", name="uq_provider_api_format"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_endpoint_format_active", "provider_endpoints", ["api_format", "is_active"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== models ====================
|
||||||
|
op.create_table(
|
||||||
|
"models",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"global_model_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("global_models.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("provider_model_name", sa.String(200), nullable=False),
|
||||||
|
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||||
|
sa.Column("tiered_pricing", sa.JSON, nullable=True),
|
||||||
|
sa.Column("supports_vision", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("supports_function_calling", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("supports_streaming", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("supports_extended_thinking", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("supports_image_generation", sa.Boolean, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("is_available", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("config", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("provider_id", "provider_model_name", name="uq_provider_model"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== model_mappings ====================
|
||||||
|
op.create_table(
|
||||||
|
"model_mappings",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("source_model", sa.String(200), nullable=False, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"target_global_model_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True, index=True
|
||||||
|
),
|
||||||
|
sa.Column("mapping_type", sa.String(20), nullable=False, server_default="alias", index=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("source_model", "provider_id", name="uq_model_mapping_source_provider"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== provider_api_keys ====================
|
||||||
|
op.create_table(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"endpoint_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("api_key", sa.String(500), nullable=False),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("note", sa.String(500), nullable=True),
|
||||||
|
sa.Column("rate_multiplier", sa.Float, server_default="1.0", nullable=False),
|
||||||
|
sa.Column("internal_priority", sa.Integer, server_default="50"),
|
||||||
|
sa.Column("global_priority", sa.Integer, nullable=True),
|
||||||
|
sa.Column("max_concurrent", sa.Integer, nullable=True),
|
||||||
|
sa.Column("rate_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("daily_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("monthly_limit", sa.Integer, nullable=True),
|
||||||
|
sa.Column("allowed_models", sa.JSON, nullable=True),
|
||||||
|
sa.Column("capabilities", sa.JSON, nullable=True),
|
||||||
|
sa.Column("learned_max_concurrent", sa.Integer, nullable=True),
|
||||||
|
sa.Column("concurrent_429_count", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("rpm_429_count", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("last_429_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_429_type", sa.String(50), nullable=True),
|
||||||
|
sa.Column("last_concurrent_peak", sa.Integer, nullable=True),
|
||||||
|
sa.Column("adjustment_history", sa.JSON, nullable=True),
|
||||||
|
# Sliding window fields (replaces high_utilization_start)
|
||||||
|
sa.Column("utilization_samples", sa.JSON, nullable=True),
|
||||||
|
sa.Column("last_probe_increase_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("health_score", sa.Float, server_default="1.0"),
|
||||||
|
sa.Column("consecutive_failures", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("last_failure_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cache_ttl_minutes", sa.Integer, server_default="5", nullable=False),
|
||||||
|
sa.Column("max_probe_interval_minutes", sa.Integer, server_default="32", nullable=False),
|
||||||
|
sa.Column("circuit_breaker_open", sa.Boolean, server_default="false", nullable=False),
|
||||||
|
sa.Column("circuit_breaker_open_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("next_probe_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
# Circuit breaker v2 fields
|
||||||
|
sa.Column("request_results_window", sa.JSON, nullable=True),
|
||||||
|
sa.Column("half_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("half_open_successes", sa.Integer, server_default="0", nullable=True),
|
||||||
|
sa.Column("half_open_failures", sa.Integer, server_default="0", nullable=True),
|
||||||
|
sa.Column("request_count", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("success_count", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("error_count", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("total_response_time_ms", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error_msg", sa.Text, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== usage ====================
|
||||||
|
op.create_table(
|
||||||
|
"usage",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"api_key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("api_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("request_id", sa.String(100), unique=True, index=True, nullable=False),
|
||||||
|
sa.Column("provider", sa.String(100), nullable=False),
|
||||||
|
sa.Column("model", sa.String(100), nullable=False),
|
||||||
|
sa.Column("target_model", sa.String(100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("providers.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_endpoint_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_endpoints.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_api_key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_api_keys.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("input_tokens", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("output_tokens", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("total_tokens", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("cache_creation_input_tokens", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("cache_read_input_tokens", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("input_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("output_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("cache_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("request_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_input_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_output_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_cache_creation_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_cache_read_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_request_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("actual_total_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("rate_multiplier", sa.Float, server_default="1.0"),
|
||||||
|
sa.Column("input_price_per_1m", sa.Float, nullable=True),
|
||||||
|
sa.Column("output_price_per_1m", sa.Float, nullable=True),
|
||||||
|
sa.Column("cache_creation_price_per_1m", sa.Float, nullable=True),
|
||||||
|
sa.Column("cache_read_price_per_1m", sa.Float, nullable=True),
|
||||||
|
sa.Column("price_per_request", sa.Float, nullable=True),
|
||||||
|
sa.Column("request_type", sa.String(50), nullable=True),
|
||||||
|
sa.Column("api_format", sa.String(50), nullable=True),
|
||||||
|
sa.Column("is_stream", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("status_code", sa.Integer, nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column("response_time_ms", sa.Integer, nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), server_default="completed", nullable=False, index=True),
|
||||||
|
sa.Column("request_headers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("request_body", sa.JSON, nullable=True),
|
||||||
|
sa.Column("provider_request_headers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("response_headers", sa.JSON, nullable=True),
|
||||||
|
sa.Column("response_body", sa.JSON, nullable=True),
|
||||||
|
sa.Column("request_body_compressed", sa.LargeBinary, nullable=True),
|
||||||
|
sa.Column("response_body_compressed", sa.LargeBinary, nullable=True),
|
||||||
|
sa.Column("request_metadata", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# usage 表复合索引(优化常见查询)
|
||||||
|
op.create_index("idx_usage_user_created", "usage", ["user_id", "created_at"])
|
||||||
|
op.create_index("idx_usage_apikey_created", "usage", ["api_key_id", "created_at"])
|
||||||
|
op.create_index("idx_usage_provider_model_created", "usage", ["provider", "model", "created_at"])
|
||||||
|
|
||||||
|
# ==================== user_quotas ====================
|
||||||
|
op.create_table(
|
||||||
|
"user_quotas",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("quota_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("quota_usd", sa.Float, nullable=False),
|
||||||
|
sa.Column("period_start", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("period_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== system_configs ====================
|
||||||
|
op.create_table(
|
||||||
|
"system_configs",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("key", sa.String(100), unique=True, nullable=False),
|
||||||
|
sa.Column("value", sa.JSON, nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== user_preferences ====================
|
||||||
|
op.create_table(
|
||||||
|
"user_preferences",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
unique=True,
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("avatar_url", sa.String(500), nullable=True),
|
||||||
|
sa.Column("bio", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"default_provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("theme", sa.String(20), server_default="light"),
|
||||||
|
sa.Column("language", sa.String(10), server_default="zh-CN"),
|
||||||
|
sa.Column("timezone", sa.String(50), server_default="Asia/Shanghai"),
|
||||||
|
sa.Column("email_notifications", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("usage_alerts", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("announcement_notifications", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== announcements ====================
|
||||||
|
op.create_table(
|
||||||
|
"announcements",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("title", sa.String(200), nullable=False),
|
||||||
|
sa.Column("content", sa.Text, nullable=False),
|
||||||
|
sa.Column("type", sa.String(20), server_default="info"),
|
||||||
|
sa.Column("priority", sa.Integer, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"author_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true", index=True),
|
||||||
|
sa.Column("is_pinned", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("start_time", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== announcement_reads ====================
|
||||||
|
op.create_table(
|
||||||
|
"announcement_reads",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"announcement_id", sa.String(36), sa.ForeignKey("announcements.id"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"read_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "announcement_id", name="uq_user_announcement"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== audit_logs ====================
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column("event_type", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("api_key_id", sa.String(36), nullable=True),
|
||||||
|
sa.Column("description", sa.Text, nullable=False),
|
||||||
|
sa.Column("ip_address", sa.String(45), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(500), nullable=True),
|
||||||
|
sa.Column("request_id", sa.String(100), nullable=True, index=True),
|
||||||
|
sa.Column("event_metadata", sa.JSON, nullable=True),
|
||||||
|
sa.Column("status_code", sa.Integer, nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== request_candidates ====================
|
||||||
|
op.create_table(
|
||||||
|
"request_candidates",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("request_id", sa.String(100), nullable=False, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"api_key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("candidate_index", sa.Integer, nullable=False),
|
||||||
|
sa.Column("retry_index", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"endpoint_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_endpoints.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False),
|
||||||
|
sa.Column("skip_reason", sa.Text, nullable=True),
|
||||||
|
sa.Column("is_cached", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("status_code", sa.Integer, nullable=True),
|
||||||
|
sa.Column("error_type", sa.String(50), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text, nullable=True),
|
||||||
|
sa.Column("latency_ms", sa.Integer, nullable=True),
|
||||||
|
sa.Column("concurrent_requests", sa.Integer, nullable=True),
|
||||||
|
sa.Column("extra_data", sa.JSON, nullable=True),
|
||||||
|
sa.Column("required_capabilities", sa.JSON, nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"request_id", "candidate_index", "retry_index", name="uq_request_candidate_with_retry"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("idx_request_candidates_request_id", "request_candidates", ["request_id"])
|
||||||
|
op.create_index("idx_request_candidates_status", "request_candidates", ["status"])
|
||||||
|
op.create_index("idx_request_candidates_provider_id", "request_candidates", ["provider_id"])
|
||||||
|
|
||||||
|
# ==================== stats_daily ====================
|
||||||
|
op.create_table(
|
||||||
|
"stats_daily",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("date", sa.DateTime(timezone=True), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("actual_total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("input_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("output_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("cache_creation_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("cache_read_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("fallback_count", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("unique_models", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("unique_providers", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== stats_summary ====================
|
||||||
|
op.create_table(
|
||||||
|
"stats_summary",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("cutoff_date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("all_time_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("all_time_success_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("all_time_error_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("all_time_input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("all_time_output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"all_time_cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("all_time_cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("all_time_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("all_time_actual_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column("total_users", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("active_users", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("total_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("active_api_keys", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== stats_user_daily ====================
|
||||||
|
op.create_table(
|
||||||
|
"stats_user_daily",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id", sa.String(36), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column("date", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("total_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("success_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("error_requests", sa.Integer, server_default="0", nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger, server_default="0", nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float, server_default="0.0", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "date", name="uq_stats_user_daily"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_stats_user_daily_user_date", "stats_user_daily", ["user_id", "date"])
|
||||||
|
|
||||||
|
# ==================== api_key_provider_mappings ====================
|
||||||
|
op.create_table(
|
||||||
|
"api_key_provider_mappings",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"api_key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("priority_adjustment", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("weight_multiplier", sa.Float, server_default="1.0"),
|
||||||
|
sa.Column("is_enabled", sa.Boolean, server_default="true", nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("api_key_id", "provider_id", name="uq_apikey_provider"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_apikey_provider_enabled", "api_key_provider_mappings", ["api_key_id", "is_enabled"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== provider_usage_tracking ====================
|
||||||
|
op.create_table(
|
||||||
|
"provider_usage_tracking",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"provider_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("providers.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("window_start", sa.DateTime(timezone=True), nullable=False, index=True),
|
||||||
|
sa.Column("window_end", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("successful_requests", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("failed_requests", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("avg_response_time_ms", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("total_response_time_ms", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column("total_cost_usd", sa.Float, server_default="0.0"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_provider_window", "provider_usage_tracking", ["provider_id", "window_start"]
|
||||||
|
)
|
||||||
|
op.create_index("idx_window_time", "provider_usage_tracking", ["window_start", "window_end"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop tables in reverse order (respecting foreign key dependencies)
|
||||||
|
op.drop_table("provider_usage_tracking")
|
||||||
|
op.drop_table("api_key_provider_mappings")
|
||||||
|
op.drop_table("stats_user_daily")
|
||||||
|
op.drop_table("stats_summary")
|
||||||
|
op.drop_table("stats_daily")
|
||||||
|
op.drop_table("request_candidates")
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
op.drop_table("announcement_reads")
|
||||||
|
op.drop_table("announcements")
|
||||||
|
op.drop_table("user_preferences")
|
||||||
|
op.drop_table("system_configs")
|
||||||
|
op.drop_table("user_quotas")
|
||||||
|
op.drop_table("usage")
|
||||||
|
op.drop_table("provider_api_keys")
|
||||||
|
op.drop_table("model_mappings")
|
||||||
|
op.drop_table("models")
|
||||||
|
op.drop_table("provider_endpoints")
|
||||||
|
op.drop_table("api_keys")
|
||||||
|
op.drop_table("global_models")
|
||||||
|
op.drop_table("providers")
|
||||||
|
op.drop_table("users")
|
||||||
|
|
||||||
|
# Drop ENUM types
|
||||||
|
op.execute("DROP TYPE IF EXISTS providerbillingtype")
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
"""remove_model_mappings_add_aliases
|
||||||
|
|
||||||
|
合并迁移:
|
||||||
|
1. 添加 provider_model_aliases 字段到 models 表
|
||||||
|
2. 迁移 model_mappings 数据到 provider_model_aliases
|
||||||
|
3. 删除 model_mappings 表
|
||||||
|
4. 添加索引优化别名解析性能
|
||||||
|
|
||||||
|
Revision ID: e9b3d63f0cbf
|
||||||
|
Revises: 20251210_baseline
|
||||||
|
Create Date: 2025-12-14 13:00:22.828183+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e9b3d63f0cbf'
|
||||||
|
down_revision = '20251210_baseline'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table_name AND column_name = :column_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name, "column_name": column_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(bind, table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = :table_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(bind, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE indexname = :index_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"index_name": index_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 provider_model_aliases 字段,迁移数据,删除 model_mappings 表"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 添加 provider_model_aliases 字段(如果不存在)
|
||||||
|
if not column_exists(bind, "models", "provider_model_aliases"):
|
||||||
|
op.add_column(
|
||||||
|
'models',
|
||||||
|
sa.Column('provider_model_aliases', sa.JSON(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 迁移 model_mappings 数据(如果表存在)
|
||||||
|
session = Session(bind=bind)
|
||||||
|
|
||||||
|
model_mappings_table = sa.table(
|
||||||
|
"model_mappings",
|
||||||
|
sa.column("source_model", sa.String),
|
||||||
|
sa.column("target_global_model_id", sa.String),
|
||||||
|
sa.column("provider_id", sa.String),
|
||||||
|
sa.column("mapping_type", sa.String),
|
||||||
|
sa.column("is_active", sa.Boolean),
|
||||||
|
)
|
||||||
|
|
||||||
|
models_table = sa.table(
|
||||||
|
"models",
|
||||||
|
sa.column("id", sa.String),
|
||||||
|
sa.column("provider_id", sa.String),
|
||||||
|
sa.column("global_model_id", sa.String),
|
||||||
|
sa.column("provider_model_aliases", sa.JSON),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize_alias_list(value) -> list[dict]:
|
||||||
|
"""将 DB 返回的 JSON 值规范化为 list[{'name': str, 'priority': int}]"""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = json.loads(value) if value else []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: list[dict] = []
|
||||||
|
for item in value:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_name = item.get("name")
|
||||||
|
if not isinstance(raw_name, str):
|
||||||
|
continue
|
||||||
|
name = raw_name.strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_priority = item.get("priority", 1)
|
||||||
|
try:
|
||||||
|
priority = int(raw_priority)
|
||||||
|
except Exception:
|
||||||
|
priority = 1
|
||||||
|
if priority < 1:
|
||||||
|
priority = 1
|
||||||
|
|
||||||
|
normalized.append({"name": name, "priority": priority})
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
# 查询所有活跃的 provider 级别 alias(只迁移 is_active=True 且 mapping_type='alias' 的)
|
||||||
|
# 全局别名/映射不迁移(新架构不再支持 source_model -> GlobalModel.name 的解析)
|
||||||
|
# 仅当 model_mappings 表存在时执行迁移
|
||||||
|
if table_exists(bind, "model_mappings"):
|
||||||
|
mappings = session.execute(
|
||||||
|
sa.select(
|
||||||
|
model_mappings_table.c.source_model,
|
||||||
|
model_mappings_table.c.target_global_model_id,
|
||||||
|
model_mappings_table.c.provider_id,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
model_mappings_table.c.is_active.is_(True),
|
||||||
|
model_mappings_table.c.provider_id.isnot(None),
|
||||||
|
model_mappings_table.c.mapping_type == "alias",
|
||||||
|
)
|
||||||
|
.order_by(model_mappings_table.c.provider_id, model_mappings_table.c.source_model)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# 按 (provider_id, target_global_model_id) 分组,收集别名
|
||||||
|
alias_groups: dict = {}
|
||||||
|
for source_model, target_global_model_id, provider_id in mappings:
|
||||||
|
if not isinstance(source_model, str):
|
||||||
|
continue
|
||||||
|
source_model = source_model.strip()
|
||||||
|
if not source_model:
|
||||||
|
continue
|
||||||
|
if not isinstance(provider_id, str) or not provider_id:
|
||||||
|
continue
|
||||||
|
if not isinstance(target_global_model_id, str) or not target_global_model_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key = (provider_id, target_global_model_id)
|
||||||
|
if key not in alias_groups:
|
||||||
|
alias_groups[key] = []
|
||||||
|
priority = len(alias_groups[key]) + 1
|
||||||
|
alias_groups[key].append({"name": source_model, "priority": priority})
|
||||||
|
|
||||||
|
# 更新对应的 models 记录
|
||||||
|
for (provider_id, global_model_id), aliases in alias_groups.items():
|
||||||
|
model_row = session.execute(
|
||||||
|
sa.select(models_table.c.id, models_table.c.provider_model_aliases)
|
||||||
|
.where(
|
||||||
|
models_table.c.provider_id == provider_id,
|
||||||
|
models_table.c.global_model_id == global_model_id,
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if model_row:
|
||||||
|
model_id = model_row[0]
|
||||||
|
existing_aliases = normalize_alias_list(model_row[1])
|
||||||
|
|
||||||
|
existing_names = {a["name"] for a in existing_aliases}
|
||||||
|
merged_aliases = list(existing_aliases)
|
||||||
|
for alias in aliases:
|
||||||
|
name = alias.get("name")
|
||||||
|
if not isinstance(name, str):
|
||||||
|
continue
|
||||||
|
name = name.strip()
|
||||||
|
if not name or name in existing_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
merged_aliases.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"priority": len(merged_aliases) + 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
existing_names.add(name)
|
||||||
|
|
||||||
|
session.execute(
|
||||||
|
models_table.update()
|
||||||
|
.where(models_table.c.id == model_id)
|
||||||
|
.values(
|
||||||
|
provider_model_aliases=merged_aliases if merged_aliases else None,
|
||||||
|
updated_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 3. 删除 model_mappings 表
|
||||||
|
op.drop_table('model_mappings')
|
||||||
|
|
||||||
|
# 4. 添加索引优化别名解析性能
|
||||||
|
# provider_model_name 索引(支持精确匹配,如果不存在)
|
||||||
|
if not index_exists(bind, "idx_model_provider_model_name"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_model_provider_model_name",
|
||||||
|
"models",
|
||||||
|
["provider_model_name"],
|
||||||
|
unique=False,
|
||||||
|
postgresql_where=sa.text("is_active = true"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# provider_model_aliases GIN 索引(支持 JSONB 查询,仅 PostgreSQL)
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
# 将 json 列转为 jsonb(jsonb 性能更好且支持 GIN 索引)
|
||||||
|
# 使用 IF NOT EXISTS 风格的检查来避免重复转换
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'models'
|
||||||
|
AND column_name = 'provider_model_aliases'
|
||||||
|
AND data_type = 'json'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE models
|
||||||
|
ALTER COLUMN provider_model_aliases TYPE jsonb
|
||||||
|
USING provider_model_aliases::jsonb;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# 创建 GIN 索引
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_model_provider_model_aliases_gin
|
||||||
|
ON models USING gin(provider_model_aliases jsonb_path_ops)
|
||||||
|
WHERE is_active = true
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""恢复 model_mappings 表,移除 provider_model_aliases 字段和索引"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 删除索引
|
||||||
|
op.drop_index("idx_model_provider_model_name", table_name="models")
|
||||||
|
|
||||||
|
if bind.dialect.name == "postgresql":
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_model_provider_model_aliases_gin")
|
||||||
|
# 将 jsonb 列还原为 json
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE models
|
||||||
|
ALTER COLUMN provider_model_aliases TYPE json
|
||||||
|
USING provider_model_aliases::json
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 恢复 model_mappings 表
|
||||||
|
op.create_table(
|
||||||
|
'model_mappings',
|
||||||
|
sa.Column('id', sa.String(36), primary_key=True),
|
||||||
|
sa.Column('source_model', sa.String(200), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
'target_global_model_id',
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey('global_models.id', ondelete='CASCADE'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column('provider_id', sa.String(36), sa.ForeignKey('providers.id'), nullable=True),
|
||||||
|
sa.Column('mapping_type', sa.String(20), nullable=False, server_default='alias'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.UniqueConstraint('source_model', 'provider_id', name='uq_model_mapping_source_provider'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_model_mappings_source_model', 'model_mappings', ['source_model'])
|
||||||
|
op.create_index('ix_model_mappings_target_global_model_id', 'model_mappings', ['target_global_model_id'])
|
||||||
|
op.create_index('ix_model_mappings_provider_id', 'model_mappings', ['provider_id'])
|
||||||
|
op.create_index('ix_model_mappings_mapping_type', 'model_mappings', ['mapping_type'])
|
||||||
|
|
||||||
|
# 3. 移除 provider_model_aliases 字段
|
||||||
|
op.drop_column('models', 'provider_model_aliases')
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"""add first_byte_time_ms to usage table
|
||||||
|
|
||||||
|
Revision ID: 180e63a9c83a
|
||||||
|
Revises: e9b3d63f0cbf
|
||||||
|
Create Date: 2025-12-15 17:07:44.631032+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '180e63a9c83a'
|
||||||
|
down_revision = 'e9b3d63f0cbf'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table_name AND column_name = :column_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name, "column_name": column_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:升级到新版本"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 添加首字时间字段到 usage 表(如果不存在)
|
||||||
|
if not column_exists(bind, "usage", "first_byte_time_ms"):
|
||||||
|
op.add_column('usage', sa.Column('first_byte_time_ms', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:降级到旧版本"""
|
||||||
|
# 删除首字时间字段
|
||||||
|
op.drop_column('usage', 'first_byte_time_ms')
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"""refactor global_model to use config json field
|
||||||
|
|
||||||
|
Revision ID: 1cc6942cf06f
|
||||||
|
Revises: 180e63a9c83a
|
||||||
|
Create Date: 2025-12-16 03:11:32.480976+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1cc6942cf06f'
|
||||||
|
down_revision = '180e63a9c83a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table_name AND column_name = :column_name
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"table_name": table_name, "column_name": column_name},
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:升级到新版本
|
||||||
|
|
||||||
|
1. 添加 config 列
|
||||||
|
2. 把旧数据迁移到 config
|
||||||
|
3. 删除旧列
|
||||||
|
"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 检查是否已经迁移过(config 列存在且旧列不存在)
|
||||||
|
has_config = column_exists(bind, "global_models", "config")
|
||||||
|
has_old_columns = column_exists(bind, "global_models", "default_supports_streaming")
|
||||||
|
|
||||||
|
if has_config and not has_old_columns:
|
||||||
|
# 已完成迁移,跳过
|
||||||
|
return
|
||||||
|
|
||||||
|
# 1. 添加 config 列(使用 JSONB 类型,支持索引和更高效的查询)
|
||||||
|
if not has_config:
|
||||||
|
op.add_column('global_models', sa.Column('config', postgresql.JSONB(), nullable=True))
|
||||||
|
|
||||||
|
# 2. 迁移数据:把旧字段合并到 config JSON(仅当旧列存在时)
|
||||||
|
if has_old_columns:
|
||||||
|
op.execute("""
|
||||||
|
UPDATE global_models
|
||||||
|
SET config = jsonb_strip_nulls(jsonb_build_object(
|
||||||
|
'streaming', COALESCE(default_supports_streaming, true),
|
||||||
|
'vision', CASE WHEN COALESCE(default_supports_vision, false) THEN true ELSE NULL END,
|
||||||
|
'function_calling', CASE WHEN COALESCE(default_supports_function_calling, false) THEN true ELSE NULL END,
|
||||||
|
'extended_thinking', CASE WHEN COALESCE(default_supports_extended_thinking, false) THEN true ELSE NULL END,
|
||||||
|
'image_generation', CASE WHEN COALESCE(default_supports_image_generation, false) THEN true ELSE NULL END,
|
||||||
|
'description', description,
|
||||||
|
'icon_url', icon_url,
|
||||||
|
'official_url', official_url
|
||||||
|
))
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 3. 删除旧列
|
||||||
|
op.drop_column('global_models', 'default_supports_streaming')
|
||||||
|
op.drop_column('global_models', 'default_supports_vision')
|
||||||
|
op.drop_column('global_models', 'default_supports_function_calling')
|
||||||
|
op.drop_column('global_models', 'default_supports_extended_thinking')
|
||||||
|
op.drop_column('global_models', 'default_supports_image_generation')
|
||||||
|
op.drop_column('global_models', 'description')
|
||||||
|
op.drop_column('global_models', 'icon_url')
|
||||||
|
op.drop_column('global_models', 'official_url')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:降级到旧版本"""
|
||||||
|
# 1. 添加旧列
|
||||||
|
op.add_column('global_models', sa.Column('icon_url', sa.VARCHAR(length=500), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('official_url', sa.VARCHAR(length=500), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('description', sa.TEXT(), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('default_supports_streaming', sa.BOOLEAN(), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('default_supports_vision', sa.BOOLEAN(), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('default_supports_function_calling', sa.BOOLEAN(), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('default_supports_extended_thinking', sa.BOOLEAN(), nullable=True))
|
||||||
|
op.add_column('global_models', sa.Column('default_supports_image_generation', sa.BOOLEAN(), nullable=True))
|
||||||
|
|
||||||
|
# 2. 从 config 恢复数据
|
||||||
|
op.execute("""
|
||||||
|
UPDATE global_models
|
||||||
|
SET
|
||||||
|
default_supports_streaming = COALESCE((config->>'streaming')::boolean, true),
|
||||||
|
default_supports_vision = COALESCE((config->>'vision')::boolean, false),
|
||||||
|
default_supports_function_calling = COALESCE((config->>'function_calling')::boolean, false),
|
||||||
|
default_supports_extended_thinking = COALESCE((config->>'extended_thinking')::boolean, false),
|
||||||
|
default_supports_image_generation = COALESCE((config->>'image_generation')::boolean, false),
|
||||||
|
description = config->>'description',
|
||||||
|
icon_url = config->>'icon_url',
|
||||||
|
official_url = config->>'official_url'
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 3. 删除 config 列
|
||||||
|
op.drop_column('global_models', 'config')
|
||||||
+57
@@ -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')
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""add usage table composite indexes for query optimization
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6g7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2025-12-20 15:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'b2c3d4e5f6g7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""为 usage 表添加复合索引以优化常见查询
|
||||||
|
|
||||||
|
注意:这些索引已经在 baseline 迁移中创建。
|
||||||
|
此迁移仅用于从旧版本升级的场景,新安装会跳过。
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 检查 usage 表是否存在
|
||||||
|
result = conn.execute(text(
|
||||||
|
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'usage')"
|
||||||
|
))
|
||||||
|
if not result.scalar():
|
||||||
|
# 表不存在,跳过
|
||||||
|
return
|
||||||
|
|
||||||
|
# 定义需要创建的索引
|
||||||
|
indexes = [
|
||||||
|
("idx_usage_user_created", "ON usage (user_id, created_at)"),
|
||||||
|
("idx_usage_apikey_created", "ON usage (api_key_id, created_at)"),
|
||||||
|
("idx_usage_provider_model_created", "ON usage (provider, model, created_at)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 分别检查并创建每个索引
|
||||||
|
for index_name, index_def in indexes:
|
||||||
|
result = conn.execute(text(
|
||||||
|
f"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = '{index_name}')"
|
||||||
|
))
|
||||||
|
if result.scalar():
|
||||||
|
continue # 索引已存在,跳过
|
||||||
|
|
||||||
|
conn.execute(text(f"CREATE INDEX {index_name} {index_def}"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""删除复合索引"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 使用 IF EXISTS 避免索引不存在时报错
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_provider_model_created"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_apikey_created"
|
||||||
|
))
|
||||||
|
conn.execute(text(
|
||||||
|
"DROP INDEX IF EXISTS idx_usage_user_created"
|
||||||
|
))
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""add ldap authentication support
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6g7h8
|
||||||
|
Revises: b2c3d4e5f6g7
|
||||||
|
Create Date: 2026-01-01 14:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c3d4e5f6g7h8'
|
||||||
|
down_revision = 'b2c3d4e5f6g7'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _type_exists(conn, type_name: str) -> bool:
|
||||||
|
"""检查 PostgreSQL 类型是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_type WHERE typname = :name"),
|
||||||
|
{"name": type_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(conn, table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table AND column_name = :column
|
||||||
|
"""),
|
||||||
|
{"table": table_name, "column": column_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(conn, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": index_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
result = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = :name AND table_schema = 'public'
|
||||||
|
"""),
|
||||||
|
{"name": table_name}
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 LDAP 认证支持
|
||||||
|
|
||||||
|
1. 创建 authsource 枚举类型
|
||||||
|
2. 在 users 表添加 auth_source 字段和 LDAP 标识字段
|
||||||
|
3. 创建 ldap_configs 表
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 创建 authsource 枚举类型(幂等)
|
||||||
|
if not _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("CREATE TYPE authsource AS ENUM ('local', 'ldap')"))
|
||||||
|
|
||||||
|
# 2. 在 users 表添加字段(幂等)
|
||||||
|
if not _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.add_column('users', sa.Column(
|
||||||
|
'auth_source',
|
||||||
|
sa.Enum('local', 'ldap', name='authsource', create_type=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default='local'
|
||||||
|
))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.add_column('users', sa.Column('ldap_dn', sa.String(length=512), nullable=True))
|
||||||
|
|
||||||
|
if not _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.add_column('users', sa.Column('ldap_username', sa.String(length=255), nullable=True))
|
||||||
|
|
||||||
|
# 创建索引(幂等)
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.create_index('ix_users_ldap_dn', 'users', ['ldap_dn'])
|
||||||
|
|
||||||
|
if not _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.create_index('ix_users_ldap_username', 'users', ['ldap_username'])
|
||||||
|
|
||||||
|
# 3. 创建 ldap_configs 表(幂等)
|
||||||
|
if not _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.create_table(
|
||||||
|
'ldap_configs',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('server_url', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bind_password_encrypted', sa.Text(), nullable=True),
|
||||||
|
sa.Column('base_dn', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('user_search_filter', sa.String(length=500), nullable=False, server_default='(uid={username})'),
|
||||||
|
sa.Column('username_attr', sa.String(length=50), nullable=False, server_default='uid'),
|
||||||
|
sa.Column('email_attr', sa.String(length=50), nullable=False, server_default='mail'),
|
||||||
|
sa.Column('display_name_attr', sa.String(length=50), nullable=False, server_default='cn'),
|
||||||
|
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('is_exclusive', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('use_starttls', sa.Boolean(), nullable=False, server_default='false'),
|
||||||
|
sa.Column('connect_timeout', sa.Integer(), nullable=False, server_default='10'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚 LDAP 认证支持
|
||||||
|
|
||||||
|
警告:回滚前请确保:
|
||||||
|
1. 已备份数据库
|
||||||
|
2. 没有 LDAP 用户需要保留
|
||||||
|
"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 检查是否存在 LDAP 用户,防止数据丢失
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
result = conn.execute(text("SELECT COUNT(*) FROM users WHERE auth_source = 'ldap'"))
|
||||||
|
ldap_user_count = result.scalar()
|
||||||
|
if ldap_user_count and ldap_user_count > 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"无法回滚:存在 {ldap_user_count} 个 LDAP 用户。"
|
||||||
|
f"请先删除或转换这些用户,或使用 --force 参数强制回滚(将丢失数据)。"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1. 删除 ldap_configs 表(幂等)
|
||||||
|
if _table_exists(conn, 'ldap_configs'):
|
||||||
|
op.drop_table('ldap_configs')
|
||||||
|
|
||||||
|
# 2. 删除 users 表的 LDAP 相关字段(幂等)
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_username'):
|
||||||
|
op.drop_index('ix_users_ldap_username', table_name='users')
|
||||||
|
|
||||||
|
if _index_exists(conn, 'ix_users_ldap_dn'):
|
||||||
|
op.drop_index('ix_users_ldap_dn', table_name='users')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_username'):
|
||||||
|
op.drop_column('users', 'ldap_username')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'ldap_dn'):
|
||||||
|
op.drop_column('users', 'ldap_dn')
|
||||||
|
|
||||||
|
if _column_exists(conn, 'users', 'auth_source'):
|
||||||
|
op.drop_column('users', 'auth_source')
|
||||||
|
|
||||||
|
# 3. 删除 authsource 枚举类型(幂等)
|
||||||
|
# 注意:不使用 CASCADE,因为此时所有依赖应该已被删除
|
||||||
|
if _type_exists(conn, 'authsource'):
|
||||||
|
conn.execute(text("DROP TYPE authsource"))
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
"""add_management_tokens_table
|
||||||
|
|
||||||
|
Revision ID: ad55f1d008b7
|
||||||
|
Revises: c3d4e5f6g7h8
|
||||||
|
Create Date: 2026-01-06 15:24:10.660394+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ad55f1d008b7'
|
||||||
|
down_revision = 'c3d4e5f6g7h8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
"""检查索引是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
try:
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx["name"] == index_name for idx in indexes)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
"""检查约束是否存在"""
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
try:
|
||||||
|
constraints = inspector.get_unique_constraints(table_name)
|
||||||
|
if any(c["name"] == constraint_name for c in constraints):
|
||||||
|
return True
|
||||||
|
# 也检查 check 约束
|
||||||
|
check_constraints = inspector.get_check_constraints(table_name)
|
||||||
|
if any(c["name"] == constraint_name for c in check_constraints):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:创建 management_tokens 表"""
|
||||||
|
# 幂等性检查
|
||||||
|
if table_exists("management_tokens"):
|
||||||
|
# 表已存在,检查是否需要添加约束
|
||||||
|
if not constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_management_tokens_user_name",
|
||||||
|
"management_tokens",
|
||||||
|
["user_id", "name"],
|
||||||
|
)
|
||||||
|
# 添加 IP 白名单非空检查约束
|
||||||
|
if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||||
|
op.create_check_constraint(
|
||||||
|
"check_allowed_ips_not_empty",
|
||||||
|
"management_tokens",
|
||||||
|
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table('management_tokens',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('token_prefix', sa.String(length=12), nullable=True),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('allowed_ips', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_used_ip', sa.String(length=45), nullable=True),
|
||||||
|
sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False)
|
||||||
|
op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True)
|
||||||
|
# 添加用户名称唯一约束
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_management_tokens_user_name",
|
||||||
|
"management_tokens",
|
||||||
|
["user_id", "name"],
|
||||||
|
)
|
||||||
|
# 添加 IP 白名单非空检查约束
|
||||||
|
# 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理
|
||||||
|
op.create_check_constraint(
|
||||||
|
"check_allowed_ips_not_empty",
|
||||||
|
"management_tokens",
|
||||||
|
"allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:删除 management_tokens 表"""
|
||||||
|
# 幂等性检查
|
||||||
|
if not table_exists("management_tokens"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 删除约束
|
||||||
|
if constraint_exists("management_tokens", "check_allowed_ips_not_empty"):
|
||||||
|
op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check")
|
||||||
|
if constraint_exists("management_tokens", "uq_management_tokens_user_name"):
|
||||||
|
op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique")
|
||||||
|
|
||||||
|
# 删除索引
|
||||||
|
if index_exists("management_tokens", "ix_management_tokens_token_hash"):
|
||||||
|
op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens')
|
||||||
|
if index_exists("management_tokens", "idx_management_tokens_user_id"):
|
||||||
|
op.drop_index('idx_management_tokens_user_id', table_name='management_tokens')
|
||||||
|
if index_exists("management_tokens", "idx_management_tokens_is_active"):
|
||||||
|
op.drop_index('idx_management_tokens_is_active', table_name='management_tokens')
|
||||||
|
|
||||||
|
# 删除表
|
||||||
|
op.drop_table('management_tokens')
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
"""cleanup ambiguous database fields
|
||||||
|
|
||||||
|
Revision ID: 02a45b66b7c4
|
||||||
|
Revises: ad55f1d008b7
|
||||||
|
Create Date: 2026-01-07 11:20:12.684426+00:00
|
||||||
|
|
||||||
|
变更内容:
|
||||||
|
1. users 表:重命名 allowed_endpoints 为 allowed_api_formats(修正历史命名错误)
|
||||||
|
2. api_keys 表:删除 allowed_endpoints 字段(未使用的功能)
|
||||||
|
3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复,且未使用)
|
||||||
|
4. usage 表:重命名 provider 为 provider_name(避免与 provider_id 外键混淆)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '02a45b66b7c4'
|
||||||
|
down_revision = 'ad55f1d008b7'
|
||||||
|
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 upgrade() -> None:
|
||||||
|
"""
|
||||||
|
1. users.allowed_endpoints -> allowed_api_formats(重命名)
|
||||||
|
2. api_keys.allowed_endpoints 删除
|
||||||
|
3. providers.rate_limit 删除(与 rpm_limit 重复)
|
||||||
|
4. usage.provider -> provider_name(重命名)
|
||||||
|
"""
|
||||||
|
# 1. users 表:重命名 allowed_endpoints 为 allowed_api_formats
|
||||||
|
if _column_exists('users', 'allowed_endpoints'):
|
||||||
|
op.alter_column('users', 'allowed_endpoints', new_column_name='allowed_api_formats')
|
||||||
|
|
||||||
|
# 2. api_keys 表:删除 allowed_endpoints 字段
|
||||||
|
if _column_exists('api_keys', 'allowed_endpoints'):
|
||||||
|
op.drop_column('api_keys', 'allowed_endpoints')
|
||||||
|
|
||||||
|
# 3. providers 表:删除 rate_limit 字段(与 rpm_limit 功能重复)
|
||||||
|
if _column_exists('providers', 'rate_limit'):
|
||||||
|
op.drop_column('providers', 'rate_limit')
|
||||||
|
|
||||||
|
# 4. usage 表:重命名 provider 为 provider_name
|
||||||
|
if _column_exists('usage', 'provider'):
|
||||||
|
op.alter_column('usage', 'provider', new_column_name='provider_name')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚:恢复原字段"""
|
||||||
|
# 4. usage 表:将 provider_name 改回 provider
|
||||||
|
if _column_exists('usage', 'provider_name'):
|
||||||
|
op.alter_column('usage', 'provider_name', new_column_name='provider')
|
||||||
|
|
||||||
|
# 3. providers 表:恢复 rate_limit 字段
|
||||||
|
if not _column_exists('providers', 'rate_limit'):
|
||||||
|
op.add_column('providers', sa.Column('rate_limit', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 2. api_keys 表:恢复 allowed_endpoints 字段
|
||||||
|
if not _column_exists('api_keys', 'allowed_endpoints'):
|
||||||
|
op.add_column('api_keys', sa.Column('allowed_endpoints', sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# 1. users 表:将 allowed_api_formats 改回 allowed_endpoints
|
||||||
|
if _column_exists('users', 'allowed_api_formats'):
|
||||||
|
op.alter_column('users', 'allowed_api_formats', new_column_name='allowed_endpoints')
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
"""consolidated schema updates
|
||||||
|
|
||||||
|
Revision ID: m4n5o6p7q8r9
|
||||||
|
Revises: 02a45b66b7c4
|
||||||
|
Create Date: 2026-01-10 20:00:00.000000
|
||||||
|
|
||||||
|
This migration consolidates all schema changes from 2026-01-08 to 2026-01-10:
|
||||||
|
|
||||||
|
1. provider_api_keys: Key 直接关联 Provider (provider_id, api_formats)
|
||||||
|
2. provider_api_keys: 添加 rate_multipliers JSON 字段(按格式费率)
|
||||||
|
3. models: global_model_id 改为可空(支持独立 ProviderModel)
|
||||||
|
4. providers: 添加 timeout, max_retries, proxy(从 endpoint 迁移)
|
||||||
|
5. providers: display_name 重命名为 name,删除原 name
|
||||||
|
6. provider_api_keys: max_concurrent -> rpm_limit(并发改 RPM)
|
||||||
|
7. provider_api_keys: 健康度改为按格式存储(health_by_format, circuit_breaker_by_format)
|
||||||
|
8. provider_endpoints: 删除废弃的 rate_limit 列
|
||||||
|
9. usage: 添加 client_response_headers 字段
|
||||||
|
10. provider_api_keys: 删除 endpoint_id(Key 不再与 Endpoint 绑定)
|
||||||
|
11. provider_endpoints: 删除废弃的 max_concurrent 列
|
||||||
|
12. providers: 删除废弃的 rpm_limit, rpm_used, rpm_reset_at 列
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from sqlalchemy.exc import ProgrammingError
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
alembic_logger = logging.getLogger("alembic.runtime.migration")
|
||||||
|
|
||||||
|
revision = "m4n5o6p7q8r9"
|
||||||
|
down_revision = "02a45b66b7c4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in the table (bypasses inspector cache)"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :table AND column_name = :col"
|
||||||
|
),
|
||||||
|
{"table": table_name, "col": column_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
"""Check if a constraint exists (bypasses inspector cache)"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.table_constraints "
|
||||||
|
"WHERE table_name = :table AND constraint_name = :name"
|
||||||
|
),
|
||||||
|
{"table": table_name, "name": constraint_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
"""Check if an index exists (bypasses inspector cache)"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": index_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Apply all consolidated schema changes"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# ========== 1. provider_api_keys: 添加 provider_id 和 api_formats ==========
|
||||||
|
if not _column_exists("provider_api_keys", "provider_id"):
|
||||||
|
conn = op.get_bind()
|
||||||
|
conn.execute(sa.text("SAVEPOINT sp_add_provider_id"))
|
||||||
|
try:
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys", sa.Column("provider_id", sa.String(36), nullable=True)
|
||||||
|
)
|
||||||
|
conn.execute(sa.text("RELEASE SAVEPOINT sp_add_provider_id"))
|
||||||
|
except ProgrammingError as exc:
|
||||||
|
if getattr(getattr(exc, "orig", None), "pgcode", None) == "42701":
|
||||||
|
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||||
|
alembic_logger.warning("provider_api_keys.provider_id already exists; skipping add")
|
||||||
|
else:
|
||||||
|
conn.execute(sa.text("ROLLBACK TO SAVEPOINT sp_add_provider_id"))
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 数据迁移:从 endpoint 获取 provider_id(如果 endpoint_id 仍存在)
|
||||||
|
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys k
|
||||||
|
SET provider_id = e.provider_id
|
||||||
|
FROM provider_endpoints e
|
||||||
|
WHERE k.endpoint_id = e.id AND k.provider_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 检查无法关联的孤儿 Key
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT COUNT(*) FROM provider_api_keys WHERE provider_id IS NULL")
|
||||||
|
)
|
||||||
|
orphan_count = result.scalar() or 0
|
||||||
|
if orphan_count > 0:
|
||||||
|
# 使用 logger 记录更明显的告警
|
||||||
|
alembic_logger.warning("=" * 60)
|
||||||
|
alembic_logger.warning(
|
||||||
|
f"[MIGRATION WARNING] 发现 {orphan_count} 个无法关联 Provider 的孤儿 Key"
|
||||||
|
)
|
||||||
|
alembic_logger.warning("=" * 60)
|
||||||
|
alembic_logger.info("正在备份孤儿 Key 到 _orphan_api_keys_backup 表...")
|
||||||
|
|
||||||
|
# 先备份孤儿数据到临时表,避免数据丢失
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS _orphan_api_keys_backup AS
|
||||||
|
SELECT *, NOW() as backup_at
|
||||||
|
FROM provider_api_keys
|
||||||
|
WHERE provider_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 记录备份的 Key ID
|
||||||
|
orphan_ids = bind.execute(
|
||||||
|
sa.text("SELECT id, name FROM provider_api_keys WHERE provider_id IS NULL")
|
||||||
|
).fetchall()
|
||||||
|
alembic_logger.info("备份的孤儿 Key 列表:")
|
||||||
|
for key_id, key_name in orphan_ids:
|
||||||
|
alembic_logger.info(f" - Key: {key_name} (ID: {key_id})")
|
||||||
|
|
||||||
|
# 删除孤儿数据
|
||||||
|
op.execute("DELETE FROM provider_api_keys WHERE provider_id IS NULL")
|
||||||
|
alembic_logger.info(f"已备份并删除 {orphan_count} 个孤儿 Key")
|
||||||
|
|
||||||
|
# 提供恢复指南
|
||||||
|
alembic_logger.warning("-" * 60)
|
||||||
|
alembic_logger.warning("[恢复指南] 如需恢复孤儿 Key:")
|
||||||
|
alembic_logger.warning(" 1. 查询备份表: SELECT * FROM _orphan_api_keys_backup;")
|
||||||
|
alembic_logger.warning(" 2. 确定正确的 provider_id")
|
||||||
|
alembic_logger.warning(" 3. 执行恢复:")
|
||||||
|
alembic_logger.warning(" INSERT INTO provider_api_keys (...)")
|
||||||
|
alembic_logger.warning(" SELECT ... FROM _orphan_api_keys_backup WHERE ...;")
|
||||||
|
alembic_logger.warning("-" * 60)
|
||||||
|
|
||||||
|
# 设置 NOT NULL 并创建外键
|
||||||
|
op.alter_column("provider_api_keys", "provider_id", nullable=False)
|
||||||
|
|
||||||
|
if not _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_provider_api_keys_provider",
|
||||||
|
"provider_api_keys",
|
||||||
|
"providers",
|
||||||
|
["provider_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||||
|
op.create_index("idx_provider_api_keys_provider_id", "provider_api_keys", ["provider_id"])
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "api_formats"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("api_formats", sa.JSON(), nullable=True))
|
||||||
|
|
||||||
|
# 数据迁移:从 endpoint 获取 api_format
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys k
|
||||||
|
SET api_formats = json_build_array(e.api_format)
|
||||||
|
FROM provider_endpoints e
|
||||||
|
WHERE k.endpoint_id = e.id AND k.api_formats IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.alter_column("provider_api_keys", "api_formats", nullable=False, server_default="[]")
|
||||||
|
|
||||||
|
# 修改 endpoint_id 为可空,外键改为 SET NULL
|
||||||
|
if _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||||
|
op.drop_constraint(
|
||||||
|
"provider_api_keys_endpoint_id_fkey", "provider_api_keys", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.alter_column("provider_api_keys", "endpoint_id", nullable=True)
|
||||||
|
# 不再重建外键,因为后面会删除这个字段
|
||||||
|
|
||||||
|
# ========== 2. provider_api_keys: 添加 rate_multipliers ==========
|
||||||
|
if not _column_exists("provider_api_keys", "rate_multipliers"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("rate_multipliers", postgresql.JSON(astext_type=sa.Text()), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数据迁移:将 rate_multiplier 按 api_formats 转换
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET rate_multipliers = (
|
||||||
|
SELECT jsonb_object_agg(elem, rate_multiplier)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND api_formats::text != 'null'
|
||||||
|
AND rate_multipliers IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ========== 3. models: global_model_id 改为可空 ==========
|
||||||
|
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
|
||||||
|
|
||||||
|
# ========== 4. providers: 添加 timeout, max_retries, proxy ==========
|
||||||
|
if not _column_exists("providers", "timeout"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("timeout", sa.Integer(), nullable=True, comment="请求超时(秒)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("providers", "max_retries"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("max_retries", sa.Integer(), nullable=True, comment="最大重试次数"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("providers", "proxy"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("proxy", postgresql.JSONB(), nullable=True, comment="代理配置"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从端点迁移数据到 provider(动态构建 SQL,仅引用存在的列)
|
||||||
|
ep_has_timeout = _column_exists("provider_endpoints", "timeout")
|
||||||
|
ep_has_max_retries = _column_exists("provider_endpoints", "max_retries")
|
||||||
|
ep_has_proxy = _column_exists("provider_endpoints", "proxy")
|
||||||
|
|
||||||
|
set_clauses = []
|
||||||
|
if _column_exists("providers", "timeout"):
|
||||||
|
if ep_has_timeout:
|
||||||
|
set_clauses.append("""
|
||||||
|
timeout = COALESCE(
|
||||||
|
p.timeout,
|
||||||
|
(SELECT MAX(e.timeout) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.timeout IS NOT NULL),
|
||||||
|
300
|
||||||
|
)""")
|
||||||
|
else:
|
||||||
|
set_clauses.append("timeout = COALESCE(p.timeout, 300)")
|
||||||
|
|
||||||
|
if _column_exists("providers", "max_retries"):
|
||||||
|
if ep_has_max_retries:
|
||||||
|
set_clauses.append("""
|
||||||
|
max_retries = COALESCE(
|
||||||
|
p.max_retries,
|
||||||
|
(SELECT MAX(e.max_retries) FROM provider_endpoints e WHERE e.provider_id = p.id AND e.max_retries IS NOT NULL),
|
||||||
|
2
|
||||||
|
)""")
|
||||||
|
else:
|
||||||
|
set_clauses.append("max_retries = COALESCE(p.max_retries, 2)")
|
||||||
|
|
||||||
|
if _column_exists("providers", "proxy") and ep_has_proxy:
|
||||||
|
set_clauses.append("""
|
||||||
|
proxy = COALESCE(
|
||||||
|
p.proxy,
|
||||||
|
(SELECT e.proxy FROM provider_endpoints e WHERE e.provider_id = p.id AND e.proxy IS NOT NULL ORDER BY e.created_at LIMIT 1)
|
||||||
|
)""")
|
||||||
|
|
||||||
|
if set_clauses:
|
||||||
|
where_parts = []
|
||||||
|
if _column_exists("providers", "timeout"):
|
||||||
|
where_parts.append("p.timeout IS NULL")
|
||||||
|
if _column_exists("providers", "max_retries"):
|
||||||
|
where_parts.append("p.max_retries IS NULL")
|
||||||
|
where_clause = " OR ".join(where_parts) if where_parts else "TRUE"
|
||||||
|
sql = "UPDATE providers p SET " + ", ".join(set_clauses) + " WHERE " + where_clause
|
||||||
|
op.execute(sql)
|
||||||
|
|
||||||
|
# ========== 5. providers: display_name -> name ==========
|
||||||
|
# 注意:这里假设 display_name 已经被重命名为 name
|
||||||
|
# 如果 display_name 仍然存在,则需要执行重命名
|
||||||
|
if _column_exists("providers", "display_name"):
|
||||||
|
# 删除旧的 name 索引
|
||||||
|
if _index_exists("providers", "ix_providers_name"):
|
||||||
|
op.drop_index("ix_providers_name", table_name="providers")
|
||||||
|
|
||||||
|
# 如果存在旧的 name 列,先删除
|
||||||
|
if _column_exists("providers", "name"):
|
||||||
|
op.drop_column("providers", "name")
|
||||||
|
|
||||||
|
# 重命名 display_name 为 name
|
||||||
|
op.alter_column("providers", "display_name", new_column_name="name")
|
||||||
|
|
||||||
|
# 创建新索引
|
||||||
|
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||||
|
|
||||||
|
# ========== 6. provider_api_keys: max_concurrent -> rpm_limit ==========
|
||||||
|
if _column_exists("provider_api_keys", "max_concurrent"):
|
||||||
|
op.alter_column("provider_api_keys", "max_concurrent", new_column_name="rpm_limit")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "learned_max_concurrent"):
|
||||||
|
op.alter_column(
|
||||||
|
"provider_api_keys", "learned_max_concurrent", new_column_name="learned_rpm_limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "last_concurrent_peak"):
|
||||||
|
op.alter_column(
|
||||||
|
"provider_api_keys", "last_concurrent_peak", new_column_name="last_rpm_peak"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除废弃字段
|
||||||
|
for col in ["rate_limit", "daily_limit", "monthly_limit"]:
|
||||||
|
if _column_exists("provider_api_keys", col):
|
||||||
|
op.drop_column("provider_api_keys", col)
|
||||||
|
|
||||||
|
# ========== 7. provider_api_keys: 健康度改为按格式存储 ==========
|
||||||
|
if not _column_exists("provider_api_keys", "health_by_format"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"health_by_format",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
comment="按API格式存储的健康度数据",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"circuit_breaker_by_format",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
comment="按API格式存储的熔断器状态",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 数据迁移:如果存在旧字段,迁移数据到新结构
|
||||||
|
if _column_exists("provider_api_keys", "health_score"):
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
elem,
|
||||||
|
jsonb_build_object(
|
||||||
|
'health_score', COALESCE(health_score, 1.0),
|
||||||
|
'consecutive_failures', COALESCE(consecutive_failures, 0),
|
||||||
|
'last_failure_at', last_failure_at,
|
||||||
|
'request_results_window', COALESCE(request_results_window::jsonb, '[]'::jsonb)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND health_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Circuit Breaker 迁移策略:
|
||||||
|
# 不复制旧的 circuit_breaker_open 状态到所有 format,而是全部重置为 closed
|
||||||
|
# 原因:旧的单一 circuit breaker 状态可能因某一个 format 失败而打开,
|
||||||
|
# 如果复制到所有 format,会导致其他正常工作的 format 被错误标记为不可用
|
||||||
|
if _column_exists("provider_api_keys", "circuit_breaker_open"):
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET circuit_breaker_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
elem,
|
||||||
|
jsonb_build_object(
|
||||||
|
'open', false,
|
||||||
|
'open_at', NULL,
|
||||||
|
'next_probe_at', NULL,
|
||||||
|
'half_open_until', NULL,
|
||||||
|
'half_open_successes', 0,
|
||||||
|
'half_open_failures', 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS elem
|
||||||
|
)
|
||||||
|
WHERE api_formats IS NOT NULL
|
||||||
|
AND api_formats::text != '[]'
|
||||||
|
AND circuit_breaker_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 设置默认空对象
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = '{}'::jsonb
|
||||||
|
WHERE health_by_format IS NULL
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET circuit_breaker_by_format = '{}'::jsonb
|
||||||
|
WHERE circuit_breaker_by_format IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# 创建 GIN 索引
|
||||||
|
if not _index_exists("provider_api_keys", "ix_provider_api_keys_health_by_format"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_provider_api_keys_health_by_format",
|
||||||
|
"provider_api_keys",
|
||||||
|
["health_by_format"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
if not _index_exists("provider_api_keys", "ix_provider_api_keys_circuit_breaker_by_format"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_provider_api_keys_circuit_breaker_by_format",
|
||||||
|
"provider_api_keys",
|
||||||
|
["circuit_breaker_by_format"],
|
||||||
|
postgresql_using="gin",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 删除旧字段
|
||||||
|
old_health_columns = [
|
||||||
|
"health_score",
|
||||||
|
"consecutive_failures",
|
||||||
|
"last_failure_at",
|
||||||
|
"request_results_window",
|
||||||
|
"circuit_breaker_open",
|
||||||
|
"circuit_breaker_open_at",
|
||||||
|
"next_probe_at",
|
||||||
|
"half_open_until",
|
||||||
|
"half_open_successes",
|
||||||
|
"half_open_failures",
|
||||||
|
]
|
||||||
|
for col in old_health_columns:
|
||||||
|
if _column_exists("provider_api_keys", col):
|
||||||
|
op.drop_column("provider_api_keys", col)
|
||||||
|
|
||||||
|
# ========== 8. provider_endpoints: 删除废弃的 rate_limit 列 ==========
|
||||||
|
if _column_exists("provider_endpoints", "rate_limit"):
|
||||||
|
op.drop_column("provider_endpoints", "rate_limit")
|
||||||
|
|
||||||
|
# ========== 9. usage: 添加 client_response_headers ==========
|
||||||
|
if not _column_exists("usage", "client_response_headers"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("client_response_headers", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== 10. provider_api_keys: 删除 endpoint_id ==========
|
||||||
|
# Key 不再与 Endpoint 绑定,通过 provider_id + api_formats 关联
|
||||||
|
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
# 查找 endpoint_id 上的外键并删除(用 savepoint 保护,避免事务中止)
|
||||||
|
conn = op.get_bind()
|
||||||
|
fk_rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT con.conname FROM pg_constraint con "
|
||||||
|
"JOIN pg_attribute att ON att.attnum = ANY(con.conkey) "
|
||||||
|
" AND att.attrelid = con.conrelid "
|
||||||
|
"WHERE con.conrelid = 'provider_api_keys'::regclass "
|
||||||
|
" AND con.contype = 'f' AND att.attname = 'endpoint_id'"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for (fk_name,) in fk_rows:
|
||||||
|
conn.execute(sa.text(f"SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||||
|
try:
|
||||||
|
op.drop_constraint(fk_name, "provider_api_keys", type_="foreignkey")
|
||||||
|
conn.execute(sa.text(f"RELEASE SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||||
|
except Exception:
|
||||||
|
conn.execute(sa.text(f"ROLLBACK TO SAVEPOINT sp_drop_fk_{fk_name}"))
|
||||||
|
op.drop_column("provider_api_keys", "endpoint_id")
|
||||||
|
|
||||||
|
# ========== 11. provider_endpoints: 删除废弃的 max_concurrent 列 ==========
|
||||||
|
if _column_exists("provider_endpoints", "max_concurrent"):
|
||||||
|
op.drop_column("provider_endpoints", "max_concurrent")
|
||||||
|
|
||||||
|
# ========== 12. providers: 删除废弃的 RPM 相关字段 ==========
|
||||||
|
if _column_exists("providers", "rpm_limit"):
|
||||||
|
op.drop_column("providers", "rpm_limit")
|
||||||
|
if _column_exists("providers", "rpm_used"):
|
||||||
|
op.drop_column("providers", "rpm_used")
|
||||||
|
if _column_exists("providers", "rpm_reset_at"):
|
||||||
|
op.drop_column("providers", "rpm_reset_at")
|
||||||
|
|
||||||
|
alembic_logger.info("[OK] Consolidated migration completed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""
|
||||||
|
Downgrade is complex due to data migrations.
|
||||||
|
For safety, this only removes new columns without restoring old structure.
|
||||||
|
Manual intervention may be required for full rollback.
|
||||||
|
"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# 12. 恢复 providers RPM 相关字段
|
||||||
|
if not _column_exists("providers", "rpm_limit"):
|
||||||
|
op.add_column("providers", sa.Column("rpm_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("providers", "rpm_used"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("rpm_used", sa.Integer(), server_default="0", nullable=True),
|
||||||
|
)
|
||||||
|
if not _column_exists("providers", "rpm_reset_at"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("rpm_reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 11. 恢复 provider_endpoints.max_concurrent
|
||||||
|
if not _column_exists("provider_endpoints", "max_concurrent"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_endpoints", sa.Column("max_concurrent", sa.Integer(), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 10. 恢复 endpoint_id
|
||||||
|
if not _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("endpoint_id", sa.String(36), nullable=True))
|
||||||
|
|
||||||
|
# 9. 删除 client_response_headers
|
||||||
|
if _column_exists("usage", "client_response_headers"):
|
||||||
|
op.drop_column("usage", "client_response_headers")
|
||||||
|
|
||||||
|
# 8. 恢复 provider_endpoints.rate_limit(如果需要)
|
||||||
|
if not _column_exists("provider_endpoints", "rate_limit"):
|
||||||
|
op.add_column("provider_endpoints", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 7. 删除健康度 JSON 字段
|
||||||
|
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_health_by_format"))
|
||||||
|
bind.execute(sa.text("DROP INDEX IF EXISTS ix_provider_api_keys_circuit_breaker_by_format"))
|
||||||
|
if _column_exists("provider_api_keys", "health_by_format"):
|
||||||
|
op.drop_column("provider_api_keys", "health_by_format")
|
||||||
|
if _column_exists("provider_api_keys", "circuit_breaker_by_format"):
|
||||||
|
op.drop_column("provider_api_keys", "circuit_breaker_by_format")
|
||||||
|
|
||||||
|
# 6. rpm_limit -> max_concurrent(简化版:仅重命名)
|
||||||
|
if _column_exists("provider_api_keys", "rpm_limit"):
|
||||||
|
op.alter_column("provider_api_keys", "rpm_limit", new_column_name="max_concurrent")
|
||||||
|
if _column_exists("provider_api_keys", "learned_rpm_limit"):
|
||||||
|
op.alter_column(
|
||||||
|
"provider_api_keys", "learned_rpm_limit", new_column_name="learned_max_concurrent"
|
||||||
|
)
|
||||||
|
if _column_exists("provider_api_keys", "last_rpm_peak"):
|
||||||
|
op.alter_column(
|
||||||
|
"provider_api_keys", "last_rpm_peak", new_column_name="last_concurrent_peak"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 恢复已删除的字段
|
||||||
|
if not _column_exists("provider_api_keys", "rate_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("provider_api_keys", "daily_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("daily_limit", sa.Integer(), nullable=True))
|
||||||
|
if not _column_exists("provider_api_keys", "monthly_limit"):
|
||||||
|
op.add_column("provider_api_keys", sa.Column("monthly_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 5. name -> display_name (需要先删除索引)
|
||||||
|
if _column_exists("providers", "name") and not _column_exists("providers", "display_name"):
|
||||||
|
if _index_exists("providers", "ix_providers_name"):
|
||||||
|
op.drop_index("ix_providers_name", table_name="providers")
|
||||||
|
op.alter_column("providers", "name", new_column_name="display_name")
|
||||||
|
|
||||||
|
if not _column_exists("providers", "name"):
|
||||||
|
op.add_column("providers", sa.Column("name", sa.String(100), nullable=True))
|
||||||
|
op.execute("""
|
||||||
|
UPDATE providers
|
||||||
|
SET name = LOWER(REPLACE(REPLACE(display_name, ' ', '_'), '-', '_'))
|
||||||
|
""")
|
||||||
|
op.alter_column("providers", "name", nullable=False)
|
||||||
|
if not _index_exists("providers", "ix_providers_name"):
|
||||||
|
op.create_index("ix_providers_name", "providers", ["name"], unique=True)
|
||||||
|
|
||||||
|
# 4. 删除 providers 的 timeout, max_retries, proxy
|
||||||
|
if _column_exists("providers", "proxy"):
|
||||||
|
op.drop_column("providers", "proxy")
|
||||||
|
if _column_exists("providers", "max_retries"):
|
||||||
|
op.drop_column("providers", "max_retries")
|
||||||
|
if _column_exists("providers", "timeout"):
|
||||||
|
op.drop_column("providers", "timeout")
|
||||||
|
|
||||||
|
# 3. models: global_model_id 改回 NOT NULL
|
||||||
|
result = bind.execute(sa.text("SELECT COUNT(*) FROM models WHERE global_model_id IS NULL"))
|
||||||
|
orphan_model_count = result.scalar() or 0
|
||||||
|
if orphan_model_count > 0:
|
||||||
|
alembic_logger.warning(
|
||||||
|
f"[WARN] 发现 {orphan_model_count} 个无 global_model_id 的独立模型,将被删除"
|
||||||
|
)
|
||||||
|
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
|
||||||
|
alembic_logger.info(f"已删除 {orphan_model_count} 个独立模型")
|
||||||
|
op.alter_column("models", "global_model_id", nullable=False)
|
||||||
|
|
||||||
|
# 2. 删除 rate_multipliers
|
||||||
|
if _column_exists("provider_api_keys", "rate_multipliers"):
|
||||||
|
op.drop_column("provider_api_keys", "rate_multipliers")
|
||||||
|
|
||||||
|
# 1. 删除 provider_id 和 api_formats
|
||||||
|
if _index_exists("provider_api_keys", "idx_provider_api_keys_provider_id"):
|
||||||
|
op.drop_index("idx_provider_api_keys_provider_id", table_name="provider_api_keys")
|
||||||
|
if _constraint_exists("provider_api_keys", "fk_provider_api_keys_provider"):
|
||||||
|
op.drop_constraint("fk_provider_api_keys_provider", "provider_api_keys", type_="foreignkey")
|
||||||
|
if _column_exists("provider_api_keys", "api_formats"):
|
||||||
|
op.drop_column("provider_api_keys", "api_formats")
|
||||||
|
if _column_exists("provider_api_keys", "provider_id"):
|
||||||
|
op.drop_column("provider_api_keys", "provider_id")
|
||||||
|
|
||||||
|
# 恢复 endpoint_id 外键(简化版:仅创建外键,不强制 NOT NULL)
|
||||||
|
if _column_exists("provider_api_keys", "endpoint_id"):
|
||||||
|
if not _constraint_exists("provider_api_keys", "provider_api_keys_endpoint_id_fkey"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"provider_api_keys_endpoint_id_fkey",
|
||||||
|
"provider_api_keys",
|
||||||
|
"provider_endpoints",
|
||||||
|
["endpoint_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
alembic_logger.info("[OK] Downgrade completed (simplified version)")
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
"""add auto_fetch_models and locked_models to provider_api_keys
|
||||||
|
|
||||||
|
Revision ID: e4ebe3233b40
|
||||||
|
Revises: m4n5o6p7q8r9
|
||||||
|
Create Date: 2026-01-13 17:59:53.119479+00:00
|
||||||
|
|
||||||
|
为 provider_api_keys 表添加自动获取模型相关字段:
|
||||||
|
1. auto_fetch_models: 是否启用自动获取模型
|
||||||
|
2. last_models_fetch_at: 最后获取时间
|
||||||
|
3. last_models_fetch_error: 最后获取错误信息
|
||||||
|
4. locked_models: 被锁定的模型列表(刷新时不会被删除)
|
||||||
|
|
||||||
|
注意: downgrade 操作会永久删除 auto_fetch_models 配置和 locked_models 数据
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
"""Check if an index exists"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
indexes = inspector.get_indexes("provider_api_keys")
|
||||||
|
return any(idx["name"] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e4ebe3233b40'
|
||||||
|
down_revision = 'm4n5o6p7q8r9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in the table"""
|
||||||
|
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:
|
||||||
|
"""添加自动获取模型相关字段"""
|
||||||
|
if not _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("auto_fetch_models", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("last_models_fetch_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("last_models_fetch_error", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _column_exists("provider_api_keys", "locked_models"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("locked_models", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加复合索引以优化调度器查询
|
||||||
|
if not _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||||
|
op.create_index(
|
||||||
|
"ix_provider_api_keys_auto_fetch_active",
|
||||||
|
"provider_api_keys",
|
||||||
|
["auto_fetch_models", "is_active"],
|
||||||
|
postgresql_where=sa.text("auto_fetch_models = true AND is_active = true"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""移除自动获取模型相关字段"""
|
||||||
|
# 先删除索引
|
||||||
|
if _index_exists("ix_provider_api_keys_auto_fetch_active"):
|
||||||
|
op.drop_index("ix_provider_api_keys_auto_fetch_active", table_name="provider_api_keys")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "locked_models"):
|
||||||
|
op.drop_column("provider_api_keys", "locked_models")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "last_models_fetch_error"):
|
||||||
|
op.drop_column("provider_api_keys", "last_models_fetch_error")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "last_models_fetch_at"):
|
||||||
|
op.drop_column("provider_api_keys", "last_models_fetch_at")
|
||||||
|
|
||||||
|
if _column_exists("provider_api_keys", "auto_fetch_models"):
|
||||||
|
op.drop_column("provider_api_keys", "auto_fetch_models")
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
"""add header_rules to provider_endpoints and is_locked to api_keys
|
||||||
|
|
||||||
|
Revision ID: 6d579000e511
|
||||||
|
Revises: e4ebe3233b40
|
||||||
|
Create Date: 2026-01-15 23:00:00.000000+00:00
|
||||||
|
|
||||||
|
变更:
|
||||||
|
1. provider_endpoints 表: 添加 header_rules 字段,迁移 headers 数据
|
||||||
|
2. api_keys 表: 添加 is_locked 字段(管理员锁定标志)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '6d579000e511'
|
||||||
|
down_revision = 'e4ebe3233b40'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(connection, table: str, column: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table AND column_name = :column
|
||||||
|
"""),
|
||||||
|
{"table": table, "column": column}
|
||||||
|
)
|
||||||
|
return result.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""添加 header_rules 字段并迁移现有 headers 数据;添加 is_locked 字段"""
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# ========== provider_endpoints.header_rules ==========
|
||||||
|
# 1. 添加 header_rules 列(幂等)
|
||||||
|
if not _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||||
|
op.add_column('provider_endpoints', sa.Column('header_rules', JSON, nullable=True))
|
||||||
|
|
||||||
|
# 2. 批量迁移:headers -> header_rules
|
||||||
|
# 使用纯 SQL 将 {"k1":"v1", "k2":"v2"} 转换为 [{"action":"set","key":"k1","value":"v1"}, ...]
|
||||||
|
if _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||||
|
connection.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET header_rules = (
|
||||||
|
SELECT jsonb_agg(
|
||||||
|
jsonb_build_object('action', 'set', 'key', key, 'value', value)
|
||||||
|
)
|
||||||
|
FROM jsonb_each_text(headers::jsonb)
|
||||||
|
)
|
||||||
|
WHERE headers IS NOT NULL
|
||||||
|
AND headers::text != '{}'
|
||||||
|
AND jsonb_typeof(headers::jsonb) = 'object'
|
||||||
|
AND header_rules IS NULL
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 删除旧列
|
||||||
|
op.drop_column('provider_endpoints', 'headers')
|
||||||
|
|
||||||
|
# ========== api_keys.is_locked ==========
|
||||||
|
if not _column_exists(connection, 'api_keys', 'is_locked'):
|
||||||
|
op.add_column(
|
||||||
|
'api_keys',
|
||||||
|
sa.Column('is_locked', sa.Boolean(), nullable=False, server_default='false')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""移除 header_rules 字段,恢复 headers 字段;移除 is_locked 字段"""
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# ========== api_keys.is_locked ==========
|
||||||
|
if _column_exists(connection, 'api_keys', 'is_locked'):
|
||||||
|
op.drop_column('api_keys', 'is_locked')
|
||||||
|
|
||||||
|
# ========== provider_endpoints.header_rules ==========
|
||||||
|
# 1. 添加 headers 列(幂等)
|
||||||
|
if not _column_exists(connection, 'provider_endpoints', 'headers'):
|
||||||
|
op.add_column('provider_endpoints', sa.Column('headers', JSON, nullable=True))
|
||||||
|
|
||||||
|
# 2. 批量迁移:header_rules -> headers(仅提取 set 操作)
|
||||||
|
if _column_exists(connection, 'provider_endpoints', 'header_rules'):
|
||||||
|
connection.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET headers = (
|
||||||
|
SELECT jsonb_object_agg(rule->>'key', rule->>'value')
|
||||||
|
FROM jsonb_array_elements(header_rules::jsonb) AS rule
|
||||||
|
WHERE rule->>'action' = 'set'
|
||||||
|
AND rule->>'key' IS NOT NULL
|
||||||
|
)
|
||||||
|
WHERE header_rules IS NOT NULL
|
||||||
|
AND jsonb_typeof(header_rules::jsonb) = 'array'
|
||||||
|
AND jsonb_array_length(header_rules::jsonb) > 0
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 删除 header_rules 列
|
||||||
|
op.drop_column('provider_endpoints', 'header_rules')
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""add global_priority_by_format and remove deprecated fields
|
||||||
|
|
||||||
|
Revision ID: ddd59cdf0349
|
||||||
|
Revises: 6d579000e511
|
||||||
|
Create Date: 2026-01-16 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
变更:
|
||||||
|
1. provider_api_keys 表: 添加 global_priority_by_format 字段(按 API 格式的全局优先级)
|
||||||
|
2. 迁移现有 global_priority 数据到新字段
|
||||||
|
3. 删除已废弃的 global_priority 字段
|
||||||
|
4. 删除已废弃的 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||||
|
5. 删除已废弃的 providers.timeout 字段(由环境变量控制)
|
||||||
|
6. 删除已废弃的 provider_endpoints.timeout 字段(由环境变量控制)
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import JSON
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ddd59cdf0349'
|
||||||
|
down_revision = '6d579000e511'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(connection, table: str, column: str) -> bool:
|
||||||
|
"""检查列是否存在"""
|
||||||
|
result = connection.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = :table AND column_name = :column
|
||||||
|
"""),
|
||||||
|
{"table": table, "column": column}
|
||||||
|
)
|
||||||
|
return result.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 添加 global_priority_by_format 字段
|
||||||
|
if not _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||||
|
op.add_column(
|
||||||
|
'provider_api_keys',
|
||||||
|
sa.Column('global_priority_by_format', JSON, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 迁移现有 global_priority 数据到新字段
|
||||||
|
# 对于有 global_priority 的 Key,将其值应用到所有支持的 api_formats
|
||||||
|
if _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||||
|
# 将 JSON 数组转换为 text[] 后使用 unnest
|
||||||
|
connection.execute(sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET global_priority_by_format = (
|
||||||
|
SELECT jsonb_object_agg(format, global_priority)
|
||||||
|
FROM jsonb_array_elements_text(api_formats::jsonb) AS format
|
||||||
|
)
|
||||||
|
WHERE global_priority IS NOT NULL
|
||||||
|
AND api_formats IS NOT NULL
|
||||||
|
AND jsonb_array_length(api_formats::jsonb) > 0
|
||||||
|
AND global_priority_by_format IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. 删除 global_priority 字段
|
||||||
|
op.drop_column('provider_api_keys', 'global_priority')
|
||||||
|
|
||||||
|
# 4. 删除 rate_multiplier 字段(已被 rate_multipliers 替代)
|
||||||
|
if _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||||
|
op.drop_column('provider_api_keys', 'rate_multiplier')
|
||||||
|
|
||||||
|
# 5. 删除 providers.timeout 字段(由环境变量控制)
|
||||||
|
if _column_exists(connection, 'providers', 'timeout'):
|
||||||
|
op.drop_column('providers', 'timeout')
|
||||||
|
|
||||||
|
# 6. 删除 provider_endpoints.timeout 字段(由环境变量控制)
|
||||||
|
if _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||||
|
op.drop_column('provider_endpoints', 'timeout')
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# 1. 恢复 rate_multiplier 字段
|
||||||
|
if not _column_exists(connection, 'provider_api_keys', 'rate_multiplier'):
|
||||||
|
op.add_column(
|
||||||
|
'provider_api_keys',
|
||||||
|
sa.Column('rate_multiplier', sa.Float, nullable=False, server_default='1.0')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 恢复 global_priority 字段并迁移数据
|
||||||
|
if not _column_exists(connection, 'provider_api_keys', 'global_priority'):
|
||||||
|
op.add_column(
|
||||||
|
'provider_api_keys',
|
||||||
|
sa.Column('global_priority', sa.Integer, nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从 global_priority_by_format 迁移数据(取第一个格式的优先级值)
|
||||||
|
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||||
|
connection.execute(sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET global_priority = (
|
||||||
|
SELECT (value::text)::integer
|
||||||
|
FROM jsonb_each(global_priority_by_format::jsonb)
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE global_priority_by_format IS NOT NULL
|
||||||
|
AND jsonb_typeof(global_priority_by_format::jsonb) = 'object'
|
||||||
|
AND global_priority IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 3. 删除 global_priority_by_format 字段
|
||||||
|
if _column_exists(connection, 'provider_api_keys', 'global_priority_by_format'):
|
||||||
|
op.drop_column('provider_api_keys', 'global_priority_by_format')
|
||||||
|
|
||||||
|
# 4. 恢复 providers.timeout 字段
|
||||||
|
if not _column_exists(connection, 'providers', 'timeout'):
|
||||||
|
op.add_column(
|
||||||
|
'providers',
|
||||||
|
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. 恢复 provider_endpoints.timeout 字段
|
||||||
|
if not _column_exists(connection, 'provider_endpoints', 'timeout'):
|
||||||
|
op.add_column(
|
||||||
|
'provider_endpoints',
|
||||||
|
sa.Column('timeout', sa.Integer, nullable=True, server_default='300')
|
||||||
|
)
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
"""make users email/password nullable add email_verified and oauth tables
|
||||||
|
|
||||||
|
Revision ID: 33e347f97c0c
|
||||||
|
Revises: ddd59cdf0349
|
||||||
|
Create Date: 2026-01-18 11:18:15.940559+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "33e347f97c0c"
|
||||||
|
down_revision = "ddd59cdf0349"
|
||||||
|
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 table_exists(table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def column_is_nullable(table_name: str, column_name: str) -> bool:
|
||||||
|
"""检查列是否允许 NULL"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
for col in inspector.get_columns(table_name):
|
||||||
|
if col["name"] == column_name:
|
||||||
|
return col["nullable"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enum_value_exists(enum_name: str, value: str) -> bool:
|
||||||
|
"""检查 PostgreSQL ENUM 是否包含指定值"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
if bind.dialect.name != "postgresql":
|
||||||
|
return True # 非 PostgreSQL 跳过检查
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_enum WHERE enumlabel = :value "
|
||||||
|
"AND enumtypid = (SELECT oid FROM pg_type WHERE typname = :enum_name)"
|
||||||
|
),
|
||||||
|
{"value": value, "enum_name": enum_name},
|
||||||
|
).first()
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""应用迁移:升级到新版本"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# ========== Part 1: users 表修改 ==========
|
||||||
|
|
||||||
|
# 1) 新增 email_verified
|
||||||
|
if not column_exists("users", "email_verified"):
|
||||||
|
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=True))
|
||||||
|
# 历史数据回填:已有邮箱的用户默认视为已验证
|
||||||
|
op.execute(sa.text("UPDATE users SET email_verified = true WHERE email IS NOT NULL"))
|
||||||
|
op.execute(sa.text("UPDATE users SET email_verified = false WHERE email IS NULL"))
|
||||||
|
# 收紧约束
|
||||||
|
op.alter_column("users", "email_verified", existing_type=sa.Boolean(), nullable=False)
|
||||||
|
|
||||||
|
# 2) email 放宽为可空
|
||||||
|
if not column_is_nullable("users", "email"):
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"email",
|
||||||
|
existing_type=sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3) password_hash 放宽为可空
|
||||||
|
if not column_is_nullable("users", "password_hash"):
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"password_hash",
|
||||||
|
existing_type=sa.String(length=255),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========== Part 2: OAuth 相关 ==========
|
||||||
|
|
||||||
|
# 4) 扩展 authsource enum
|
||||||
|
if bind.dialect.name == "postgresql" and not enum_value_exists("authsource", "oauth"):
|
||||||
|
ctx = op.get_context()
|
||||||
|
with ctx.autocommit_block():
|
||||||
|
op.execute("ALTER TYPE authsource ADD VALUE IF NOT EXISTS 'oauth'")
|
||||||
|
|
||||||
|
# 5) OAuth provider 配置表
|
||||||
|
if not table_exists("oauth_providers"):
|
||||||
|
op.create_table(
|
||||||
|
"oauth_providers",
|
||||||
|
sa.Column("provider_type", sa.String(length=50), primary_key=True),
|
||||||
|
sa.Column("display_name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("client_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("client_secret_encrypted", sa.Text(), nullable=True),
|
||||||
|
sa.Column("authorization_url_override", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("token_url_override", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("userinfo_url_override", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("scopes", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("redirect_uri", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("frontend_callback_url", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("attribute_mapping", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("extra_config", sa.JSON(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6) 用户 OAuth 绑定关系表
|
||||||
|
if not table_exists("user_oauth_links"):
|
||||||
|
op.create_table(
|
||||||
|
"user_oauth_links",
|
||||||
|
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"provider_type",
|
||||||
|
sa.String(length=50),
|
||||||
|
sa.ForeignKey("oauth_providers.provider_type", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("provider_user_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("provider_username", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("provider_email", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("extra_data", sa.JSON(), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"linked_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"provider_type", "provider_user_id", name="uq_oauth_provider_user"
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "provider_type", name="uq_user_oauth_provider"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_oauth_links_user_id", "user_oauth_links", ["user_id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_oauth_links_provider_type", "user_oauth_links", ["provider_type"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:降级到旧版本"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
# ========== Part 2: OAuth 相关(先删除,因为有外键依赖) ==========
|
||||||
|
|
||||||
|
if table_exists("user_oauth_links"):
|
||||||
|
op.drop_index("ix_user_oauth_links_provider_type", table_name="user_oauth_links")
|
||||||
|
op.drop_index("ix_user_oauth_links_user_id", table_name="user_oauth_links")
|
||||||
|
op.drop_table("user_oauth_links")
|
||||||
|
|
||||||
|
if table_exists("oauth_providers"):
|
||||||
|
op.drop_table("oauth_providers")
|
||||||
|
|
||||||
|
# 注意:Postgres 不支持从 ENUM 删除值,authsource 不回退
|
||||||
|
|
||||||
|
# ========== Part 1: users 表修改 ==========
|
||||||
|
|
||||||
|
# 降级前检查:避免把包含 NULL 的列强制改回 NOT NULL
|
||||||
|
has_null_email = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM users WHERE email IS NULL LIMIT 1")
|
||||||
|
).first()
|
||||||
|
if has_null_email:
|
||||||
|
raise RuntimeError("Cannot downgrade: users.email contains NULL values")
|
||||||
|
|
||||||
|
has_null_password = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM users WHERE password_hash IS NULL LIMIT 1")
|
||||||
|
).first()
|
||||||
|
if has_null_password:
|
||||||
|
raise RuntimeError("Cannot downgrade: users.password_hash contains NULL values")
|
||||||
|
|
||||||
|
# 恢复 NOT NULL 约束
|
||||||
|
if column_is_nullable("users", "email"):
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"email",
|
||||||
|
existing_type=sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if column_is_nullable("users", "password_hash"):
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"password_hash",
|
||||||
|
existing_type=sa.String(length=255),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if column_exists("users", "email_verified"):
|
||||||
|
op.drop_column("users", "email_verified")
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"""add_stats_daily_provider_table
|
||||||
|
|
||||||
|
Revision ID: c868729753ad
|
||||||
|
Revises: 33e347f97c0c
|
||||||
|
Create Date: 2026-01-19 05:19:49.634662+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'c868729753ad'
|
||||||
|
down_revision = '33e347f97c0c'
|
||||||
|
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 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 upgrade() -> None:
|
||||||
|
"""应用迁移:升级到新版本"""
|
||||||
|
if not table_exists('stats_daily_provider'):
|
||||||
|
op.create_table(
|
||||||
|
'stats_daily_provider',
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('date', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('provider_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('total_requests', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('input_tokens', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('output_tokens', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('cache_creation_tokens', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('cache_read_tokens', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('total_cost', sa.Float(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('date', 'provider_name', name='uq_stats_daily_provider')
|
||||||
|
)
|
||||||
|
op.create_index('idx_stats_daily_provider_date', 'stats_daily_provider', ['date'], unique=False)
|
||||||
|
op.create_index('idx_stats_daily_provider_date_provider', 'stats_daily_provider', ['date', 'provider_name'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""回滚迁移:降级到旧版本"""
|
||||||
|
if table_exists('stats_daily_provider'):
|
||||||
|
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date_provider'):
|
||||||
|
op.drop_index('idx_stats_daily_provider_date_provider', table_name='stats_daily_provider')
|
||||||
|
if index_exists('stats_daily_provider', 'idx_stats_daily_provider_date'):
|
||||||
|
op.drop_index('idx_stats_daily_provider_date', table_name='stats_daily_provider')
|
||||||
|
op.drop_table('stats_daily_provider')
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"""add_format_acceptance_config_to_provider_endpoints
|
||||||
|
|
||||||
|
Revision ID: 4b4c7b0df1a2
|
||||||
|
Revises: c868729753ad
|
||||||
|
Create Date: 2026-01-21 18:45:00+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "4b4c7b0df1a2"
|
||||||
|
down_revision = "c868729753ad"
|
||||||
|
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:
|
||||||
|
if not table_exists("provider_endpoints"):
|
||||||
|
return
|
||||||
|
if column_exists("provider_endpoints", "format_acceptance_config"):
|
||||||
|
return
|
||||||
|
op.add_column(
|
||||||
|
"provider_endpoints",
|
||||||
|
sa.Column("format_acceptance_config", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not table_exists("provider_endpoints"):
|
||||||
|
return
|
||||||
|
if not column_exists("provider_endpoints", "format_acceptance_config"):
|
||||||
|
return
|
||||||
|
op.drop_column("provider_endpoints", "format_acceptance_config")
|
||||||
|
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
"""add_format_conversion_tracking_and_model_filter_patterns_and_provider_timeout
|
||||||
|
|
||||||
|
Revision ID: f7c8d9e0a1b2
|
||||||
|
Revises: 4b4c7b0df1a2
|
||||||
|
Create Date: 2026-01-27 10:00:00+00:00
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. usage 表: 添加 endpoint_api_format 和 has_format_conversion 字段
|
||||||
|
2. provider_api_keys 表: 添加 model_include_patterns 和 model_exclude_patterns 字段
|
||||||
|
- 支持通配符规则自动过滤从上游获取的模型列表
|
||||||
|
- 包含规则和排除规则(支持 * 和 ? 通配符)
|
||||||
|
3. providers 表: 添加 stream_first_byte_timeout 和 request_timeout 字段
|
||||||
|
- 允许每个提供商单独配置超时时间
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f7c8d9e0a1b2"
|
||||||
|
down_revision = "4b4c7b0df1a2"
|
||||||
|
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:
|
||||||
|
# === usage 表: 格式转换追踪 ===
|
||||||
|
if table_exists("usage"):
|
||||||
|
# 添加 endpoint_api_format 字段(端点原生 API 格式)
|
||||||
|
if not column_exists("usage", "endpoint_api_format"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("endpoint_api_format", sa.String(50), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加 has_format_conversion 字段(是否发生了格式转换)
|
||||||
|
if not column_exists("usage", "has_format_conversion"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("has_format_conversion", sa.Boolean(), nullable=True, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === provider_api_keys 表: 模型过滤规则 ===
|
||||||
|
if table_exists("provider_api_keys"):
|
||||||
|
# 添加 model_include_patterns 字段(包含规则,支持 * 和 ? 通配符)
|
||||||
|
if not column_exists("provider_api_keys", "model_include_patterns"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("model_include_patterns", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加 model_exclude_patterns 字段(排除规则,支持 * 和 ? 通配符)
|
||||||
|
if not column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("model_exclude_patterns", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# === providers 表: 超时配置 ===
|
||||||
|
if table_exists("providers"):
|
||||||
|
# 添加 stream_first_byte_timeout 字段(流式请求首字节超时)
|
||||||
|
if not column_exists("providers", "stream_first_byte_timeout"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("stream_first_byte_timeout", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加 request_timeout 字段(非流式请求整体超时)
|
||||||
|
if not column_exists("providers", "request_timeout"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("request_timeout", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# === providers 表: 移除超时配置 ===
|
||||||
|
if table_exists("providers"):
|
||||||
|
if column_exists("providers", "request_timeout"):
|
||||||
|
op.drop_column("providers", "request_timeout")
|
||||||
|
|
||||||
|
if column_exists("providers", "stream_first_byte_timeout"):
|
||||||
|
op.drop_column("providers", "stream_first_byte_timeout")
|
||||||
|
|
||||||
|
# === provider_api_keys 表: 移除模型过滤规则 ===
|
||||||
|
if table_exists("provider_api_keys"):
|
||||||
|
if column_exists("provider_api_keys", "model_exclude_patterns"):
|
||||||
|
op.drop_column("provider_api_keys", "model_exclude_patterns")
|
||||||
|
|
||||||
|
if column_exists("provider_api_keys", "model_include_patterns"):
|
||||||
|
op.drop_column("provider_api_keys", "model_include_patterns")
|
||||||
|
|
||||||
|
# === usage 表: 移除格式转换追踪 ===
|
||||||
|
if table_exists("usage"):
|
||||||
|
if column_exists("usage", "has_format_conversion"):
|
||||||
|
op.drop_column("usage", "has_format_conversion")
|
||||||
|
|
||||||
|
if column_exists("usage", "endpoint_api_format"):
|
||||||
|
op.drop_column("usage", "endpoint_api_format")
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""add_keep_priority_on_conversion_to_providers
|
||||||
|
|
||||||
|
Revision ID: 364680d1bc99
|
||||||
|
Revises: f7c8d9e0a1b2
|
||||||
|
Create Date: 2026-01-28 12:00:00+00:00
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. providers 表: 添加 keep_priority_on_conversion 字段
|
||||||
|
- 格式转换时是否保持提供商原优先级
|
||||||
|
- 默认 False:需要格式转换时,候选会被降级到不需要转换的候选之后
|
||||||
|
- 设为 True:即使需要格式转换,也保持原优先级排名
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "364680d1bc99"
|
||||||
|
down_revision = "f7c8d9e0a1b2"
|
||||||
|
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:
|
||||||
|
# === providers 表: 添加格式转换优先级保持配置 ===
|
||||||
|
if table_exists("providers"):
|
||||||
|
if not column_exists("providers", "keep_priority_on_conversion"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column(
|
||||||
|
"keep_priority_on_conversion",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# === providers 表: 移除格式转换优先级保持配置 ===
|
||||||
|
if table_exists("providers"):
|
||||||
|
if column_exists("providers", "keep_priority_on_conversion"):
|
||||||
|
op.drop_column("providers", "keep_priority_on_conversion")
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
"""Add auth_type and auth_config fields to provider_api_keys table
|
||||||
|
|
||||||
|
Revision ID: 7f6f8065f517
|
||||||
|
Revises: 364680d1bc99
|
||||||
|
Create Date: 2026-01-30 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7f6f8065f517"
|
||||||
|
down_revision: Union[str, None] = "364680d1bc99"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = 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 upgrade() -> None:
|
||||||
|
# 添加 auth_type 字段,默认值为 "api_key"
|
||||||
|
if not column_exists("provider_api_keys", "auth_type"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("auth_type", sa.String(20), nullable=False, server_default="api_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加 auth_config 字段(Text,存储加密后的认证配置)
|
||||||
|
if not column_exists("provider_api_keys", "auth_config"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("auth_config", sa.Text, nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "auth_config"):
|
||||||
|
op.drop_column("provider_api_keys", "auth_config")
|
||||||
|
|
||||||
|
if column_exists("provider_api_keys", "auth_type"):
|
||||||
|
op.drop_column("provider_api_keys", "auth_type")
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Add video_tasks table
|
||||||
|
|
||||||
|
Revision ID: b6f1a2c5d8e9
|
||||||
|
Revises: 7f6f8065f517
|
||||||
|
Create Date: 2026-01-30 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b6f1a2c5d8e9"
|
||||||
|
down_revision: Union[str, None] = "7f6f8065f517"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if table_exists("video_tasks"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("external_task_id", sa.String(200), nullable=True, index=False),
|
||||||
|
sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False),
|
||||||
|
sa.Column("api_key_id", sa.String(36), sa.ForeignKey("api_keys.id"), nullable=True),
|
||||||
|
sa.Column("provider_id", sa.String(36), sa.ForeignKey("providers.id"), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"endpoint_id", sa.String(36), sa.ForeignKey("provider_endpoints.id"), nullable=True
|
||||||
|
),
|
||||||
|
sa.Column("key_id", sa.String(36), sa.ForeignKey("provider_api_keys.id"), nullable=True),
|
||||||
|
sa.Column("client_api_format", sa.String(50), nullable=False),
|
||||||
|
sa.Column("provider_api_format", sa.String(50), nullable=False),
|
||||||
|
sa.Column("format_converted", sa.Boolean(), server_default=sa.false()),
|
||||||
|
sa.Column("model", sa.String(100), nullable=False),
|
||||||
|
sa.Column("prompt", sa.Text(), nullable=False),
|
||||||
|
sa.Column("original_request_body", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("converted_request_body", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("duration_seconds", sa.Integer(), server_default=sa.text("4")),
|
||||||
|
sa.Column("resolution", sa.String(20), server_default=sa.text("'720p'")),
|
||||||
|
sa.Column("aspect_ratio", sa.String(10), server_default=sa.text("'16:9'")),
|
||||||
|
sa.Column("size", sa.String(20), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), server_default=sa.text("'pending'")),
|
||||||
|
sa.Column("progress_percent", sa.Integer(), server_default=sa.text("0")),
|
||||||
|
sa.Column("progress_message", sa.String(500), nullable=True),
|
||||||
|
sa.Column("video_url", sa.String(2000), nullable=True),
|
||||||
|
sa.Column("video_urls", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("thumbnail_url", sa.String(2000), nullable=True),
|
||||||
|
sa.Column("video_size_bytes", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("video_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("stored_video_path", sa.String(500), nullable=True),
|
||||||
|
sa.Column("storage_provider", sa.String(50), nullable=True),
|
||||||
|
sa.Column("error_code", sa.String(50), nullable=True),
|
||||||
|
sa.Column("error_message", sa.Text(), nullable=True),
|
||||||
|
sa.Column("retry_count", sa.Integer(), server_default=sa.text("0")),
|
||||||
|
sa.Column("max_retries", sa.Integer(), server_default=sa.text("3")),
|
||||||
|
sa.Column("poll_interval_seconds", sa.Integer(), server_default=sa.text("10")),
|
||||||
|
sa.Column("next_poll_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("poll_count", sa.Integer(), server_default=sa.text("0")),
|
||||||
|
sa.Column("max_poll_count", sa.Integer(), server_default=sa.text("360")),
|
||||||
|
sa.Column(
|
||||||
|
"remixed_from_task_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("video_tasks.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("idx_video_tasks_user_status", "video_tasks", ["user_id", "status"])
|
||||||
|
op.create_index("idx_video_tasks_next_poll", "video_tasks", ["next_poll_at"])
|
||||||
|
op.create_index("idx_video_tasks_external_id", "video_tasks", ["external_task_id"])
|
||||||
|
# 唯一约束:同一用户不能有重复的 external_task_id
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_video_tasks_user_external_id",
|
||||||
|
"video_tasks",
|
||||||
|
["user_id", "external_task_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not table_exists("video_tasks"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.drop_constraint("uq_video_tasks_user_external_id", "video_tasks", type_="unique")
|
||||||
|
op.drop_index("idx_video_tasks_external_id", table_name="video_tasks")
|
||||||
|
op.drop_index("idx_video_tasks_next_poll", table_name="video_tasks")
|
||||||
|
op.drop_index("idx_video_tasks_user_status", table_name="video_tasks")
|
||||||
|
op.drop_table("video_tasks")
|
||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
"""Add billing system tables and video_tasks.request_metadata
|
||||||
|
|
||||||
|
Revision ID: c8d2e4f6a1b3
|
||||||
|
Revises: b6f1a2c5d8e9
|
||||||
|
Create Date: 2026-01-31 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c8d2e4f6a1b3"
|
||||||
|
down_revision: Union[str, None] = "b6f1a2c5d8e9"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = 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 index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
try:
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(idx.get("name") == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ==================== video_tasks.request_metadata ====================
|
||||||
|
if not column_exists("video_tasks", "request_metadata"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("request_metadata", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== billing_rules ====================
|
||||||
|
if not table_exists("billing_rules"):
|
||||||
|
op.create_table(
|
||||||
|
"billing_rules",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"global_model_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("global_models.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"model_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("models.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("task_type", sa.String(20), nullable=False, server_default="chat"),
|
||||||
|
sa.Column("expression", sa.Text(), nullable=False),
|
||||||
|
sa.Column("variables", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||||
|
sa.Column(
|
||||||
|
"dimension_mappings", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")
|
||||||
|
),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(global_model_id IS NOT NULL AND model_id IS NULL) OR "
|
||||||
|
"(global_model_id IS NULL AND model_id IS NOT NULL)",
|
||||||
|
name="chk_billing_rules_model_ref",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Partial unique indexes for enabled rules
|
||||||
|
if table_exists("billing_rules"):
|
||||||
|
if not index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||||
|
op.create_index(
|
||||||
|
"uq_billing_rules_global_model_task",
|
||||||
|
"billing_rules",
|
||||||
|
["global_model_id", "task_type"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_enabled = TRUE AND global_model_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
if not index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||||
|
op.create_index(
|
||||||
|
"uq_billing_rules_model_task",
|
||||||
|
"billing_rules",
|
||||||
|
["model_id", "task_type"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_enabled = TRUE AND model_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== dimension_collectors ====================
|
||||||
|
if not table_exists("dimension_collectors"):
|
||||||
|
op.create_table(
|
||||||
|
"dimension_collectors",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("api_format", sa.String(50), nullable=False),
|
||||||
|
sa.Column("task_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("dimension_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("source_type", sa.String(20), nullable=False),
|
||||||
|
sa.Column("source_path", sa.String(200), nullable=True),
|
||||||
|
sa.Column("value_type", sa.String(20), nullable=False, server_default="float"),
|
||||||
|
sa.Column("transform_expression", sa.Text(), nullable=True),
|
||||||
|
sa.Column("default_value", sa.String(100), nullable=True),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
),
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"(source_type = 'computed' AND source_path IS NULL AND transform_expression IS NOT NULL) OR "
|
||||||
|
"(source_type != 'computed' AND source_path IS NOT NULL)",
|
||||||
|
name="chk_dimension_collectors_source_config",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if table_exists("dimension_collectors"):
|
||||||
|
if not index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||||
|
op.create_index(
|
||||||
|
"uq_dimension_collectors_enabled",
|
||||||
|
"dimension_collectors",
|
||||||
|
["api_format", "task_type", "dimension_name", "priority"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("is_enabled = TRUE"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop in reverse order
|
||||||
|
if table_exists("dimension_collectors"):
|
||||||
|
if index_exists("dimension_collectors", "uq_dimension_collectors_enabled"):
|
||||||
|
op.drop_index("uq_dimension_collectors_enabled", table_name="dimension_collectors")
|
||||||
|
op.drop_table("dimension_collectors")
|
||||||
|
|
||||||
|
if table_exists("billing_rules"):
|
||||||
|
if index_exists("billing_rules", "uq_billing_rules_model_task"):
|
||||||
|
op.drop_index("uq_billing_rules_model_task", table_name="billing_rules")
|
||||||
|
if index_exists("billing_rules", "uq_billing_rules_global_model_task"):
|
||||||
|
op.drop_index("uq_billing_rules_global_model_task", table_name="billing_rules")
|
||||||
|
op.drop_table("billing_rules")
|
||||||
|
|
||||||
|
if column_exists("video_tasks", "request_metadata"):
|
||||||
|
op.drop_column("video_tasks", "request_metadata")
|
||||||
+462
@@ -0,0 +1,462 @@
|
|||||||
|
"""Add api_family/endpoint_kind and migrate api_format to endpoint signature keys
|
||||||
|
|
||||||
|
Revision ID: cf40e6a5c5b1
|
||||||
|
Revises: c8d2e4f6a1b3
|
||||||
|
Create Date: 2026-01-31 15:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "cf40e6a5c5b1"
|
||||||
|
down_revision: Union[str, None] = "c8d2e4f6a1b3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(val):
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, (dict, list)):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
try:
|
||||||
|
return json.loads(val)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(val):
|
||||||
|
"""将 dict/list 转为 JSON 字符串,None 保持 None"""
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val
|
||||||
|
return json.dumps(val)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_signature(value: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Normalize legacy api_format / signature-ish strings to canonical signature key.
|
||||||
|
|
||||||
|
- canonical: `<family>:<kind>` (lowercase)
|
||||||
|
- legacy examples: "OPENAI", "OPENAI_CLI", "GEMINI_VIDEO"
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
raw = str(value).strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if ":" in raw:
|
||||||
|
fam, kind = raw.split(":", 1)
|
||||||
|
fam = fam.strip().lower()
|
||||||
|
kind = kind.strip().lower()
|
||||||
|
if not fam or not kind:
|
||||||
|
return None
|
||||||
|
return f"{fam}:{kind}"
|
||||||
|
|
||||||
|
upper = raw.upper()
|
||||||
|
if upper.startswith("CLAUDE"):
|
||||||
|
fam = "claude"
|
||||||
|
elif upper.startswith("OPENAI"):
|
||||||
|
fam = "openai"
|
||||||
|
elif upper.startswith("GEMINI"):
|
||||||
|
fam = "gemini"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
kind = "chat"
|
||||||
|
if upper.endswith("_CLI"):
|
||||||
|
kind = "cli"
|
||||||
|
elif upper.endswith("_VIDEO"):
|
||||||
|
kind = "video"
|
||||||
|
|
||||||
|
return f"{fam}:{kind}"
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_signature_list(values) -> list[str] | None:
|
||||||
|
if values is None:
|
||||||
|
return None
|
||||||
|
if isinstance(values, str):
|
||||||
|
values = _json_loads(values)
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return None
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for v in values:
|
||||||
|
sig = _normalize_signature(str(v) if v is not None else None)
|
||||||
|
if not sig:
|
||||||
|
continue
|
||||||
|
if sig in seen:
|
||||||
|
continue
|
||||||
|
seen.add(sig)
|
||||||
|
out.append(sig)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_signature_dict(values) -> dict | None:
|
||||||
|
if values is None:
|
||||||
|
return None
|
||||||
|
if isinstance(values, str):
|
||||||
|
values = _json_loads(values)
|
||||||
|
if not isinstance(values, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
out: dict = {}
|
||||||
|
for k, v in values.items():
|
||||||
|
sig = _normalize_signature(str(k) if k is not None else None)
|
||||||
|
if not sig:
|
||||||
|
continue
|
||||||
|
out[sig] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _add_video_variants(formats: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
保持原有格式,不自动补齐 video 变体。
|
||||||
|
"""
|
||||||
|
return formats
|
||||||
|
|
||||||
|
|
||||||
|
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 index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
try:
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return any(idx.get("name") == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_format_acceptance_config(cfg) -> dict | None:
|
||||||
|
cfg_obj = _json_loads(cfg)
|
||||||
|
if not isinstance(cfg_obj, dict):
|
||||||
|
return cfg_obj if cfg_obj is None else None
|
||||||
|
|
||||||
|
for key in ("accept_formats", "reject_formats"):
|
||||||
|
raw = cfg_obj.get(key)
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
continue
|
||||||
|
normalized = _normalize_signature_list(raw) or []
|
||||||
|
cfg_obj[key] = normalized
|
||||||
|
|
||||||
|
return cfg_obj
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_provider_endpoints(connection) -> None:
|
||||||
|
"""
|
||||||
|
- 将 provider_endpoints.api_format 统一迁移为 signature key(小写)
|
||||||
|
- 填充/校准 api_family / endpoint_kind
|
||||||
|
- 迁移 format_acceptance_config 中的 accept/reject formats
|
||||||
|
"""
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
api_format,
|
||||||
|
api_family,
|
||||||
|
endpoint_kind,
|
||||||
|
format_acceptance_config
|
||||||
|
FROM provider_endpoints
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
sig = _normalize_signature(row.api_format)
|
||||||
|
if not sig:
|
||||||
|
continue
|
||||||
|
fam, kind = sig.split(":", 1)
|
||||||
|
|
||||||
|
cfg = _migrate_format_acceptance_config(row.format_acceptance_config)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET
|
||||||
|
api_format = :api_format,
|
||||||
|
api_family = :api_family,
|
||||||
|
endpoint_kind = :endpoint_kind,
|
||||||
|
format_acceptance_config = CAST(:format_acceptance_config AS json)
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"api_format": sig,
|
||||||
|
"api_family": fam,
|
||||||
|
"endpoint_kind": kind,
|
||||||
|
"format_acceptance_config": _json_dumps(cfg),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_video_endpoints(connection) -> None:
|
||||||
|
"""
|
||||||
|
不再自动创建 video endpoint,保持原有配置。
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_provider_api_keys(connection) -> None:
|
||||||
|
"""
|
||||||
|
迁移 provider_api_keys:
|
||||||
|
- api_formats -> signature keys(并补齐 video 变体)
|
||||||
|
- dict 字段 key -> signature keys(rate_multipliers/global_priority/health/circuit_breaker)
|
||||||
|
- rate_multipliers/global_priority_by_format 复制 chat -> video(如 openai:chat -> openai:video)
|
||||||
|
"""
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
api_formats,
|
||||||
|
rate_multipliers,
|
||||||
|
global_priority_by_format,
|
||||||
|
health_by_format,
|
||||||
|
circuit_breaker_by_format
|
||||||
|
FROM provider_api_keys
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
api_formats = _normalize_signature_list(row.api_formats)
|
||||||
|
if api_formats is not None:
|
||||||
|
api_formats = _add_video_variants(api_formats)
|
||||||
|
|
||||||
|
rate_multipliers = _normalize_signature_dict(row.rate_multipliers)
|
||||||
|
global_priority_by_format = _normalize_signature_dict(row.global_priority_by_format)
|
||||||
|
|
||||||
|
health_by_format = _normalize_signature_dict(row.health_by_format)
|
||||||
|
circuit_breaker_by_format = _normalize_signature_dict(row.circuit_breaker_by_format)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
api_formats = CAST(:api_formats AS json),
|
||||||
|
rate_multipliers = CAST(:rate_multipliers AS json),
|
||||||
|
global_priority_by_format = CAST(:global_priority_by_format AS json),
|
||||||
|
health_by_format = CAST(:health_by_format AS json),
|
||||||
|
circuit_breaker_by_format = CAST(:circuit_breaker_by_format AS json)
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"api_formats": _json_dumps(api_formats),
|
||||||
|
"rate_multipliers": _json_dumps(rate_multipliers),
|
||||||
|
"global_priority_by_format": _json_dumps(global_priority_by_format),
|
||||||
|
"health_by_format": _json_dumps(health_by_format),
|
||||||
|
"circuit_breaker_by_format": _json_dumps(circuit_breaker_by_format),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_allowed_api_formats(connection, *, table_name: str) -> None:
|
||||||
|
"""迁移 users/api_keys.allowed_api_formats 为 signature keys(并补齐 video 变体)。"""
|
||||||
|
if not table_exists(table_name):
|
||||||
|
return
|
||||||
|
rows = connection.execute(text(f"""
|
||||||
|
SELECT id, allowed_api_formats
|
||||||
|
FROM {table_name}
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
allowed = _normalize_signature_list(row.allowed_api_formats)
|
||||||
|
if allowed is None:
|
||||||
|
continue
|
||||||
|
allowed = _add_video_variants(allowed)
|
||||||
|
connection.execute(
|
||||||
|
text(f"""
|
||||||
|
UPDATE {table_name}
|
||||||
|
SET allowed_api_formats = CAST(:allowed_api_formats AS json)
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": row.id, "allowed_api_formats": _json_dumps(allowed)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_video_tasks(connection) -> None:
|
||||||
|
"""
|
||||||
|
video_tasks.*_api_format 迁移为 signature keys。
|
||||||
|
|
||||||
|
注意:video_tasks 表天然是 video 任务,因此将 openai/gemini 的 kind 强制归一为 video,
|
||||||
|
以兼容历史上复用 chat 格式存储的旧记录。
|
||||||
|
"""
|
||||||
|
if not table_exists("video_tasks"):
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT id, client_api_format, provider_api_format
|
||||||
|
FROM video_tasks
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
client_sig = _normalize_signature(row.client_api_format) or ""
|
||||||
|
provider_sig = _normalize_signature(row.provider_api_format) or ""
|
||||||
|
|
||||||
|
def _force_video(sig: str) -> str:
|
||||||
|
if not sig or ":" not in sig:
|
||||||
|
return sig
|
||||||
|
fam, _kind = sig.split(":", 1)
|
||||||
|
fam = fam.strip().lower()
|
||||||
|
if fam in ("openai", "gemini"):
|
||||||
|
return f"{fam}:video"
|
||||||
|
return sig
|
||||||
|
|
||||||
|
client_sig = _force_video(client_sig)
|
||||||
|
provider_sig = _force_video(provider_sig)
|
||||||
|
|
||||||
|
if not client_sig or not provider_sig:
|
||||||
|
continue
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE video_tasks
|
||||||
|
SET client_api_format = :client_api_format,
|
||||||
|
provider_api_format = :provider_api_format
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"client_api_format": client_sig,
|
||||||
|
"provider_api_format": provider_sig,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_model_provider_mappings(connection) -> None:
|
||||||
|
"""迁移 models.provider_model_mappings[*].api_formats 为 signature keys。"""
|
||||||
|
if not table_exists("models"):
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT id, provider_model_mappings
|
||||||
|
FROM models
|
||||||
|
WHERE provider_model_mappings IS NOT NULL
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
mappings = _json_loads(row.provider_model_mappings)
|
||||||
|
if not isinstance(mappings, list):
|
||||||
|
continue
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
new_mappings: list = []
|
||||||
|
for item in mappings:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
new_mappings.append(item)
|
||||||
|
continue
|
||||||
|
raw_formats = item.get("api_formats")
|
||||||
|
if isinstance(raw_formats, list):
|
||||||
|
normalized = _normalize_signature_list(raw_formats) or []
|
||||||
|
# 内容比较(而非引用比较),避免已迁移数据被无意义地重复 UPDATE
|
||||||
|
if set(normalized) != set(raw_formats):
|
||||||
|
changed = True
|
||||||
|
item = dict(item)
|
||||||
|
item["api_formats"] = normalized
|
||||||
|
new_mappings.append(item)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE models
|
||||||
|
SET provider_model_mappings = CAST(:provider_model_mappings AS json)
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": row.id, "provider_model_mappings": _json_dumps(new_mappings)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_dimension_collectors(connection) -> None:
|
||||||
|
"""迁移 dimension_collectors.api_format 为 signature keys(如果存在历史数据)。"""
|
||||||
|
if not table_exists("dimension_collectors"):
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = connection.execute(text("""
|
||||||
|
SELECT id, api_format
|
||||||
|
FROM dimension_collectors
|
||||||
|
WHERE api_format IS NOT NULL
|
||||||
|
""")).fetchall()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
sig = _normalize_signature(row.api_format)
|
||||||
|
if not sig:
|
||||||
|
continue
|
||||||
|
connection.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE dimension_collectors
|
||||||
|
SET api_format = :api_format
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": row.id, "api_format": sig},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not table_exists("provider_endpoints"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# ==================== provider_endpoints.api_family / endpoint_kind ====================
|
||||||
|
if not column_exists("provider_endpoints", "api_family"):
|
||||||
|
op.add_column("provider_endpoints", sa.Column("api_family", sa.String(50), nullable=True))
|
||||||
|
if not column_exists("provider_endpoints", "endpoint_kind"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_endpoints", sa.Column("endpoint_kind", sa.String(50), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== idx_provider_family_kind ====================
|
||||||
|
if not index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_provider_family_kind",
|
||||||
|
"provider_endpoints",
|
||||||
|
["provider_id", "api_family", "endpoint_kind"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==================== data migrations (idempotent) ====================
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
migrate_provider_endpoints(conn)
|
||||||
|
create_video_endpoints(conn)
|
||||||
|
|
||||||
|
if table_exists("provider_api_keys"):
|
||||||
|
migrate_provider_api_keys(conn)
|
||||||
|
|
||||||
|
migrate_allowed_api_formats(conn, table_name="users")
|
||||||
|
migrate_allowed_api_formats(conn, table_name="api_keys")
|
||||||
|
migrate_video_tasks(conn)
|
||||||
|
migrate_model_provider_mappings(conn)
|
||||||
|
migrate_dimension_collectors(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop index/columns only; data changes are intentionally kept (safe rollback strategy).
|
||||||
|
if table_exists("provider_endpoints"):
|
||||||
|
if index_exists("provider_endpoints", "idx_provider_family_kind"):
|
||||||
|
op.drop_index("idx_provider_family_kind", table_name="provider_endpoints")
|
||||||
|
if column_exists("provider_endpoints", "endpoint_kind"):
|
||||||
|
op.drop_column("provider_endpoints", "endpoint_kind")
|
||||||
|
if column_exists("provider_endpoints", "api_family"):
|
||||||
|
op.drop_column("provider_endpoints", "api_family")
|
||||||
+329
@@ -0,0 +1,329 @@
|
|||||||
|
"""Add usage billing, video_tasks fields, gemini_file_mappings, provider format conversion, and indexes
|
||||||
|
|
||||||
|
Revision ID: a2f1b3c4d5e6
|
||||||
|
Revises: cf40e6a5c5b1
|
||||||
|
Create Date: 2026-02-01 12:00:00+00:00
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. usage 表:
|
||||||
|
- 添加 billing_status (pending/settled/void),用于表示结算状态
|
||||||
|
- 添加 finalized_at,用于记录结算完成时间
|
||||||
|
- 添加 (provider_name, created_at) 和 (model, created_at) 索引
|
||||||
|
|
||||||
|
2. video_tasks 表:
|
||||||
|
- 添加 request_id(全局唯一),用于与 Usage/RequestCandidate 建立稳定关联
|
||||||
|
- 添加 short_id (Gemini-style short ID)
|
||||||
|
|
||||||
|
3. gemini_file_mappings 表:
|
||||||
|
- 创建新表用于文件映射
|
||||||
|
- 添加 source_hash 字段用于关联相同源文件
|
||||||
|
|
||||||
|
4. providers 表:
|
||||||
|
- 添加 enable_format_conversion 开关字段
|
||||||
|
|
||||||
|
5. request_candidates 表:
|
||||||
|
- 添加 created_at 索引
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "a2f1b3c4d5e6"
|
||||||
|
down_revision: Union[str, None] = "cf40e6a5c5b1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
inspector.clear_cache()
|
||||||
|
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)
|
||||||
|
# Clear cached schema info to get fresh data
|
||||||
|
inspector.clear_cache()
|
||||||
|
columns = [col["name"] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
inspector.clear_cache()
|
||||||
|
indexes = inspector.get_indexes(table_name)
|
||||||
|
return any(idx.get("name") == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
inspector.clear_cache()
|
||||||
|
constraints = inspector.get_unique_constraints(table_name)
|
||||||
|
return any(c.get("name") == constraint_name for c in constraints)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_short_id(length: int = 12) -> str:
|
||||||
|
"""Generate a Gemini-style short ID (lowercase letters + digits)"""
|
||||||
|
alphabet = string.ascii_lowercase + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 1. usage 表: billing_status + finalized_at + 索引
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("usage"):
|
||||||
|
if not column_exists("usage", "billing_status"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column(
|
||||||
|
"billing_status",
|
||||||
|
sa.String(20),
|
||||||
|
nullable=False,
|
||||||
|
server_default="settled",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists("usage", "finalized_at"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not index_exists("usage", "idx_usage_billing_status"):
|
||||||
|
op.create_index("idx_usage_billing_status", "usage", ["billing_status"])
|
||||||
|
|
||||||
|
# (provider_name, created_at) — provider list / dashboard queries
|
||||||
|
if (
|
||||||
|
column_exists("usage", "provider_name")
|
||||||
|
and column_exists("usage", "created_at")
|
||||||
|
and not index_exists("usage", "idx_usage_provider_created")
|
||||||
|
):
|
||||||
|
op.create_index("idx_usage_provider_created", "usage", ["provider_name", "created_at"])
|
||||||
|
|
||||||
|
# (model, created_at) — model analytics / recent requests queries
|
||||||
|
if (
|
||||||
|
column_exists("usage", "model")
|
||||||
|
and column_exists("usage", "created_at")
|
||||||
|
and not index_exists("usage", "idx_usage_model_created")
|
||||||
|
):
|
||||||
|
op.create_index("idx_usage_model_created", "usage", ["model", "created_at"])
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2. video_tasks 表: request_id + short_id
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("video_tasks"):
|
||||||
|
# --- request_id ---
|
||||||
|
if not column_exists("video_tasks", "request_id"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("request_id", sa.String(100), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 回填 request_id
|
||||||
|
if dialect == "postgresql":
|
||||||
|
op.execute("""
|
||||||
|
UPDATE video_tasks
|
||||||
|
SET request_id = COALESCE(request_metadata->>'request_id', id)
|
||||||
|
WHERE request_id IS NULL
|
||||||
|
""")
|
||||||
|
elif dialect == "sqlite":
|
||||||
|
op.execute("""
|
||||||
|
UPDATE video_tasks
|
||||||
|
SET request_id = COALESCE(json_extract(request_metadata, '$.request_id'), id)
|
||||||
|
WHERE request_id IS NULL
|
||||||
|
""")
|
||||||
|
else:
|
||||||
|
op.execute("""
|
||||||
|
UPDATE video_tasks
|
||||||
|
SET request_id = id
|
||||||
|
WHERE request_id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
if dialect == "postgresql":
|
||||||
|
op.alter_column("video_tasks", "request_id", nullable=False)
|
||||||
|
|
||||||
|
if not index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||||
|
op.create_index("idx_video_tasks_request_id", "video_tasks", ["request_id"])
|
||||||
|
|
||||||
|
if not unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_video_tasks_request_id",
|
||||||
|
"video_tasks",
|
||||||
|
["request_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- short_id ---
|
||||||
|
if not column_exists("video_tasks", "short_id"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("short_id", sa.String(16), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Populate existing rows with unique short_ids
|
||||||
|
result = bind.execute(text("SELECT id FROM video_tasks WHERE short_id IS NULL"))
|
||||||
|
for row in result:
|
||||||
|
short_id = generate_short_id()
|
||||||
|
bind.execute(
|
||||||
|
text("UPDATE video_tasks SET short_id = :short_id WHERE id = :id"),
|
||||||
|
{"short_id": short_id, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column("video_tasks", "short_id", nullable=False)
|
||||||
|
op.create_index("ix_video_tasks_short_id", "video_tasks", ["short_id"], unique=True)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 3. gemini_file_mappings 表
|
||||||
|
# =========================================================================
|
||||||
|
if not table_exists("gemini_file_mappings"):
|
||||||
|
op.create_table(
|
||||||
|
"gemini_file_mappings",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("file_name", sa.String(255), nullable=False, unique=True),
|
||||||
|
sa.Column(
|
||||||
|
"key_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("provider_api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("display_name", sa.String(255), nullable=True),
|
||||||
|
sa.Column("mime_type", sa.String(100), nullable=True),
|
||||||
|
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index("ix_gemini_file_mappings_id", "gemini_file_mappings", ["id"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_gemini_file_mappings_file_name", "gemini_file_mappings", ["file_name"], unique=True
|
||||||
|
)
|
||||||
|
op.create_index("ix_gemini_file_mappings_key_id", "gemini_file_mappings", ["key_id"])
|
||||||
|
op.create_index("ix_gemini_file_mappings_user_id", "gemini_file_mappings", ["user_id"])
|
||||||
|
op.create_index("idx_gemini_file_mappings_expires", "gemini_file_mappings", ["expires_at"])
|
||||||
|
op.create_index(
|
||||||
|
"idx_gemini_file_mappings_source_hash", "gemini_file_mappings", ["source_hash"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 表已存在,只添加 source_hash
|
||||||
|
if not column_exists("gemini_file_mappings", "source_hash"):
|
||||||
|
op.add_column(
|
||||||
|
"gemini_file_mappings",
|
||||||
|
sa.Column("source_hash", sa.String(64), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_gemini_file_mappings_source_hash",
|
||||||
|
"gemini_file_mappings",
|
||||||
|
["source_hash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. providers 表: enable_format_conversion
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("providers") and not column_exists("providers", "enable_format_conversion"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column(
|
||||||
|
"enable_format_conversion",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 5. request_candidates 表: created_at 索引
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("request_candidates"):
|
||||||
|
if not index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_request_candidates_created_at",
|
||||||
|
"request_candidates",
|
||||||
|
["created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
dialect = bind.dialect.name
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 5. request_candidates 表回滚
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("request_candidates"):
|
||||||
|
if index_exists("request_candidates", "idx_request_candidates_created_at"):
|
||||||
|
op.drop_index("idx_request_candidates_created_at", table_name="request_candidates")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 4. providers 表回滚
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("providers") and column_exists("providers", "enable_format_conversion"):
|
||||||
|
op.drop_column("providers", "enable_format_conversion")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 3. gemini_file_mappings 表回滚
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("gemini_file_mappings"):
|
||||||
|
op.drop_index("idx_gemini_file_mappings_source_hash", table_name="gemini_file_mappings")
|
||||||
|
op.drop_index("idx_gemini_file_mappings_expires", table_name="gemini_file_mappings")
|
||||||
|
op.drop_index("ix_gemini_file_mappings_user_id", table_name="gemini_file_mappings")
|
||||||
|
op.drop_index("ix_gemini_file_mappings_key_id", table_name="gemini_file_mappings")
|
||||||
|
op.drop_index("ix_gemini_file_mappings_file_name", table_name="gemini_file_mappings")
|
||||||
|
op.drop_index("ix_gemini_file_mappings_id", table_name="gemini_file_mappings")
|
||||||
|
op.drop_table("gemini_file_mappings")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 2. video_tasks 表回滚
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("video_tasks"):
|
||||||
|
# short_id
|
||||||
|
if column_exists("video_tasks", "short_id"):
|
||||||
|
if index_exists("video_tasks", "ix_video_tasks_short_id"):
|
||||||
|
op.drop_index("ix_video_tasks_short_id", table_name="video_tasks")
|
||||||
|
op.drop_column("video_tasks", "short_id")
|
||||||
|
|
||||||
|
# request_id
|
||||||
|
if column_exists("video_tasks", "request_id"):
|
||||||
|
if dialect == "postgresql":
|
||||||
|
if unique_constraint_exists("video_tasks", "uq_video_tasks_request_id"):
|
||||||
|
op.drop_constraint("uq_video_tasks_request_id", "video_tasks", type_="unique")
|
||||||
|
if index_exists("video_tasks", "idx_video_tasks_request_id"):
|
||||||
|
op.drop_index("idx_video_tasks_request_id", table_name="video_tasks")
|
||||||
|
op.drop_column("video_tasks", "request_id")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 1. usage 表回滚
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("usage"):
|
||||||
|
if index_exists("usage", "idx_usage_model_created"):
|
||||||
|
op.drop_index("idx_usage_model_created", table_name="usage")
|
||||||
|
if index_exists("usage", "idx_usage_provider_created"):
|
||||||
|
op.drop_index("idx_usage_provider_created", table_name="usage")
|
||||||
|
if index_exists("usage", "idx_usage_billing_status"):
|
||||||
|
op.drop_index("idx_usage_billing_status", table_name="usage")
|
||||||
|
if column_exists("usage", "finalized_at"):
|
||||||
|
op.drop_column("usage", "finalized_at")
|
||||||
|
if column_exists("usage", "billing_status"):
|
||||||
|
op.drop_column("usage", "billing_status")
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
"""Add video_duration_seconds to video_tasks and body_rules to provider_endpoints
|
||||||
|
|
||||||
|
Revision ID: b3c4d5e6f7a8
|
||||||
|
Revises: a2f1b3c4d5e6
|
||||||
|
Create Date: 2026-02-03 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b3c4d5e6f7a8"
|
||||||
|
down_revision: Union[str, None] = "a2f1b3c4d5e6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
"""Check if a column exists in a table."""
|
||||||
|
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:
|
||||||
|
# 1. Add video_duration_seconds to video_tasks
|
||||||
|
if not _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("video_duration_seconds", sa.Float(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Add body_rules to provider_endpoints
|
||||||
|
# 请求体规则支持三种操作:
|
||||||
|
# - set: 设置/覆盖字段 {"action": "set", "path": "metadata", "value": {"custom": "val"}}
|
||||||
|
# - drop: 删除字段 {"action": "drop", "path": "unwanted_field"}
|
||||||
|
# - rename: 重命名字段 {"action": "rename", "from": "old_key", "to": "new_key"}
|
||||||
|
if not _column_exists("provider_endpoints", "body_rules"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_endpoints",
|
||||||
|
sa.Column("body_rules", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove body_rules from provider_endpoints
|
||||||
|
if _column_exists("provider_endpoints", "body_rules"):
|
||||||
|
op.drop_column("provider_endpoints", "body_rules")
|
||||||
|
|
||||||
|
# Remove video_duration_seconds from video_tasks
|
||||||
|
if _column_exists("video_tasks", "video_duration_seconds"):
|
||||||
|
op.drop_column("video_tasks", "video_duration_seconds")
|
||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
"""add_stats_hourly_and_daily_complete_flag
|
||||||
|
|
||||||
|
Revision ID: c4e8f9a1b2c3
|
||||||
|
Revises: b3c4d5e6f7a8
|
||||||
|
Create Date: 2026-02-04 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c4e8f9a1b2c3"
|
||||||
|
down_revision: Union[str, None] = "b3c4d5e6f7a8"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
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 _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
# Use information_schema for more reliable detection (inspector can have caching issues)
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT EXISTS ("
|
||||||
|
"SELECT 1 FROM information_schema.columns "
|
||||||
|
"WHERE table_name = :table AND column_name = :column"
|
||||||
|
")"
|
||||||
|
),
|
||||||
|
{"table": table_name, "column": column_name},
|
||||||
|
)
|
||||||
|
return bool(result.scalar())
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _table_exists("stats_daily"):
|
||||||
|
if not _column_exists("stats_daily", "is_complete"):
|
||||||
|
op.add_column(
|
||||||
|
"stats_daily",
|
||||||
|
sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
)
|
||||||
|
op.execute("UPDATE stats_daily SET is_complete = true")
|
||||||
|
if not _column_exists("stats_daily", "aggregated_at"):
|
||||||
|
op.add_column(
|
||||||
|
"stats_daily",
|
||||||
|
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists("stats_hourly"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_hourly",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("actual_total_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||||
|
sa.Column("is_complete", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("hour_utc", name="uq_stats_hourly_hour"),
|
||||||
|
)
|
||||||
|
op.create_index("idx_stats_hourly_hour", "stats_hourly", ["hour_utc"], unique=False)
|
||||||
|
|
||||||
|
if not _table_exists("stats_hourly_user"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_hourly_user",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("success_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("error_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("hour_utc", "user_id", name="uq_stats_hourly_user"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_hourly_user_hour", "stats_hourly_user", ["hour_utc"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_hourly_user_user_hour",
|
||||||
|
"stats_hourly_user",
|
||||||
|
["user_id", "hour_utc"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists("stats_hourly_model"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_hourly_model",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("model", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("avg_response_time_ms", sa.Float(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("hour_utc", "model", name="uq_stats_hourly_model"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_hourly_model_hour", "stats_hourly_model", ["hour_utc"], unique=False
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_hourly_model_model_hour",
|
||||||
|
"stats_hourly_model",
|
||||||
|
["model", "hour_utc"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists("stats_hourly_provider"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_hourly_provider",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("hour_utc", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("provider_name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("total_cost", sa.Float(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("hour_utc", "provider_name", name="uq_stats_hourly_provider"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_hourly_provider_hour",
|
||||||
|
"stats_hourly_provider",
|
||||||
|
["hour_utc"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _table_exists("stats_daily_api_key"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_daily_api_key",
|
||||||
|
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"api_key_id",
|
||||||
|
sa.String(length=36),
|
||||||
|
sa.ForeignKey("api_keys.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("success_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("error_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_cost", sa.Float(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("api_key_id", "date", name="uq_stats_daily_api_key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _table_exists("stats_daily_api_key"):
|
||||||
|
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||||
|
op.create_index("idx_stats_daily_api_key_date", "stats_daily_api_key", ["date"])
|
||||||
|
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_daily_api_key_key_date",
|
||||||
|
"stats_daily_api_key",
|
||||||
|
["api_key_id", "date"],
|
||||||
|
)
|
||||||
|
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_daily_api_key_date_requests",
|
||||||
|
"stats_daily_api_key",
|
||||||
|
["date", "total_requests"],
|
||||||
|
)
|
||||||
|
if not _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_daily_api_key_date_cost",
|
||||||
|
"stats_daily_api_key",
|
||||||
|
["date", "total_cost"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if _table_exists("usage"):
|
||||||
|
if not _column_exists("usage", "error_category"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("error_category", sa.String(length=50), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_usage_error_category", "usage", ["error_category"], unique=False)
|
||||||
|
|
||||||
|
if _table_exists("stats_daily"):
|
||||||
|
for name in (
|
||||||
|
"p50_response_time_ms",
|
||||||
|
"p90_response_time_ms",
|
||||||
|
"p99_response_time_ms",
|
||||||
|
"p50_first_byte_time_ms",
|
||||||
|
"p90_first_byte_time_ms",
|
||||||
|
"p99_first_byte_time_ms",
|
||||||
|
):
|
||||||
|
if not _column_exists("stats_daily", name):
|
||||||
|
op.add_column("stats_daily", sa.Column(name, sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
if not _table_exists("stats_daily_error"):
|
||||||
|
op.create_table(
|
||||||
|
"stats_daily_error",
|
||||||
|
sa.Column("id", sa.String(length=36), primary_key=True),
|
||||||
|
sa.Column("date", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("error_category", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("provider_name", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("model", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"date",
|
||||||
|
"error_category",
|
||||||
|
"provider_name",
|
||||||
|
"model",
|
||||||
|
name="uq_stats_daily_error",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _table_exists("stats_daily_error"):
|
||||||
|
if not _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||||
|
op.create_index("idx_stats_daily_error_date", "stats_daily_error", ["date"])
|
||||||
|
if not _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_stats_daily_error_category",
|
||||||
|
"stats_daily_error",
|
||||||
|
["date", "error_category"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _table_exists("stats_daily_error"):
|
||||||
|
if _index_exists("stats_daily_error", "idx_stats_daily_error_category"):
|
||||||
|
op.drop_index("idx_stats_daily_error_category", table_name="stats_daily_error")
|
||||||
|
if _index_exists("stats_daily_error", "idx_stats_daily_error_date"):
|
||||||
|
op.drop_index("idx_stats_daily_error_date", table_name="stats_daily_error")
|
||||||
|
op.drop_table("stats_daily_error")
|
||||||
|
|
||||||
|
if _table_exists("stats_daily"):
|
||||||
|
for name in (
|
||||||
|
"p50_response_time_ms",
|
||||||
|
"p90_response_time_ms",
|
||||||
|
"p99_response_time_ms",
|
||||||
|
"p50_first_byte_time_ms",
|
||||||
|
"p90_first_byte_time_ms",
|
||||||
|
"p99_first_byte_time_ms",
|
||||||
|
):
|
||||||
|
if _column_exists("stats_daily", name):
|
||||||
|
op.drop_column("stats_daily", name)
|
||||||
|
|
||||||
|
if _table_exists("usage") and _column_exists("usage", "error_category"):
|
||||||
|
if _index_exists("usage", "idx_usage_error_category"):
|
||||||
|
op.drop_index("idx_usage_error_category", table_name="usage")
|
||||||
|
op.drop_column("usage", "error_category")
|
||||||
|
|
||||||
|
if _table_exists("stats_daily_api_key"):
|
||||||
|
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_cost"):
|
||||||
|
op.drop_index("idx_stats_daily_api_key_date_cost", table_name="stats_daily_api_key")
|
||||||
|
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date_requests"):
|
||||||
|
op.drop_index("idx_stats_daily_api_key_date_requests", table_name="stats_daily_api_key")
|
||||||
|
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_key_date"):
|
||||||
|
op.drop_index("idx_stats_daily_api_key_key_date", table_name="stats_daily_api_key")
|
||||||
|
if _index_exists("stats_daily_api_key", "idx_stats_daily_api_key_date"):
|
||||||
|
op.drop_index("idx_stats_daily_api_key_date", table_name="stats_daily_api_key")
|
||||||
|
op.drop_table("stats_daily_api_key")
|
||||||
|
|
||||||
|
if _table_exists("stats_hourly_provider"):
|
||||||
|
if _index_exists("stats_hourly_provider", "idx_stats_hourly_provider_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_provider_hour", table_name="stats_hourly_provider")
|
||||||
|
op.drop_table("stats_hourly_provider")
|
||||||
|
|
||||||
|
if _table_exists("stats_hourly_model"):
|
||||||
|
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_model_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_model_model_hour", table_name="stats_hourly_model")
|
||||||
|
if _index_exists("stats_hourly_model", "idx_stats_hourly_model_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_model_hour", table_name="stats_hourly_model")
|
||||||
|
op.drop_table("stats_hourly_model")
|
||||||
|
|
||||||
|
if _table_exists("stats_hourly_user"):
|
||||||
|
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_user_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_user_user_hour", table_name="stats_hourly_user")
|
||||||
|
if _index_exists("stats_hourly_user", "idx_stats_hourly_user_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_user_hour", table_name="stats_hourly_user")
|
||||||
|
op.drop_table("stats_hourly_user")
|
||||||
|
|
||||||
|
if _table_exists("stats_hourly"):
|
||||||
|
if _index_exists("stats_hourly", "idx_stats_hourly_hour"):
|
||||||
|
op.drop_index("idx_stats_hourly_hour", table_name="stats_hourly")
|
||||||
|
op.drop_table("stats_hourly")
|
||||||
|
|
||||||
|
if _table_exists("stats_daily"):
|
||||||
|
if _column_exists("stats_daily", "aggregated_at"):
|
||||||
|
op.drop_column("stats_daily", "aggregated_at")
|
||||||
|
if _column_exists("stats_daily", "is_complete"):
|
||||||
|
op.drop_column("stats_daily", "is_complete")
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
"""Add provider_type, upstream_metadata, oauth_invalid fields and expand string columns to TEXT
|
||||||
|
|
||||||
|
- Add providers.provider_type (String(20), server_default="custom")
|
||||||
|
- Add provider_api_keys.upstream_metadata (JSON, nullable)
|
||||||
|
- Add provider_api_keys.oauth_invalid_at (DateTime, nullable) - OAuth Token 失效时间
|
||||||
|
- Add provider_api_keys.oauth_invalid_reason (String(255), nullable) - OAuth Token 失效原因
|
||||||
|
- Expand multiple VARCHAR columns to TEXT for long values (OAuth tokens, LDAP DN, URLs, etc.)
|
||||||
|
|
||||||
|
Revision ID: b5c6d7e8f9a0
|
||||||
|
Revises: c4e8f9a1b2c3
|
||||||
|
Create Date: 2026-02-04 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b5c6d7e8f9a0"
|
||||||
|
down_revision: Union[str, None] = "c4e8f9a1b2c3"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
# 需要扩展为 TEXT 的列(表名, 列名, 原始类型长度)
|
||||||
|
COLUMNS_TO_EXPAND = [
|
||||||
|
("provider_api_keys", "api_key", 500), # OAuth tokens can be very long
|
||||||
|
(
|
||||||
|
"provider_api_keys",
|
||||||
|
"auth_config",
|
||||||
|
None,
|
||||||
|
), # 确保 auth_config 是 TEXT 类型(可能从 JSON 迁移过来)
|
||||||
|
("ldap_configs", "bind_dn", 255), # LDAP DN can be deeply nested
|
||||||
|
("ldap_configs", "base_dn", 255), # LDAP DN can be deeply nested
|
||||||
|
("ldap_configs", "user_search_filter", 500), # Complex LDAP filters
|
||||||
|
("oauth_providers", "client_id", 255), # Some OAuth providers use JWT client_id
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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 table_exists(table_name: str) -> bool:
|
||||||
|
"""检查表是否存在"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def is_sqlite() -> bool:
|
||||||
|
"""检查是否为 SQLite 数据库"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
return bind.dialect.name == "sqlite"
|
||||||
|
|
||||||
|
|
||||||
|
def get_column_type(table_name: str, column_name: str) -> str | None:
|
||||||
|
"""获取列的数据类型"""
|
||||||
|
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 None
|
||||||
|
|
||||||
|
|
||||||
|
def expand_column_to_text(table_name: str, column_name: str, original_length: int | None) -> None:
|
||||||
|
"""将 VARCHAR 列扩展为 TEXT(兼容 SQLite)"""
|
||||||
|
if not table_exists(table_name):
|
||||||
|
return
|
||||||
|
if not column_exists(table_name, column_name):
|
||||||
|
return
|
||||||
|
|
||||||
|
# 检查当前列类型,如果已经是 TEXT 则跳过
|
||||||
|
col_type = get_column_type(table_name, column_name)
|
||||||
|
if col_type and "TEXT" in col_type:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 如果是 JSON 类型(可能是历史遗留),先将 JSON 数据转为文本表示再变更类型
|
||||||
|
is_json_col = col_type and "JSON" in col_type
|
||||||
|
|
||||||
|
if is_json_col and not is_sqlite():
|
||||||
|
# PostgreSQL: 先用 CAST 把 JSON 值转为 TEXT,保留数据
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
|
||||||
|
f"TYPE TEXT USING {column_name}::TEXT"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_sqlite():
|
||||||
|
# SQLite 不支持直接 ALTER COLUMN,需要用 batch 模式
|
||||||
|
# batch 模式会自动处理 JSON->TEXT 的数据迁移
|
||||||
|
with op.batch_alter_table(table_name) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
column_name,
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.alter_column(
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shrink_column_to_varchar(
|
||||||
|
table_name: str, column_name: str, target_length: int, nullable: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""将 TEXT 列缩小为 VARCHAR(兼容 SQLite)
|
||||||
|
WARNING: 如果数据超过 target_length 会失败
|
||||||
|
"""
|
||||||
|
if not table_exists(table_name):
|
||||||
|
return
|
||||||
|
if not column_exists(table_name, column_name):
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_sqlite():
|
||||||
|
with op.batch_alter_table(table_name) as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
column_name,
|
||||||
|
type_=sa.String(target_length),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
existing_nullable=nullable,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
op.alter_column(
|
||||||
|
table_name,
|
||||||
|
column_name,
|
||||||
|
type_=sa.String(target_length),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
existing_nullable=nullable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add providers.provider_type
|
||||||
|
if not column_exists("providers", "provider_type"):
|
||||||
|
op.add_column(
|
||||||
|
"providers",
|
||||||
|
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add provider_api_keys.upstream_metadata
|
||||||
|
if not column_exists("provider_api_keys", "upstream_metadata"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("upstream_metadata", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add provider_api_keys.oauth_invalid_at
|
||||||
|
if not column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("oauth_invalid_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add provider_api_keys.oauth_invalid_reason
|
||||||
|
if not column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("oauth_invalid_reason", sa.String(255), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expand VARCHAR columns to TEXT
|
||||||
|
for table_name, column_name, original_length in COLUMNS_TO_EXPAND:
|
||||||
|
expand_column_to_text(table_name, column_name, original_length)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Shrink TEXT columns back to VARCHAR
|
||||||
|
# WARNING: Downgrade may fail if any values exceed original length
|
||||||
|
for table_name, column_name, original_length in reversed(COLUMNS_TO_EXPAND):
|
||||||
|
# 跳过没有原始长度的列(如 auth_config,由其他迁移创建)
|
||||||
|
if original_length is None:
|
||||||
|
continue
|
||||||
|
shrink_column_to_varchar(table_name, column_name, original_length)
|
||||||
|
|
||||||
|
# Drop provider_api_keys.oauth_invalid_reason
|
||||||
|
if column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||||
|
op.drop_column("provider_api_keys", "oauth_invalid_reason")
|
||||||
|
|
||||||
|
# Drop provider_api_keys.oauth_invalid_at
|
||||||
|
if column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||||
|
op.drop_column("provider_api_keys", "oauth_invalid_at")
|
||||||
|
|
||||||
|
# Drop provider_api_keys.upstream_metadata
|
||||||
|
if column_exists("provider_api_keys", "upstream_metadata"):
|
||||||
|
op.drop_column("provider_api_keys", "upstream_metadata")
|
||||||
|
|
||||||
|
# Drop providers.provider_type
|
||||||
|
if column_exists("providers", "provider_type"):
|
||||||
|
op.drop_column("providers", "provider_type")
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
"""Antigravity endpoint signature to gemini:chat & add proxy_nodes table (with manual fields)
|
||||||
|
|
||||||
|
Revision ID: e1b2c3d4f5a6
|
||||||
|
Revises: b5c6d7e8f9a0
|
||||||
|
Create Date: 2026-02-06 23:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "e1b2c3d4f5a6"
|
||||||
|
down_revision: str | None = "b5c6d7e8f9a0"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
return table_name in inspector.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Part 1: Antigravity endpoint signature migration (gemini:cli -> gemini:chat)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# --- provider_endpoints ---
|
||||||
|
# Update only when there is no conflicting gemini:chat endpoint for the same provider
|
||||||
|
# (provider_endpoints has a unique constraint on (provider_id, api_format)).
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_endpoints pe
|
||||||
|
SET
|
||||||
|
api_format = 'gemini:chat',
|
||||||
|
api_family = 'gemini',
|
||||||
|
endpoint_kind = 'chat'
|
||||||
|
WHERE pe.api_format = 'gemini:cli'
|
||||||
|
AND pe.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM provider_endpoints pe2
|
||||||
|
WHERE pe2.provider_id = pe.provider_id
|
||||||
|
AND pe2.api_format = 'gemini:chat'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Best-effort normalization for already-existing Antigravity gemini:chat endpoints.
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_endpoints pe
|
||||||
|
SET
|
||||||
|
api_family = 'gemini',
|
||||||
|
endpoint_kind = 'chat'
|
||||||
|
WHERE pe.api_format = 'gemini:chat'
|
||||||
|
AND pe.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# --- provider_api_keys.api_formats (JSON array) ---
|
||||||
|
# Replace "gemini:cli" with "gemini:chat" in the JSON array for Antigravity keys.
|
||||||
|
# Uses text-level replace on the serialized JSON -- safe because the value is a
|
||||||
|
# simple string with no special characters that could cause ambiguous replacements.
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_api_keys pak
|
||||||
|
SET api_formats = replace(pak.api_formats::text, '"gemini:cli"', '"gemini:chat"')::json
|
||||||
|
WHERE pak.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
AND pak.api_formats IS NOT NULL
|
||||||
|
AND pak.api_formats::text LIKE '%"gemini:cli"%'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Part 2: Create proxy_nodes table with manual proxy fields (idempotent)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# Create ENUM type (idempotent)
|
||||||
|
op.execute(
|
||||||
|
"DO $$ BEGIN "
|
||||||
|
"CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline'); "
|
||||||
|
"EXCEPTION WHEN duplicate_object THEN NULL; "
|
||||||
|
"END $$"
|
||||||
|
)
|
||||||
|
|
||||||
|
if table_exists("proxy_nodes"):
|
||||||
|
# Table already exists — ensure manual proxy columns are present
|
||||||
|
inspector = inspect(conn)
|
||||||
|
existing_columns = {c["name"] for c in inspector.get_columns("proxy_nodes")}
|
||||||
|
|
||||||
|
# ip 列扩容:手动节点的 ip 存储 "socks5://hostname" 形式,45 字符可能不够
|
||||||
|
ip_col = next((c for c in inspector.get_columns("proxy_nodes") if c["name"] == "ip"), None)
|
||||||
|
if ip_col and hasattr(ip_col["type"], "length") and (ip_col["type"].length or 0) < 512:
|
||||||
|
op.alter_column("proxy_nodes", "ip", type_=sa.String(512), existing_nullable=False)
|
||||||
|
|
||||||
|
manual_columns = [
|
||||||
|
("is_manual", sa.Boolean(), False, sa.text("false"), "是否为手动添加的代理节点"),
|
||||||
|
("proxy_url", sa.String(500), True, None, "手动节点的完整代理 URL"),
|
||||||
|
("proxy_username", sa.String(255), True, None, "手动节点的代理用户名"),
|
||||||
|
("proxy_password", sa.String(500), True, None, "手动节点的代理密码"),
|
||||||
|
]
|
||||||
|
for col_name, col_type, nullable, default, comment in manual_columns:
|
||||||
|
if col_name not in existing_columns:
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
col_name,
|
||||||
|
col_type, # type: ignore[arg-type]
|
||||||
|
nullable=nullable,
|
||||||
|
server_default=default,
|
||||||
|
comment=comment,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("ip", sa.String(512), nullable=False),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("region", sa.String(100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"status",
|
||||||
|
postgresql.ENUM(
|
||||||
|
"online",
|
||||||
|
"unhealthy",
|
||||||
|
"offline",
|
||||||
|
name="proxynodestatus",
|
||||||
|
create_type=False,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("'online'"),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"registered_by",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("heartbeat_interval", sa.Integer(), nullable=False, server_default=sa.text("30")),
|
||||||
|
sa.Column("active_connections", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("total_requests", sa.BigInteger(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("avg_latency_ms", sa.Float(), nullable=True),
|
||||||
|
# --- Manual proxy node fields ---
|
||||||
|
sa.Column(
|
||||||
|
"is_manual",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
comment="是否为手动添加的代理节点",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_url",
|
||||||
|
sa.String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的完整代理 URL",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_username",
|
||||||
|
sa.String(255),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的代理用户名",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"proxy_password",
|
||||||
|
sa.String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="手动节点的代理密码",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("ip", "port", name="uq_proxy_node_ip_port"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Part 2 rollback: Drop proxy_nodes table (and manual columns if present)
|
||||||
|
# =========================================================================
|
||||||
|
if table_exists("proxy_nodes"):
|
||||||
|
op.drop_table("proxy_nodes")
|
||||||
|
|
||||||
|
# Best-effort: drop type (only used by proxy_nodes)
|
||||||
|
op.execute("DROP TYPE IF EXISTS proxynodestatus")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Part 1 rollback: Revert Antigravity endpoint signature (gemini:chat -> gemini:cli)
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
# --- provider_endpoints ---
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_endpoints pe
|
||||||
|
SET
|
||||||
|
api_format = 'gemini:cli',
|
||||||
|
api_family = 'gemini',
|
||||||
|
endpoint_kind = 'cli'
|
||||||
|
WHERE pe.api_format = 'gemini:chat'
|
||||||
|
AND pe.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM provider_endpoints pe2
|
||||||
|
WHERE pe2.provider_id = pe.provider_id
|
||||||
|
AND pe2.api_format = 'gemini:cli'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Best-effort normalization for already-existing Antigravity gemini:cli endpoints.
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_endpoints pe
|
||||||
|
SET
|
||||||
|
api_family = 'gemini',
|
||||||
|
endpoint_kind = 'cli'
|
||||||
|
WHERE pe.api_format = 'gemini:cli'
|
||||||
|
AND pe.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# --- provider_api_keys.api_formats (JSON array) ---
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE provider_api_keys pak
|
||||||
|
SET api_formats = replace(pak.api_formats::text, '"gemini:chat"', '"gemini:cli"')::json
|
||||||
|
WHERE pak.provider_id IN (
|
||||||
|
SELECT id FROM providers WHERE lower(provider_type) = 'antigravity'
|
||||||
|
)
|
||||||
|
AND pak.api_formats IS NOT NULL
|
||||||
|
AND pak.api_formats::text LIKE '%"gemini:chat"%'
|
||||||
|
"""))
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add remote_config and config_version to proxy_nodes
|
||||||
|
|
||||||
|
Revision ID: 3aff3ffc4a0e
|
||||||
|
Revises: e1b2c3d4f5a6
|
||||||
|
Create Date: 2026-02-07 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "3aff3ffc4a0e"
|
||||||
|
down_revision: str | None = "e1b2c3d4f5a6"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("proxy_nodes", "remote_config"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"remote_config",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="管理端下发的远程配置 (allowed_ports, log_level, heartbeat_interval, timestamp_tolerance)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists("proxy_nodes", "config_version"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"config_version",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="远程配置版本号,每次更新 +1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("proxy_nodes", "config_version"):
|
||||||
|
op.drop_column("proxy_nodes", "config_version")
|
||||||
|
if column_exists("proxy_nodes", "remote_config"):
|
||||||
|
op.drop_column("proxy_nodes", "remote_config")
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
"""Add tls_enabled and tls_cert_fingerprint to proxy_nodes
|
||||||
|
|
||||||
|
Revision ID: 4b5c6d7e8f9a
|
||||||
|
Revises: 3aff3ffc4a0e
|
||||||
|
Create Date: 2026-02-07 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "4b5c6d7e8f9a"
|
||||||
|
down_revision: str | None = "3aff3ffc4a0e"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="false",
|
||||||
|
comment="是否启用 TLS 加密",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_cert_fingerprint",
|
||||||
|
sa.String(128),
|
||||||
|
nullable=True,
|
||||||
|
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||||
|
if column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_enabled")
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
"""Add hardware_info and estimated_max_concurrency to proxy_nodes
|
||||||
|
|
||||||
|
Revision ID: 5c6d7e8f9a0b
|
||||||
|
Revises: 4b5c6d7e8f9a
|
||||||
|
Create Date: 2026-02-08 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "5c6d7e8f9a0b"
|
||||||
|
down_revision: str | None = "4b5c6d7e8f9a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("proxy_nodes", "hardware_info"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"hardware_info",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"estimated_max_concurrency",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=True,
|
||||||
|
comment="基于硬件估算的最大并发连接数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("proxy_nodes", "estimated_max_concurrency"):
|
||||||
|
op.drop_column("proxy_nodes", "estimated_max_concurrency")
|
||||||
|
if column_exists("proxy_nodes", "hardware_info"):
|
||||||
|
op.drop_column("proxy_nodes", "hardware_info")
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add proxy column to provider_api_keys for per-key proxy configuration
|
||||||
|
|
||||||
|
Revision ID: 6d7e8f9a0b1c
|
||||||
|
Revises: 5c6d7e8f9a0b
|
||||||
|
Create Date: 2026-02-08 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "6d7e8f9a0b1c"
|
||||||
|
down_revision: str | None = "5c6d7e8f9a0b"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "proxy"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"proxy",
|
||||||
|
sa.JSON(),
|
||||||
|
nullable=True,
|
||||||
|
comment="Key 级别代理配置(覆盖 Provider 级别代理),如 {node_id, enabled}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "proxy"):
|
||||||
|
op.drop_column("provider_api_keys", "proxy")
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
"""Add provider_request_body and client_response_body columns to usage table
|
||||||
|
|
||||||
|
Revision ID: 7e8f9a0b1c2d
|
||||||
|
Revises: 6d7e8f9a0b1c
|
||||||
|
Create Date: 2026-02-20 18:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7e8f9a0b1c2d"
|
||||||
|
down_revision: str | None = "6d7e8f9a0b1c"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
# Use PostgreSQL native IF NOT EXISTS to avoid duplicate-column races
|
||||||
|
# when migrations are triggered concurrently (e.g. startup + manual run).
|
||||||
|
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body JSON"))
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS provider_request_body_compressed BYTEA")
|
||||||
|
)
|
||||||
|
conn.execute(text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body JSON"))
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE usage ADD COLUMN IF NOT EXISTS client_response_body_compressed BYTEA")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for col in (
|
||||||
|
"client_response_body_compressed",
|
||||||
|
"client_response_body",
|
||||||
|
"provider_request_body_compressed",
|
||||||
|
"provider_request_body",
|
||||||
|
):
|
||||||
|
conn.execute(text(f"ALTER TABLE usage DROP COLUMN IF EXISTS {col}"))
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
"""Add api_family and endpoint_kind columns to usage table
|
||||||
|
|
||||||
|
Revision ID: 8f9a0b1c2d3e
|
||||||
|
Revises: 7e8f9a0b1c2d
|
||||||
|
Create Date: 2026-02-21 15:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8f9a0b1c2d3e"
|
||||||
|
down_revision: str | None = "7e8f9a0b1c2d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# Usage 表新增列
|
||||||
|
NEW_COLUMNS = [
|
||||||
|
("api_family", sa.String(50)),
|
||||||
|
("endpoint_kind", sa.String(50)),
|
||||||
|
("provider_api_family", sa.String(50)),
|
||||||
|
("provider_endpoint_kind", sa.String(50)),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 新增索引
|
||||||
|
NEW_INDEXES = [
|
||||||
|
("idx_usage_api_family", "usage", ["api_family"]),
|
||||||
|
("idx_usage_endpoint_kind", "usage", ["endpoint_kind"]),
|
||||||
|
("idx_usage_family_kind", "usage", ["api_family", "endpoint_kind"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 使用 PostgreSQL 原生 IF NOT EXISTS,比 inspect 更可靠(避免同一事务内缓存问题)
|
||||||
|
col_definitions = {
|
||||||
|
"api_family": "VARCHAR(50)",
|
||||||
|
"endpoint_kind": "VARCHAR(50)",
|
||||||
|
"provider_api_family": "VARCHAR(50)",
|
||||||
|
"provider_endpoint_kind": "VARCHAR(50)",
|
||||||
|
}
|
||||||
|
for col_name, col_type_sql in col_definitions.items():
|
||||||
|
conn.execute(text(f"ALTER TABLE usage ADD COLUMN IF NOT EXISTS {col_name} {col_type_sql}"))
|
||||||
|
|
||||||
|
# 数据迁移:从 api_format 解析 api_family + endpoint_kind
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE usage SET
|
||||||
|
api_family = lower(split_part(api_format, ':', 1)),
|
||||||
|
endpoint_kind = lower(split_part(api_format, ':', 2))
|
||||||
|
WHERE api_format IS NOT NULL
|
||||||
|
AND api_format LIKE '%%:%%'
|
||||||
|
AND api_family IS NULL
|
||||||
|
"""))
|
||||||
|
conn.execute(text("""
|
||||||
|
UPDATE usage SET
|
||||||
|
provider_api_family = lower(split_part(endpoint_api_format, ':', 1)),
|
||||||
|
provider_endpoint_kind = lower(split_part(endpoint_api_format, ':', 2))
|
||||||
|
WHERE endpoint_api_format IS NOT NULL
|
||||||
|
AND endpoint_api_format LIKE '%%:%%'
|
||||||
|
AND provider_api_family IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# 创建索引
|
||||||
|
inspector = inspect(conn)
|
||||||
|
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
|
||||||
|
for idx_name, table, columns in NEW_INDEXES:
|
||||||
|
if idx_name not in existing_indexes:
|
||||||
|
op.create_index(idx_name, table, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
|
||||||
|
existing_indexes = {idx["name"] for idx in inspector.get_indexes("usage")}
|
||||||
|
for idx_name, _, _ in reversed(NEW_INDEXES):
|
||||||
|
if idx_name in existing_indexes:
|
||||||
|
op.drop_index(idx_name, table_name="usage")
|
||||||
|
|
||||||
|
existing_columns = {col["name"] for col in inspector.get_columns("usage")}
|
||||||
|
for col_name, _ in reversed(NEW_COLUMNS):
|
||||||
|
if col_name in existing_columns:
|
||||||
|
op.drop_column("usage", col_name)
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
"""Add tunnel mode fields and remove IP forwarding fields
|
||||||
|
|
||||||
|
Revision ID: 9a0b1c2d3e4f
|
||||||
|
Revises: 8f9a0b1c2d3e
|
||||||
|
Create Date: 2026-02-24 17:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "9a0b1c2d3e4f"
|
||||||
|
down_revision: str | None = "8f9a0b1c2d3e"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 添加 tunnel 模式字段
|
||||||
|
if not column_exists("proxy_nodes", "tunnel_mode"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tunnel_mode",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
comment="是否使用 WebSocket 隧道模式",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not column_exists("proxy_nodes", "tunnel_connected"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tunnel_connected",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
comment="隧道是否已连接",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not column_exists("proxy_nodes", "tunnel_connected_at"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tunnel_connected_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
comment="隧道最近一次建立时间",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# tunnel 模式节点不需要 port,将其置零
|
||||||
|
op.execute("UPDATE proxy_nodes SET port = 0 WHERE tunnel_mode = true")
|
||||||
|
|
||||||
|
# 移除旧的 IP 转发字段
|
||||||
|
if column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_enabled")
|
||||||
|
if column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.drop_column("proxy_nodes", "tls_cert_fingerprint")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复 IP 转发字段
|
||||||
|
if not column_exists("proxy_nodes", "tls_cert_fingerprint"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_cert_fingerprint",
|
||||||
|
sa.String(128),
|
||||||
|
nullable=True,
|
||||||
|
comment="TLS 证书 SHA-256 指纹(hex)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not column_exists("proxy_nodes", "tls_enabled"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"tls_enabled",
|
||||||
|
sa.Boolean(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("false"),
|
||||||
|
comment="是否启用 TLS 加密",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 移除 tunnel 模式字段
|
||||||
|
if column_exists("proxy_nodes", "tunnel_connected_at"):
|
||||||
|
op.drop_column("proxy_nodes", "tunnel_connected_at")
|
||||||
|
if column_exists("proxy_nodes", "tunnel_connected"):
|
||||||
|
op.drop_column("proxy_nodes", "tunnel_connected")
|
||||||
|
if column_exists("proxy_nodes", "tunnel_mode"):
|
||||||
|
op.drop_column("proxy_nodes", "tunnel_mode")
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
"""Add cache_creation columns, clean up capability settings, add user_model_usage_counts,
|
||||||
|
enforce global_model_id NOT NULL
|
||||||
|
|
||||||
|
1. Add cache_creation_input_tokens_5m and cache_creation_input_tokens_1h to usage table.
|
||||||
|
2. Clean up cache_1h/context_1m/gemini_files from user-configurable settings
|
||||||
|
(now auto-detected via REQUEST_PARAM mode).
|
||||||
|
3. Create user_model_usage_counts table for per-user per-model atomic usage counters.
|
||||||
|
4. Enforce models.global_model_id NOT NULL (delete orphan models without global model).
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: 9a0b1c2d3e4f
|
||||||
|
Create Date: 2026-02-28 14:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "b2c3d4e5f6a7"
|
||||||
|
down_revision: str | None = "9a0b1c2d3e4f"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
columns = [c["name"] for c in insp.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# --- 1. Add cache_creation columns ---
|
||||||
|
if not column_exists("usage", "cache_creation_input_tokens_5m"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column(
|
||||||
|
"cache_creation_input_tokens_5m",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("0"),
|
||||||
|
comment="5min TTL cache creation input tokens",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not column_exists("usage", "cache_creation_input_tokens_1h"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column(
|
||||||
|
"cache_creation_input_tokens_1h",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.text("0"),
|
||||||
|
comment="1h TTL cache creation input tokens",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 2. Clean up stale capability settings (pure Python, DB-agnostic) ---
|
||||||
|
stale_keys = {"cache_1h", "context_1m", "gemini_files"}
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# ApiKey.force_capabilities: dict-like JSON, remove stale keys
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text("SELECT id, force_capabilities FROM api_keys WHERE force_capabilities IS NOT NULL")
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
raw = row[1]
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
|
cleaned = {k: v for k, v in data.items() if k not in stale_keys}
|
||||||
|
new_val = json.dumps(cleaned) if cleaned else None
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE api_keys SET force_capabilities = :val WHERE id = :id"),
|
||||||
|
{"val": new_val, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# User.model_capability_settings: nested dict {model_key: {cap: val}}, remove stale keys
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, model_capability_settings FROM users"
|
||||||
|
" WHERE model_capability_settings IS NOT NULL"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
raw = row[1]
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
data = raw if isinstance(raw, dict) else json.loads(raw)
|
||||||
|
cleaned = {}
|
||||||
|
for model_key, caps in data.items():
|
||||||
|
cap_cleaned = {k: v for k, v in caps.items() if k not in stale_keys}
|
||||||
|
if cap_cleaned:
|
||||||
|
cleaned[model_key] = cap_cleaned
|
||||||
|
new_val = json.dumps(cleaned) if cleaned else None
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE users SET model_capability_settings = :val WHERE id = :id"),
|
||||||
|
{"val": new_val, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# GlobalModel.supported_capabilities: JSON array, remove stale entries
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, supported_capabilities FROM global_models"
|
||||||
|
" WHERE supported_capabilities IS NOT NULL"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
raw = row[1]
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
data = raw if isinstance(raw, list) else json.loads(raw)
|
||||||
|
cleaned = [c for c in data if c not in stale_keys]
|
||||||
|
new_val = json.dumps(cleaned) if cleaned else None
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE global_models SET supported_capabilities = :val WHERE id = :id"),
|
||||||
|
{"val": new_val, "id": row[0]},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 3. Create user_model_usage_counts table ---
|
||||||
|
if not table_exists("user_model_usage_counts"):
|
||||||
|
op.create_table(
|
||||||
|
"user_model_usage_counts",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("model", sa.String(100), nullable=False),
|
||||||
|
sa.Column("usage_count", sa.Integer, nullable=False, server_default="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(),
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "model", name="uq_user_model_usage_count"),
|
||||||
|
)
|
||||||
|
if not index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
|
||||||
|
op.create_index("idx_user_model_usage_user", "user_model_usage_counts", ["user_id"])
|
||||||
|
if not index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
|
||||||
|
op.create_index("idx_user_model_usage_model", "user_model_usage_counts", ["model"])
|
||||||
|
|
||||||
|
# Backfill from existing usage records (truncate first for idempotency)
|
||||||
|
conn.execute(sa.text("DELETE FROM user_model_usage_counts"))
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT user_id, model, COUNT(*) AS cnt FROM usage"
|
||||||
|
" WHERE user_id IS NOT NULL GROUP BY user_id, model"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO user_model_usage_counts"
|
||||||
|
" (id, user_id, model, usage_count, created_at, updated_at)"
|
||||||
|
" VALUES (:id, :user_id, :model, :cnt, :now, :now)"
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"user_id": row[0],
|
||||||
|
"model": row[1],
|
||||||
|
"cnt": row[2],
|
||||||
|
"now": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 4. Enforce models.global_model_id NOT NULL ---
|
||||||
|
conn = op.get_bind()
|
||||||
|
insp = inspect(conn)
|
||||||
|
model_cols = {c["name"]: c for c in insp.get_columns("models")}
|
||||||
|
if model_cols.get("global_model_id", {}).get("nullable", True):
|
||||||
|
op.execute("DELETE FROM models WHERE global_model_id IS NULL")
|
||||||
|
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Revert models.global_model_id to nullable
|
||||||
|
if column_exists("models", "global_model_id"):
|
||||||
|
op.alter_column("models", "global_model_id", existing_type=sa.String(36), nullable=True)
|
||||||
|
|
||||||
|
# Drop user_model_usage_counts
|
||||||
|
if table_exists("user_model_usage_counts"):
|
||||||
|
if index_exists("user_model_usage_counts", "idx_user_model_usage_model"):
|
||||||
|
op.drop_index("idx_user_model_usage_model", table_name="user_model_usage_counts")
|
||||||
|
if index_exists("user_model_usage_counts", "idx_user_model_usage_user"):
|
||||||
|
op.drop_index("idx_user_model_usage_user", table_name="user_model_usage_counts")
|
||||||
|
op.drop_table("user_model_usage_counts")
|
||||||
|
|
||||||
|
# Drop cache_creation columns
|
||||||
|
if column_exists("usage", "cache_creation_input_tokens_1h"):
|
||||||
|
op.drop_column("usage", "cache_creation_input_tokens_1h")
|
||||||
|
if column_exists("usage", "cache_creation_input_tokens_5m"):
|
||||||
|
op.drop_column("usage", "cache_creation_input_tokens_5m")
|
||||||
|
# capability settings cleanup is not reversible
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
"""proxy_node_metrics_and_events
|
||||||
|
|
||||||
|
Revision ID: 48afe197cc15
|
||||||
|
Revises: b2c3d4e5f6a7
|
||||||
|
Create Date: 2026-02-28 04:33:11.201185+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "48afe197cc15"
|
||||||
|
down_revision = "b2c3d4e5f6a7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
columns = [c["name"] for c in insp.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _enum_has_value(enum_name: str, value: str) -> bool:
|
||||||
|
"""检查 PostgreSQL 枚举类型是否包含指定值"""
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_enum e JOIN pg_type t ON e.enumtypid = t.oid"
|
||||||
|
" WHERE t.typname = :enum_name AND e.enumlabel = :value"
|
||||||
|
),
|
||||||
|
{"enum_name": enum_name, "value": value},
|
||||||
|
)
|
||||||
|
return result.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# proxy_nodes: 将已废弃的 unhealthy 状态迁移为 offline,然后从枚举中移除
|
||||||
|
if _enum_has_value("proxynodestatus", "unhealthy"):
|
||||||
|
op.execute("UPDATE proxy_nodes SET status = 'offline' WHERE status = 'unhealthy'")
|
||||||
|
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
|
||||||
|
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'offline')")
|
||||||
|
# 必须先移除旧枚举类型的 DEFAULT,否则 ALTER TYPE 会因无法转换默认值而报错
|
||||||
|
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
|
||||||
|
" USING status::text::proxynodestatus"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
|
||||||
|
op.execute("DROP TYPE proxynodestatus_old")
|
||||||
|
|
||||||
|
# proxy_nodes: 新增错误指标字段
|
||||||
|
if not _column_exists("proxy_nodes", "failed_requests"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"failed_requests",
|
||||||
|
sa.BigInteger(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="累计失败请求数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not _column_exists("proxy_nodes", "dns_failures"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"dns_failures",
|
||||||
|
sa.BigInteger(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="累计 DNS 失败数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not _column_exists("proxy_nodes", "stream_errors"):
|
||||||
|
op.add_column(
|
||||||
|
"proxy_nodes",
|
||||||
|
sa.Column(
|
||||||
|
"stream_errors",
|
||||||
|
sa.BigInteger(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="累计流错误数",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# proxy_node_events: 连接事件表
|
||||||
|
if not _table_exists("proxy_node_events"):
|
||||||
|
op.create_table(
|
||||||
|
"proxy_node_events",
|
||||||
|
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("node_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"event_type",
|
||||||
|
sa.String(length=20),
|
||||||
|
nullable=False,
|
||||||
|
comment="事件类型: connected, disconnected, error",
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"detail",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=True,
|
||||||
|
comment="事件详情(如断开原因)",
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["node_id"], ["proxy_nodes.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_proxy_node_events_node_created",
|
||||||
|
"proxy_node_events",
|
||||||
|
["node_id", "created_at"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_proxy_node_events_node_id"),
|
||||||
|
"proxy_node_events",
|
||||||
|
["node_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复 proxynodestatus 枚举,加回 unhealthy
|
||||||
|
if not _enum_has_value("proxynodestatus", "unhealthy"):
|
||||||
|
op.execute("ALTER TYPE proxynodestatus RENAME TO proxynodestatus_old")
|
||||||
|
op.execute("CREATE TYPE proxynodestatus AS ENUM ('online', 'unhealthy', 'offline')")
|
||||||
|
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status DROP DEFAULT")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE proxy_nodes ALTER COLUMN status TYPE proxynodestatus"
|
||||||
|
" USING status::text::proxynodestatus"
|
||||||
|
)
|
||||||
|
op.execute("ALTER TABLE proxy_nodes ALTER COLUMN status SET DEFAULT 'online'::proxynodestatus")
|
||||||
|
op.execute("DROP TYPE proxynodestatus_old")
|
||||||
|
|
||||||
|
if _table_exists("proxy_node_events"):
|
||||||
|
op.drop_index(op.f("ix_proxy_node_events_node_id"), table_name="proxy_node_events")
|
||||||
|
op.drop_index("idx_proxy_node_events_node_created", table_name="proxy_node_events")
|
||||||
|
op.drop_table("proxy_node_events")
|
||||||
|
if _column_exists("proxy_nodes", "stream_errors"):
|
||||||
|
op.drop_column("proxy_nodes", "stream_errors")
|
||||||
|
if _column_exists("proxy_nodes", "dns_failures"):
|
||||||
|
op.drop_column("proxy_nodes", "dns_failures")
|
||||||
|
if _column_exists("proxy_nodes", "failed_requests"):
|
||||||
|
op.drop_column("proxy_nodes", "failed_requests")
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
"""add_request_candidates_composite_indexes
|
||||||
|
|
||||||
|
Revision ID: 00b9161b8729
|
||||||
|
Revises: 48afe197cc15
|
||||||
|
Create Date: 2026-02-28 14:48:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "00b9161b8729"
|
||||||
|
down_revision = "48afe197cc15"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
indexes = insp.get_indexes("request_candidates")
|
||||||
|
return any(idx["name"] == index_name for idx in indexes)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# (request_id, status) - fallback/retry 查询优化
|
||||||
|
if not _index_exists("idx_rc_request_id_status"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_rc_request_id_status",
|
||||||
|
"request_candidates",
|
||||||
|
["request_id", "status"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# (provider_id, status, created_at) - provider 聚合统计优化
|
||||||
|
if not _index_exists("idx_rc_provider_status_created"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_rc_provider_status_created",
|
||||||
|
"request_candidates",
|
||||||
|
["provider_id", "status", "created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _index_exists("idx_rc_provider_status_created"):
|
||||||
|
op.drop_index("idx_rc_provider_status_created", table_name="request_candidates")
|
||||||
|
if _index_exists("idx_rc_request_id_status"):
|
||||||
|
op.drop_index("idx_rc_request_id_status", table_name="request_candidates")
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
"""vertex_ai_provider_type
|
||||||
|
|
||||||
|
Migrate legacy Vertex auth_type/provider_type into the new model:
|
||||||
|
- provider_type=vertex_ai
|
||||||
|
- auth_type=service_account (legacy vertex_ai renamed)
|
||||||
|
- fixed Vertex endpoints: gemini:chat + claude:chat
|
||||||
|
|
||||||
|
Revision ID: 2a624af8dd3a
|
||||||
|
Revises: 00b9161b8729
|
||||||
|
Create Date: 2026-02-28 15:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2a624af8dd3a"
|
||||||
|
down_revision = "00b9161b8729"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"
|
||||||
|
_VERTEX_ENDPOINTS: tuple[tuple[str, str, str], ...] = (
|
||||||
|
("gemini:chat", "gemini", "chat"),
|
||||||
|
("claude:chat", "claude", "chat"),
|
||||||
|
)
|
||||||
|
_VERTEX_KEY_FORMATS_SA = '["gemini:chat","claude:chat"]'
|
||||||
|
_VERTEX_KEY_FORMATS_API_KEY = '["gemini:chat"]'
|
||||||
|
|
||||||
|
|
||||||
|
def _select_vertex_provider_ids(conn: sa.Connection) -> list[str]:
|
||||||
|
"""Collect providers that should be treated as Vertex after migration."""
|
||||||
|
rows = conn.execute(sa.text("""
|
||||||
|
SELECT DISTINCT p.id
|
||||||
|
FROM providers p
|
||||||
|
LEFT JOIN provider_api_keys pak ON pak.provider_id = p.id
|
||||||
|
WHERE lower(COALESCE(p.provider_type, '')) = 'vertex_ai'
|
||||||
|
OR pak.auth_type = 'vertex_ai'
|
||||||
|
"""))
|
||||||
|
return [str(row[0]) for row in rows if row[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_fixed_vertex_endpoints(conn: sa.Connection, provider_ids: list[str]) -> None:
|
||||||
|
"""Ensure every Vertex provider has fixed gemini:chat + claude:chat endpoints."""
|
||||||
|
for provider_id in provider_ids:
|
||||||
|
provider_max_retries = (
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT COALESCE(max_retries, 2)
|
||||||
|
FROM providers
|
||||||
|
WHERE id = :provider_id
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id},
|
||||||
|
).scalar()
|
||||||
|
or 2
|
||||||
|
)
|
||||||
|
|
||||||
|
for api_format, api_family, endpoint_kind in _VERTEX_ENDPOINTS:
|
||||||
|
# Normalize existing fixed endpoint fields.
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET
|
||||||
|
api_family = :api_family,
|
||||||
|
endpoint_kind = :endpoint_kind,
|
||||||
|
base_url = :base_url,
|
||||||
|
custom_path = NULL,
|
||||||
|
is_active = TRUE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND api_format = :api_format
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"api_format": api_format,
|
||||||
|
"api_family": api_family,
|
||||||
|
"endpoint_kind": endpoint_kind,
|
||||||
|
"base_url": _VERTEX_BASE_URL,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT 1
|
||||||
|
FROM provider_endpoints
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND api_format = :api_format
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id, "api_format": api_format},
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO provider_endpoints (
|
||||||
|
id,
|
||||||
|
provider_id,
|
||||||
|
api_format,
|
||||||
|
api_family,
|
||||||
|
endpoint_kind,
|
||||||
|
base_url,
|
||||||
|
custom_path,
|
||||||
|
header_rules,
|
||||||
|
body_rules,
|
||||||
|
max_retries,
|
||||||
|
is_active,
|
||||||
|
config,
|
||||||
|
format_acceptance_config,
|
||||||
|
proxy,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:id,
|
||||||
|
:provider_id,
|
||||||
|
:api_format,
|
||||||
|
:api_family,
|
||||||
|
:endpoint_kind,
|
||||||
|
:base_url,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
:max_retries,
|
||||||
|
TRUE,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"api_format": api_format,
|
||||||
|
"api_family": api_family,
|
||||||
|
"endpoint_kind": endpoint_kind,
|
||||||
|
"base_url": _VERTEX_BASE_URL,
|
||||||
|
"max_retries": int(provider_max_retries),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Vertex fixed-provider model: disable non-fixed endpoints.
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET
|
||||||
|
is_active = FALSE,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND api_format NOT IN ('gemini:chat', 'claude:chat')
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_vertex_key_formats(conn: sa.Connection, provider_ids: list[str]) -> None:
|
||||||
|
"""Normalize key.api_formats for Vertex keys by auth type."""
|
||||||
|
for provider_id in provider_ids:
|
||||||
|
# Service Account (and legacy vertex_ai) keys: allow Gemini + Claude models.
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
api_formats = CAST(:api_formats AS json),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND auth_type IN ('service_account', 'vertex_ai')
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"api_formats": _VERTEX_KEY_FORMATS_SA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# API Key mode on Vertex 仅支持 Gemini(Google publisher)。
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET
|
||||||
|
api_formats = CAST(:api_formats AS json),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND auth_type = 'api_key'
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"provider_id": provider_id,
|
||||||
|
"api_formats": _VERTEX_KEY_FORMATS_API_KEY,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 1) 收集目标 Provider(兼容重复执行,先识别 legacy/new 两种来源)。
|
||||||
|
provider_ids = _select_vertex_provider_ids(conn)
|
||||||
|
|
||||||
|
# 2) 先重命名 auth_type(legacy vertex_ai -> service_account)。
|
||||||
|
conn.execute(sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET auth_type = 'service_account'
|
||||||
|
WHERE auth_type = 'vertex_ai'
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not provider_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3) 归一 provider_type,并启用格式转换(Vertex 同时承载 Gemini/Claude)。
|
||||||
|
for provider_id in provider_ids:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE providers
|
||||||
|
SET
|
||||||
|
provider_type = 'vertex_ai',
|
||||||
|
enable_format_conversion = TRUE
|
||||||
|
WHERE id = :provider_id
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4) 固定端点落地:gemini:chat + claude:chat。
|
||||||
|
_ensure_fixed_vertex_endpoints(conn, provider_ids)
|
||||||
|
|
||||||
|
# 5) 归一 key 的 api_formats,避免调度命中旧格式。
|
||||||
|
_normalize_vertex_key_formats(conn, provider_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
provider_rows = conn.execute(sa.text("""
|
||||||
|
SELECT id
|
||||||
|
FROM providers
|
||||||
|
WHERE lower(COALESCE(provider_type, '')) = 'vertex_ai'
|
||||||
|
"""))
|
||||||
|
provider_ids = [str(row[0]) for row in provider_rows if row[0]]
|
||||||
|
|
||||||
|
if provider_ids:
|
||||||
|
for provider_id in provider_ids:
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET auth_type = 'vertex_ai'
|
||||||
|
WHERE provider_id = :provider_id
|
||||||
|
AND auth_type = 'service_account'
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE providers
|
||||||
|
SET provider_type = 'custom'
|
||||||
|
WHERE id = :provider_id
|
||||||
|
"""),
|
||||||
|
{"provider_id": provider_id},
|
||||||
|
)
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
"""backfill_codex_compact_endpoint
|
||||||
|
|
||||||
|
Backfill Codex reverse-proxy endpoints:
|
||||||
|
- ensure `openai:cli` endpoint is pinned to force_stream
|
||||||
|
- ensure `openai:compact` endpoint exists
|
||||||
|
|
||||||
|
Revision ID: f0c3a7b9d1e2
|
||||||
|
Revises: 2a624af8dd3a
|
||||||
|
Create Date: 2026-03-01 17:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f0c3a7b9d1e2"
|
||||||
|
down_revision = "2a624af8dd3a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||||
|
_COMPACT_FORMAT = "openai:compact"
|
||||||
|
_CLI_FORMAT = "openai:cli"
|
||||||
|
_FORCE_STREAM = "force_stream"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_codex_provider_ids(conn: sa.Connection) -> list[str]:
|
||||||
|
"""Find Codex providers (by provider_type or legacy base_url pattern)."""
|
||||||
|
rows = conn.execute(sa.text("""
|
||||||
|
SELECT DISTINCT p.id
|
||||||
|
FROM providers p
|
||||||
|
LEFT JOIN provider_endpoints pe ON pe.provider_id = p.id
|
||||||
|
WHERE lower(COALESCE(p.provider_type, '')) = 'codex'
|
||||||
|
OR (
|
||||||
|
lower(COALESCE(pe.api_format, '')) = 'openai:cli'
|
||||||
|
AND lower(COALESCE(pe.base_url, '')) LIKE '%/backend-api/codex%'
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
return [str(r[0]) for r in rows if r[0]]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cli_endpoint(conn: sa.Connection, provider_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Load existing openai:cli endpoint for the provider."""
|
||||||
|
row = (
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
SELECT base_url, header_rules, body_rules, max_retries, proxy, config
|
||||||
|
FROM provider_endpoints
|
||||||
|
WHERE provider_id = :pid AND api_format = :fmt
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"pid": provider_id, "fmt": _CLI_FORMAT},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _pin_cli_force_stream(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
|
||||||
|
"""Set upstream_stream_policy=force_stream on existing cli endpoint."""
|
||||||
|
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
|
||||||
|
cfg.pop("upstreamStreamPolicy", None)
|
||||||
|
cfg.pop("upstream_stream", None)
|
||||||
|
cfg["upstream_stream_policy"] = _FORCE_STREAM
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET api_family = 'openai',
|
||||||
|
endpoint_kind = 'cli',
|
||||||
|
config = CAST(:config AS json),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :pid AND api_format = :fmt
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"pid": provider_id,
|
||||||
|
"fmt": _CLI_FORMAT,
|
||||||
|
"config": json.dumps(cfg, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_compact_endpoint(conn: sa.Connection, provider_id: str, cli: dict[str, Any]) -> None:
|
||||||
|
"""Create openai:compact endpoint if missing (clone from cli)."""
|
||||||
|
exists = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM provider_endpoints WHERE provider_id = :pid AND api_format = :fmt LIMIT 1"
|
||||||
|
),
|
||||||
|
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
|
||||||
|
).first()
|
||||||
|
if exists:
|
||||||
|
# Already exists, just ensure api_family/endpoint_kind are set.
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET api_family = 'openai', endpoint_kind = 'compact',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE provider_id = :pid AND api_format = :fmt
|
||||||
|
"""),
|
||||||
|
{"pid": provider_id, "fmt": _COMPACT_FORMAT},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clone from cli endpoint, strip stream policy.
|
||||||
|
cfg = dict(cli.get("config") or {}) if isinstance(cli.get("config"), dict) else {}
|
||||||
|
for k in ("upstream_stream_policy", "upstreamStreamPolicy", "upstream_stream"):
|
||||||
|
cfg.pop(k, None)
|
||||||
|
|
||||||
|
def _json(val: Any) -> str | None:
|
||||||
|
return json.dumps(val, ensure_ascii=False) if val is not None else None
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
INSERT INTO provider_endpoints (
|
||||||
|
id, provider_id, api_format, api_family, endpoint_kind,
|
||||||
|
base_url, custom_path, header_rules, body_rules,
|
||||||
|
max_retries, is_active, config, format_acceptance_config,
|
||||||
|
proxy, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
:id, :pid, :fmt, 'openai', 'compact',
|
||||||
|
:base_url, NULL, CAST(:header_rules AS json), CAST(:body_rules AS json),
|
||||||
|
:max_retries, TRUE, CAST(:config AS json), NULL,
|
||||||
|
CAST(:proxy AS jsonb), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"pid": provider_id,
|
||||||
|
"fmt": _COMPACT_FORMAT,
|
||||||
|
"base_url": cli.get("base_url") or _CODEX_BASE_URL,
|
||||||
|
"header_rules": _json(cli.get("header_rules")),
|
||||||
|
"body_rules": _json(cli.get("body_rules")),
|
||||||
|
"max_retries": cli.get("max_retries") or 2,
|
||||||
|
"config": _json(cfg or None),
|
||||||
|
"proxy": _json(cli.get("proxy")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_compact_to_key_formats(conn: sa.Connection, provider_id: str) -> None:
|
||||||
|
"""Ensure provider keys include openai:compact in api_formats."""
|
||||||
|
rows = (
|
||||||
|
conn.execute(
|
||||||
|
sa.text("SELECT id, api_formats FROM provider_api_keys WHERE provider_id = :pid"),
|
||||||
|
{"pid": provider_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
raw = row["api_formats"]
|
||||||
|
formats: list[str] = []
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
v = str(item or "").strip().lower()
|
||||||
|
if v and v not in formats:
|
||||||
|
formats.append(v)
|
||||||
|
|
||||||
|
if _COMPACT_FORMAT in formats:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Insert compact right after cli, or at end.
|
||||||
|
if _CLI_FORMAT in formats:
|
||||||
|
idx = formats.index(_CLI_FORMAT) + 1
|
||||||
|
formats.insert(idx, _COMPACT_FORMAT)
|
||||||
|
else:
|
||||||
|
formats.append(_COMPACT_FORMAT)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET api_formats = CAST(:fmts AS json), updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = :id
|
||||||
|
"""),
|
||||||
|
{"id": row["id"], "fmts": json.dumps(formats, ensure_ascii=False)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
for provider_id in _find_codex_provider_ids(conn):
|
||||||
|
cli = _get_cli_endpoint(conn, provider_id)
|
||||||
|
if not cli:
|
||||||
|
continue # No cli endpoint to clone from; skip.
|
||||||
|
_pin_cli_force_stream(conn, provider_id, cli)
|
||||||
|
_ensure_compact_endpoint(conn, provider_id, cli)
|
||||||
|
_add_compact_to_key_formats(conn, provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Data backfill: no-op to avoid deleting user-managed data.
|
||||||
|
return
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
"""add_proxy_metadata_to_proxy_nodes
|
||||||
|
|
||||||
|
Revision ID: 1d2e3f4a5b6c
|
||||||
|
Revises: f0c3a7b9d1e2
|
||||||
|
Create Date: 2026-03-02 13:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "1d2e3f4a5b6c"
|
||||||
|
down_revision: str | None = "f0c3a7b9d1e2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE public.proxy_nodes ADD COLUMN IF NOT EXISTS proxy_metadata json")
|
||||||
|
op.execute(
|
||||||
|
"COMMENT ON COLUMN public.proxy_nodes.proxy_metadata IS 'aether-proxy 上报元数据(版本等)'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE public.proxy_nodes DROP COLUMN IF EXISTS proxy_metadata")
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
"""backfill_codex_default_body_rules
|
||||||
|
|
||||||
|
Backfill default body_rules for codex providers with openai:cli endpoints
|
||||||
|
that currently have body_rules IS NULL.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- drop max_output_tokens
|
||||||
|
- drop temperature
|
||||||
|
- drop top_p
|
||||||
|
- set store = false
|
||||||
|
- set instructions = "You are GPT-5." (when instructions not exists)
|
||||||
|
|
||||||
|
Revision ID: dd0278c0a28c
|
||||||
|
Revises: 1d2e3f4a5b6c
|
||||||
|
Create Date: 2026-03-02 15:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "dd0278c0a28c"
|
||||||
|
down_revision = "1d2e3f4a5b6c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TARGET_FORMATS = ("openai:cli",)
|
||||||
|
|
||||||
|
_DEFAULT_BODY_RULES = [
|
||||||
|
{"action": "drop", "path": "max_output_tokens"},
|
||||||
|
{"action": "drop", "path": "temperature"},
|
||||||
|
{"action": "drop", "path": "top_p"},
|
||||||
|
{"action": "set", "path": "store", "value": False},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "instructions",
|
||||||
|
"value": "You are GPT-5.",
|
||||||
|
"condition": {"path": "instructions", "op": "not_exists"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 幂等性: 仅回填 codex 提供商中 body_rules 为空(SQL NULL 或 JSON null)的记录
|
||||||
|
rules_json = json.dumps(_DEFAULT_BODY_RULES, ensure_ascii=False)
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints pe
|
||||||
|
SET body_rules = CAST(:rules AS json),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
FROM providers p
|
||||||
|
WHERE pe.provider_id = p.id
|
||||||
|
AND p.provider_type = :ptype
|
||||||
|
AND pe.api_format = :fmt
|
||||||
|
AND (pe.body_rules IS NULL OR pe.body_rules::text = 'null')
|
||||||
|
"""),
|
||||||
|
{"rules": rules_json, "ptype": "codex", "fmt": _TARGET_FORMATS[0]},
|
||||||
|
)
|
||||||
|
if result.rowcount:
|
||||||
|
print(f" backfilled body_rules for {result.rowcount} endpoint(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Data backfill: no-op to avoid removing user-customized rules.
|
||||||
|
return
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""add_idx_usage_provider_key
|
||||||
|
|
||||||
|
Add composite index on usage(provider_id, provider_api_key_id) to support
|
||||||
|
the pool management page's per-key usage stats aggregation query.
|
||||||
|
|
||||||
|
Revision ID: 0ba031f328de
|
||||||
|
Revises: dd0278c0a28c
|
||||||
|
Create Date: 2026-03-03 10:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0ba031f328de"
|
||||||
|
down_revision = "dd0278c0a28c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
INDEX_NAME = "idx_usage_provider_key"
|
||||||
|
TABLE = "usage"
|
||||||
|
COLUMNS = ["provider_id", "provider_api_key_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": INDEX_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if result:
|
||||||
|
return
|
||||||
|
op.create_index(INDEX_NAME, TABLE, COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": INDEX_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
op.drop_index(INDEX_NAME, table_name=TABLE)
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""add_idx_usage_status_user_created
|
||||||
|
|
||||||
|
Add composite index on usage(status, user_id, created_at) to speed up
|
||||||
|
interval timeline and active usage analytics queries.
|
||||||
|
|
||||||
|
Revision ID: 5f1d2e3c4b5a
|
||||||
|
Revises: 0ba031f328de
|
||||||
|
Create Date: 2026-03-03 17:30:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "5f1d2e3c4b5a"
|
||||||
|
down_revision = "0ba031f328de"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
INDEX_NAME = "idx_usage_status_user_created"
|
||||||
|
TABLE = "usage"
|
||||||
|
COLUMNS = ["status", "user_id", "created_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": INDEX_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if result:
|
||||||
|
return
|
||||||
|
op.create_index(INDEX_NAME, TABLE, COLUMNS)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
||||||
|
{"name": INDEX_NAME},
|
||||||
|
).fetchone()
|
||||||
|
if not result:
|
||||||
|
return
|
||||||
|
op.drop_index(INDEX_NAME, table_name=TABLE)
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"""add fingerprint column to provider_api_keys
|
||||||
|
|
||||||
|
Revision ID: 6a9b8c7d5e4f
|
||||||
|
Revises: 5f1d2e3c4b5a
|
||||||
|
Create Date: 2026-03-04 23:50:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "6a9b8c7d5e4f"
|
||||||
|
down_revision: str | None = "5f1d2e3c4b5a"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "fingerprint"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("fingerprint", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "fingerprint"):
|
||||||
|
op.drop_column("provider_api_keys", "fingerprint")
|
||||||
+1101
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
"""remove standalone api key locking
|
||||||
|
|
||||||
|
Revision ID: 7c91d2e4f8a1
|
||||||
|
Revises: 6f7a8b9c0d1e
|
||||||
|
Create Date: 2026-03-05 17:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "7c91d2e4f8a1"
|
||||||
|
down_revision: str | None = "6f7a8b9c0d1e"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_CONSTRAINT_NAME = "ck_api_keys_standalone_not_locked"
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [c["name"] for c in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not (
|
||||||
|
_column_exists("api_keys", "is_standalone")
|
||||||
|
and _column_exists("api_keys", "is_locked")
|
||||||
|
and _column_exists("api_keys", "is_active")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET is_active = FALSE,
|
||||||
|
is_locked = FALSE
|
||||||
|
WHERE is_standalone IS TRUE AND is_locked IS TRUE
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if not _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
|
||||||
|
op.create_check_constraint(
|
||||||
|
_CONSTRAINT_NAME,
|
||||||
|
"api_keys",
|
||||||
|
"(NOT is_standalone) OR (NOT is_locked)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
|
||||||
|
op.drop_constraint(_CONSTRAINT_NAME, "api_keys", type_="check")
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
"""tighten wallet transaction snapshots and remove wallet version
|
||||||
|
|
||||||
|
Revision ID: 8e71f2a4c9b0
|
||||||
|
Revises: 7c91d2e4f8a1
|
||||||
|
Create Date: 2026-03-07 13:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "8e71f2a4c9b0"
|
||||||
|
down_revision: str | None = "7c91d2e4f8a1"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_WALLET_TX_BEFORE_CHECK = "ck_wallet_tx_balance_before_consistent"
|
||||||
|
_WALLET_TX_AFTER_CHECK = "ck_wallet_tx_balance_after_consistent"
|
||||||
|
_WALLET_LIMIT_MODE_INDEX = "idx_wallets_limit_mode"
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [c["name"] for c in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return any(index.get("name") == index_name for index in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _tighten_wallet_transaction_snapshots() -> None:
|
||||||
|
if not _table_exists("wallet_transactions"):
|
||||||
|
return
|
||||||
|
|
||||||
|
required_columns = {
|
||||||
|
"balance_before",
|
||||||
|
"balance_after",
|
||||||
|
"recharge_balance_before",
|
||||||
|
"recharge_balance_after",
|
||||||
|
"gift_balance_before",
|
||||||
|
"gift_balance_after",
|
||||||
|
}
|
||||||
|
existing_columns = {
|
||||||
|
column["name"] for column in inspect(op.get_bind()).get_columns("wallet_transactions")
|
||||||
|
}
|
||||||
|
if not required_columns.issubset(existing_columns):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE wallet_transactions
|
||||||
|
SET recharge_balance_before = balance_before
|
||||||
|
WHERE recharge_balance_before IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE wallet_transactions
|
||||||
|
SET recharge_balance_after = balance_after
|
||||||
|
WHERE recharge_balance_after IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE wallet_transactions
|
||||||
|
SET gift_balance_before = 0
|
||||||
|
WHERE gift_balance_before IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE wallet_transactions
|
||||||
|
SET gift_balance_after = 0
|
||||||
|
WHERE gift_balance_after IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE wallet_transactions
|
||||||
|
SET balance_before = recharge_balance_before + gift_balance_before,
|
||||||
|
balance_after = recharge_balance_after + gift_balance_after
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
|
||||||
|
op.create_check_constraint(
|
||||||
|
_WALLET_TX_BEFORE_CHECK,
|
||||||
|
"wallet_transactions",
|
||||||
|
"balance_before = recharge_balance_before + gift_balance_before",
|
||||||
|
)
|
||||||
|
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
|
||||||
|
op.create_check_constraint(
|
||||||
|
_WALLET_TX_AFTER_CHECK,
|
||||||
|
"wallet_transactions",
|
||||||
|
"balance_after = recharge_balance_after + gift_balance_after",
|
||||||
|
)
|
||||||
|
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"recharge_balance_before",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"recharge_balance_after",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"gift_balance_before",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"gift_balance_after",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_wallet_cleanup_artifacts() -> None:
|
||||||
|
if not _table_exists("wallets"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
|
||||||
|
op.drop_index(_WALLET_LIMIT_MODE_INDEX, table_name="wallets")
|
||||||
|
|
||||||
|
if _column_exists("wallets", "version"):
|
||||||
|
op.drop_column("wallets", "version")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_tighten_wallet_transaction_snapshots()
|
||||||
|
_drop_wallet_cleanup_artifacts()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _table_exists("wallets"):
|
||||||
|
if not _column_exists("wallets", "version"):
|
||||||
|
op.add_column(
|
||||||
|
"wallets",
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
if not _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
|
||||||
|
op.create_index(_WALLET_LIMIT_MODE_INDEX, "wallets", ["limit_mode"])
|
||||||
|
|
||||||
|
if not _table_exists("wallet_transactions"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _column_exists("wallet_transactions", "recharge_balance_before"):
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"recharge_balance_before",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
if _column_exists("wallet_transactions", "recharge_balance_after"):
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"recharge_balance_after",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
if _column_exists("wallet_transactions", "gift_balance_before"):
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"gift_balance_before",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
if _column_exists("wallet_transactions", "gift_balance_after"):
|
||||||
|
op.alter_column(
|
||||||
|
"wallet_transactions",
|
||||||
|
"gift_balance_after",
|
||||||
|
existing_type=sa.Numeric(20, 8),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
|
||||||
|
op.drop_constraint(_WALLET_TX_AFTER_CHECK, "wallet_transactions", type_="check")
|
||||||
|
if _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
|
||||||
|
op.drop_constraint(_WALLET_TX_BEFORE_CHECK, "wallet_transactions", type_="check")
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
"""add missing foreign key indexes for cascade delete performance
|
||||||
|
|
||||||
|
Revision ID: 2d932114930d
|
||||||
|
Revises: 8e71f2a4c9b0
|
||||||
|
Create Date: 2026-03-07 16:28:48.633531+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2d932114930d'
|
||||||
|
down_revision = '8e71f2a4c9b0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_index_if_not_exists(index_name: str, table_name: str, columns: list[str]) -> None:
|
||||||
|
if not _index_exists(table_name, index_name):
|
||||||
|
op.create_index(op.f(index_name), table_name, columns, unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _drop_index_if_exists(index_name: str, table_name: str) -> None:
|
||||||
|
if _index_exists(table_name, index_name):
|
||||||
|
op.drop_index(op.f(index_name), table_name=table_name)
|
||||||
|
|
||||||
|
|
||||||
|
# (index_name, table_name, columns)
|
||||||
|
_INDEXES = [
|
||||||
|
# api_keys.user_id (CASCADE -> users.id)
|
||||||
|
('ix_api_keys_user_id', 'api_keys', ['user_id']),
|
||||||
|
# usage: wallet_id, provider_endpoint_id, provider_api_key_id (SET NULL)
|
||||||
|
('ix_usage_wallet_id', 'usage', ['wallet_id']),
|
||||||
|
('ix_usage_provider_endpoint_id', 'usage', ['provider_endpoint_id']),
|
||||||
|
('ix_usage_provider_api_key_id', 'usage', ['provider_api_key_id']),
|
||||||
|
# wallet_transactions.operator_id (SET NULL -> users.id)
|
||||||
|
('ix_wallet_transactions_operator_id', 'wallet_transactions', ['operator_id']),
|
||||||
|
# payment_callbacks.payment_order_id (SET NULL -> payment_orders.id)
|
||||||
|
('ix_payment_callbacks_payment_order_id', 'payment_callbacks', ['payment_order_id']),
|
||||||
|
# refund_requests: payment_order_id, requested_by, approved_by, processed_by (SET NULL)
|
||||||
|
('ix_refund_requests_payment_order_id', 'refund_requests', ['payment_order_id']),
|
||||||
|
('ix_refund_requests_requested_by', 'refund_requests', ['requested_by']),
|
||||||
|
('ix_refund_requests_approved_by', 'refund_requests', ['approved_by']),
|
||||||
|
('ix_refund_requests_processed_by', 'refund_requests', ['processed_by']),
|
||||||
|
# proxy_nodes.registered_by (SET NULL -> users.id)
|
||||||
|
('ix_proxy_nodes_registered_by', 'proxy_nodes', ['registered_by']),
|
||||||
|
# video_tasks: api_key_id, provider_id, endpoint_id, key_id, remixed_from_task_id
|
||||||
|
('ix_video_tasks_api_key_id', 'video_tasks', ['api_key_id']),
|
||||||
|
('ix_video_tasks_provider_id', 'video_tasks', ['provider_id']),
|
||||||
|
('ix_video_tasks_endpoint_id', 'video_tasks', ['endpoint_id']),
|
||||||
|
('ix_video_tasks_key_id', 'video_tasks', ['key_id']),
|
||||||
|
('ix_video_tasks_remixed_from_task_id', 'video_tasks', ['remixed_from_task_id']),
|
||||||
|
# user_preferences.default_provider_id (-> providers.id)
|
||||||
|
('ix_user_preferences_default_provider_id', 'user_preferences', ['default_provider_id']),
|
||||||
|
# announcements.author_id (SET NULL -> users.id)
|
||||||
|
('ix_announcements_author_id', 'announcements', ['author_id']),
|
||||||
|
# announcement_reads.announcement_id (-> announcements.id)
|
||||||
|
('ix_announcement_reads_announcement_id', 'announcement_reads', ['announcement_id']),
|
||||||
|
# request_candidates: user_id, api_key_id, endpoint_id, key_id (CASCADE)
|
||||||
|
('ix_request_candidates_user_id', 'request_candidates', ['user_id']),
|
||||||
|
('ix_request_candidates_api_key_id', 'request_candidates', ['api_key_id']),
|
||||||
|
('ix_request_candidates_endpoint_id', 'request_candidates', ['endpoint_id']),
|
||||||
|
('ix_request_candidates_key_id', 'request_candidates', ['key_id']),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for index_name, table_name, columns in _INDEXES:
|
||||||
|
_create_index_if_not_exists(index_name, table_name, columns)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
for index_name, table_name, _columns in reversed(_INDEXES):
|
||||||
|
_drop_index_if_exists(index_name, table_name)
|
||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
"""usage stats retention: SET NULL on delete and add name snapshots
|
||||||
|
|
||||||
|
Revision ID: 45b118150a78
|
||||||
|
Revises: 2d932114930d
|
||||||
|
Create Date: 2026-03-08 03:48:49.622091+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "45b118150a78"
|
||||||
|
down_revision = "2d932114930d"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(_TABLES)
|
||||||
|
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
|
||||||
|
|
||||||
|
# --- Usage: add name snapshot columns ---
|
||||||
|
if not c.column_exists("usage", "username"):
|
||||||
|
op.add_column(
|
||||||
|
"usage", sa.Column("username", sa.String(100), nullable=True, comment="用户名快照")
|
||||||
|
)
|
||||||
|
if not c.column_exists("usage", "api_key_name"):
|
||||||
|
op.add_column(
|
||||||
|
"usage",
|
||||||
|
sa.Column("api_key_name", sa.String(200), nullable=True, comment="API Key 名称快照"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"stats_user_daily_user_id_fkey",
|
||||||
|
"stats_user_daily",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=True)
|
||||||
|
if not c.column_exists("stats_user_daily", "username"):
|
||||||
|
op.add_column(
|
||||||
|
"stats_user_daily",
|
||||||
|
sa.Column(
|
||||||
|
"username",
|
||||||
|
sa.String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="用户名快照(删除用户后仍可追溯)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"stats_daily_api_key_api_key_id_fkey",
|
||||||
|
"stats_daily_api_key",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
op.alter_column("stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=True)
|
||||||
|
if not c.column_exists("stats_daily_api_key", "api_key_name"):
|
||||||
|
op.add_column(
|
||||||
|
"stats_daily_api_key",
|
||||||
|
sa.Column(
|
||||||
|
"api_key_name",
|
||||||
|
sa.String(200),
|
||||||
|
nullable=True,
|
||||||
|
comment="API Key 名称快照(删除 Key 后仍可追溯)",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
|
||||||
|
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
|
||||||
|
|
||||||
|
# --- Remove snapshot columns ---
|
||||||
|
if c.column_exists("stats_daily_api_key", "api_key_name"):
|
||||||
|
op.drop_column("stats_daily_api_key", "api_key_name")
|
||||||
|
if c.column_exists("stats_user_daily", "username"):
|
||||||
|
op.drop_column("stats_user_daily", "username")
|
||||||
|
if c.column_exists("usage", "api_key_name"):
|
||||||
|
op.drop_column("usage", "api_key_name")
|
||||||
|
if c.column_exists("usage", "username"):
|
||||||
|
op.drop_column("usage", "username")
|
||||||
|
|
||||||
|
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"stats_daily_api_key_api_key_id_fkey",
|
||||||
|
"stats_daily_api_key",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"CASCADE",
|
||||||
|
)
|
||||||
|
op.alter_column(
|
||||||
|
"stats_daily_api_key", "api_key_id", existing_type=sa.String(36), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- StatsUserDaily: SET NULL -> CASCADE ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"stats_user_daily_user_id_fkey",
|
||||||
|
"stats_user_daily",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"CASCADE",
|
||||||
|
)
|
||||||
|
op.alter_column("stats_user_daily", "user_id", existing_type=sa.String(36), nullable=False)
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
"""request_candidates/video_tasks retention: SET NULL and add snapshots
|
||||||
|
|
||||||
|
Revision ID: 13a4c8f6d9e0
|
||||||
|
Revises: 45b118150a78
|
||||||
|
Create Date: 2026-03-08 12:15:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "13a4c8f6d9e0"
|
||||||
|
down_revision = "45b118150a78"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLES = ["request_candidates", "video_tasks"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(_TABLES)
|
||||||
|
c.load_fk_rules(_TABLES)
|
||||||
|
|
||||||
|
# --- request_candidates: add snapshot columns ---
|
||||||
|
if not c.column_exists("request_candidates", "username"):
|
||||||
|
op.add_column(
|
||||||
|
"request_candidates",
|
||||||
|
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
|
||||||
|
)
|
||||||
|
if not c.column_exists("request_candidates", "api_key_name"):
|
||||||
|
op.add_column(
|
||||||
|
"request_candidates",
|
||||||
|
sa.Column(
|
||||||
|
"api_key_name",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=True,
|
||||||
|
comment="API Key 名称快照",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- request_candidates: CASCADE -> SET NULL ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"request_candidates_user_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"request_candidates_api_key_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- video_tasks: add snapshot columns ---
|
||||||
|
if not c.column_exists("video_tasks", "username"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column("username", sa.String(length=100), nullable=True, comment="用户名快照"),
|
||||||
|
)
|
||||||
|
if not c.column_exists("video_tasks", "api_key_name"):
|
||||||
|
op.add_column(
|
||||||
|
"video_tasks",
|
||||||
|
sa.Column(
|
||||||
|
"api_key_name",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=True,
|
||||||
|
comment="API Key 名称快照",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
|
||||||
|
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"video_tasks_user_id_fkey",
|
||||||
|
"video_tasks",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"video_tasks_api_key_id_fkey",
|
||||||
|
"video_tasks",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(_TABLES)
|
||||||
|
c.load_fk_rules(_TABLES)
|
||||||
|
|
||||||
|
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"video_tasks_api_key_id_fkey",
|
||||||
|
"video_tasks",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"NO ACTION",
|
||||||
|
)
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"video_tasks_user_id_fkey",
|
||||||
|
"video_tasks",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"NO ACTION",
|
||||||
|
)
|
||||||
|
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=False)
|
||||||
|
if c.column_exists("video_tasks", "api_key_name"):
|
||||||
|
op.drop_column("video_tasks", "api_key_name")
|
||||||
|
if c.column_exists("video_tasks", "username"):
|
||||||
|
op.drop_column("video_tasks", "username")
|
||||||
|
|
||||||
|
# --- request_candidates: SET NULL -> CASCADE ---
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"request_candidates_api_key_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"api_keys",
|
||||||
|
["api_key_id"],
|
||||||
|
["id"],
|
||||||
|
"CASCADE",
|
||||||
|
)
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
"request_candidates_user_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"users",
|
||||||
|
["user_id"],
|
||||||
|
["id"],
|
||||||
|
"CASCADE",
|
||||||
|
)
|
||||||
|
if c.column_exists("request_candidates", "api_key_name"):
|
||||||
|
op.drop_column("request_candidates", "api_key_name")
|
||||||
|
if c.column_exists("request_candidates", "username"):
|
||||||
|
op.drop_column("request_candidates", "username")
|
||||||
+230
@@ -0,0 +1,230 @@
|
|||||||
|
"""cost fields: Float -> Numeric(20,8) + provider_api_keys composite index
|
||||||
|
|
||||||
|
Revision ID: 2053ab8ed764
|
||||||
|
Revises: 13a4c8f6d9e0
|
||||||
|
Create Date: 2026-03-08 15:30:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2053ab8ed764"
|
||||||
|
down_revision = "13a4c8f6d9e0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
# (table_name, column_name, nullable, server_default)
|
||||||
|
_COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
|
||||||
|
# api_keys
|
||||||
|
("api_keys", "total_cost_usd", True, "0.0"),
|
||||||
|
# usage
|
||||||
|
("usage", "input_cost_usd", True, "0.0"),
|
||||||
|
("usage", "output_cost_usd", True, "0.0"),
|
||||||
|
("usage", "cache_cost_usd", True, "0.0"),
|
||||||
|
("usage", "cache_creation_cost_usd", True, "0.0"),
|
||||||
|
("usage", "cache_read_cost_usd", True, "0.0"),
|
||||||
|
("usage", "request_cost_usd", True, "0.0"),
|
||||||
|
("usage", "total_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_input_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_output_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_cache_creation_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_cache_read_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_request_cost_usd", True, "0.0"),
|
||||||
|
("usage", "actual_total_cost_usd", True, "0.0"),
|
||||||
|
("usage", "rate_multiplier", True, "1.0"),
|
||||||
|
("usage", "input_price_per_1m", True, None),
|
||||||
|
("usage", "output_price_per_1m", True, None),
|
||||||
|
("usage", "cache_creation_price_per_1m", True, None),
|
||||||
|
("usage", "cache_read_price_per_1m", True, None),
|
||||||
|
("usage", "price_per_request", True, None),
|
||||||
|
# providers
|
||||||
|
("providers", "monthly_quota_usd", True, None),
|
||||||
|
("providers", "monthly_used_usd", True, "0.0"),
|
||||||
|
# global_models
|
||||||
|
("global_models", "default_price_per_request", True, None),
|
||||||
|
# models
|
||||||
|
("models", "price_per_request", True, None),
|
||||||
|
# stats_hourly
|
||||||
|
("stats_hourly", "total_cost", False, "0.0"),
|
||||||
|
("stats_hourly", "actual_total_cost", False, "0.0"),
|
||||||
|
# stats_hourly_user
|
||||||
|
("stats_hourly_user", "total_cost", False, "0.0"),
|
||||||
|
# stats_hourly_model
|
||||||
|
("stats_hourly_model", "total_cost", False, "0.0"),
|
||||||
|
# stats_hourly_provider
|
||||||
|
("stats_hourly_provider", "total_cost", False, "0.0"),
|
||||||
|
# stats_daily
|
||||||
|
("stats_daily", "total_cost", False, "0.0"),
|
||||||
|
("stats_daily", "actual_total_cost", False, "0.0"),
|
||||||
|
("stats_daily", "input_cost", False, "0.0"),
|
||||||
|
("stats_daily", "output_cost", False, "0.0"),
|
||||||
|
("stats_daily", "cache_creation_cost", False, "0.0"),
|
||||||
|
("stats_daily", "cache_read_cost", False, "0.0"),
|
||||||
|
# stats_daily_model
|
||||||
|
("stats_daily_model", "total_cost", False, "0.0"),
|
||||||
|
# stats_daily_provider
|
||||||
|
("stats_daily_provider", "total_cost", False, "0.0"),
|
||||||
|
# stats_daily_api_key
|
||||||
|
("stats_daily_api_key", "total_cost", False, "0.0"),
|
||||||
|
# stats_summary
|
||||||
|
("stats_summary", "all_time_cost", False, "0.0"),
|
||||||
|
("stats_summary", "all_time_actual_cost", False, "0.0"),
|
||||||
|
# stats_user_daily
|
||||||
|
("stats_user_daily", "total_cost", False, "0.0"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def column_type(self, table: str, column: str) -> str | None:
|
||||||
|
return self._columns.get(table, {}).get(column)
|
||||||
|
|
||||||
|
def is_numeric(self, table: str, column: str) -> bool:
|
||||||
|
return self.column_type(table, column) == "numeric"
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_indexes "
|
||||||
|
"WHERE indexname = :name AND schemaname = current_schema()::text"
|
||||||
|
),
|
||||||
|
{"name": index_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_max(type_spec: str) -> float | None:
|
||||||
|
m = re.match(r"NUMERIC\((\d+),(\d+)\)", type_spec, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
precision, scale = int(m.group(1)), int(m.group(2))
|
||||||
|
return 10 ** (precision - scale) - 10 ** (-scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_alter_type(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
columns: list[tuple[str, str, bool, str | None]],
|
||||||
|
cast_suffix: str,
|
||||||
|
type_fn: Callable[[str], str],
|
||||||
|
) -> None:
|
||||||
|
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
|
for table, col, _nullable, _default in columns:
|
||||||
|
if not cache.column_exists(table, col):
|
||||||
|
continue
|
||||||
|
by_table[table].append((col, type_fn(col)))
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
for table, col_types in by_table.items():
|
||||||
|
for col, target in col_types:
|
||||||
|
cap = _numeric_max(target)
|
||||||
|
if cap is not None:
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
f"UPDATE {table} SET {col} = :cap "
|
||||||
|
f"WHERE {col} IS NOT NULL AND abs({col}) > :cap"
|
||||||
|
),
|
||||||
|
{"cap": cap},
|
||||||
|
)
|
||||||
|
parts = [
|
||||||
|
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
|
||||||
|
for col, target in col_types
|
||||||
|
]
|
||||||
|
if parts:
|
||||||
|
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _type_spec(col: str) -> str:
|
||||||
|
"""Return the SQL type literal for a given column name."""
|
||||||
|
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(_ALL_TABLES)
|
||||||
|
|
||||||
|
# -- 1. cost fields: Float -> Numeric (batched per table)
|
||||||
|
cols_to_convert = [
|
||||||
|
(t, col, n, d)
|
||||||
|
for t, col, n, d in _COST_COLUMNS
|
||||||
|
if c.column_exists(t, col) and not c.is_numeric(t, col)
|
||||||
|
]
|
||||||
|
_batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
|
||||||
|
|
||||||
|
# -- 2. provider_api_keys composite index
|
||||||
|
if not _index_exists("idx_provider_api_keys_provider_active"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_provider_api_keys_provider_active",
|
||||||
|
"provider_api_keys",
|
||||||
|
["provider_id", "is_active"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# -- 2. drop composite index
|
||||||
|
if _index_exists("idx_provider_api_keys_provider_active"):
|
||||||
|
op.drop_index(
|
||||||
|
"idx_provider_api_keys_provider_active",
|
||||||
|
table_name="provider_api_keys",
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- 1. Numeric -> Float (batched per table)
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_columns(_ALL_TABLES)
|
||||||
|
|
||||||
|
cols_to_revert = [
|
||||||
|
(t, col, n, d)
|
||||||
|
for t, col, n, d in _COST_COLUMNS
|
||||||
|
if c.column_exists(t, col) and c.is_numeric(t, col)
|
||||||
|
]
|
||||||
|
_batch_alter_type(
|
||||||
|
c,
|
||||||
|
cols_to_revert,
|
||||||
|
cast_suffix="double precision",
|
||||||
|
type_fn=lambda _col: "DOUBLE PRECISION",
|
||||||
|
)
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
"""video_tasks.key_id: add ondelete SET NULL
|
||||||
|
|
||||||
|
Revision ID: d7649c1f8e21
|
||||||
|
Revises: 2053ab8ed764
|
||||||
|
Create Date: 2026-03-09 01:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "d7649c1f8e21"
|
||||||
|
down_revision = "2053ab8ed764"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TABLE = "video_tasks"
|
||||||
|
_FK_NAME = "video_tasks_key_id_fkey"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_fk_rules([_TABLE])
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
_FK_NAME,
|
||||||
|
_TABLE,
|
||||||
|
"provider_api_keys",
|
||||||
|
["key_id"],
|
||||||
|
["id"],
|
||||||
|
"SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
c = _SchemaCache()
|
||||||
|
c.load_fk_rules([_TABLE])
|
||||||
|
_replace_fk_if_needed(
|
||||||
|
c,
|
||||||
|
_FK_NAME,
|
||||||
|
_TABLE,
|
||||||
|
"provider_api_keys",
|
||||||
|
["key_id"],
|
||||||
|
["id"],
|
||||||
|
"NO ACTION",
|
||||||
|
)
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
"""Strip request_results_window from health_by_format JSON.
|
||||||
|
|
||||||
|
This data is now maintained in process memory only, no longer persisted to DB.
|
||||||
|
|
||||||
|
Revision ID: a3f1b7c9d2e4
|
||||||
|
Revises: d7649c1f8e21
|
||||||
|
Create Date: 2026-03-10 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a3f1b7c9d2e4"
|
||||||
|
down_revision = "d7649c1f8e21"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
fmt_key,
|
||||||
|
fmt_value - 'request_results_window'
|
||||||
|
)
|
||||||
|
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||||
|
)
|
||||||
|
WHERE health_by_format IS NOT NULL
|
||||||
|
AND health_by_format != '{}'::jsonb
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||||
|
WHERE fmt_value ? 'request_results_window'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op: window data is rebuilt from scratch on process start
|
||||||
|
pass
|
||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
"""tighten usage billing state machine
|
||||||
|
|
||||||
|
Revision ID: 9e4f1a2b3c4d
|
||||||
|
Revises: a3f1b7c9d2e4
|
||||||
|
Create Date: 2026-03-11 19:00:00.000000+00:00
|
||||||
|
|
||||||
|
This migration does two things:
|
||||||
|
1. Change new `usage.billing_status` default from `settled` to `pending`.
|
||||||
|
2. Repair only the clearly-safe inconsistent historical rows for production:
|
||||||
|
- failed/cancelled zero-cost rows that were marked settled are converted to void
|
||||||
|
- terminal rows missing finalized_at are backfilled from created_at
|
||||||
|
|
||||||
|
Ambiguous positive-cost settled rows are intentionally left untouched for manual audit.
|
||||||
|
|
||||||
|
All data updates are batched (10000 rows per iteration) to avoid long-held locks
|
||||||
|
and excessive WAL generation on large usage tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9e4f1a2b3c4d"
|
||||||
|
down_revision: str | None = "a3f1b7c9d2e4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
BATCH_SIZE = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _table_exists("usage"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _column_exists("usage", "billing_status"):
|
||||||
|
op.alter_column(
|
||||||
|
"usage",
|
||||||
|
"billing_status",
|
||||||
|
existing_type=sa.String(length=20),
|
||||||
|
server_default="pending",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
required_columns = {
|
||||||
|
"billing_status",
|
||||||
|
"status",
|
||||||
|
"total_cost_usd",
|
||||||
|
"request_cost_usd",
|
||||||
|
"actual_total_cost_usd",
|
||||||
|
"actual_request_cost_usd",
|
||||||
|
"wallet_balance_after",
|
||||||
|
"finalized_at",
|
||||||
|
"created_at",
|
||||||
|
}
|
||||||
|
if not required_columns.issubset(
|
||||||
|
{col for col in required_columns if _column_exists("usage", col)}
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Step 1: billing_status IS NULL -> 'pending' (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET billing_status = 'pending'
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Step 2: failed/cancelled zero-cost settled -> void (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status = 'settled'
|
||||||
|
AND status IN ('failed', 'cancelled')
|
||||||
|
AND COALESCE(total_cost_usd, 0) = 0
|
||||||
|
AND wallet_balance_after IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET billing_status = 'void',
|
||||||
|
finalized_at = COALESCE(usage.finalized_at, usage.created_at),
|
||||||
|
total_cost_usd = 0,
|
||||||
|
request_cost_usd = 0,
|
||||||
|
actual_total_cost_usd = 0,
|
||||||
|
actual_request_cost_usd = 0
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Step 3: backfill finalized_at for terminal rows (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status IN ('settled', 'void')
|
||||||
|
AND finalized_at IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET finalized_at = COALESCE(usage.finalized_at, usage.created_at)
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not _table_exists("usage") or not _column_exists("usage", "billing_status"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.alter_column(
|
||||||
|
"usage",
|
||||||
|
"billing_status",
|
||||||
|
existing_type=sa.String(length=20),
|
||||||
|
server_default="settled",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
"""add wallet daily usage ledgers
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: 9e4f1a2b3c4d
|
||||||
|
Create Date: 2026-03-11 21:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "d4e5f6a7b8c9"
|
||||||
|
down_revision: str | None = "9e4f1a2b3c4d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _table_exists("wallet_daily_usage_ledgers"):
|
||||||
|
op.create_table(
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("wallet_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("billing_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("billing_timezone", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("total_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("first_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"wallet_id",
|
||||||
|
"billing_date",
|
||||||
|
"billing_timezone",
|
||||||
|
name="uq_wallet_daily_usage_ledgers_wallet_date_tz",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_wallet_daily_usage_wallet_date",
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
["wallet_id", "billing_date"],
|
||||||
|
)
|
||||||
|
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_wallet_daily_usage_date",
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
["billing_date"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
_table_exists("usage")
|
||||||
|
and all(
|
||||||
|
_column_exists("usage", col) for col in ["billing_status", "finalized_at", "wallet_id"]
|
||||||
|
)
|
||||||
|
and not _index_exists("usage", "idx_usage_billing_finalized_wallet")
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
"idx_usage_billing_finalized_wallet",
|
||||||
|
"usage",
|
||||||
|
["billing_status", "finalized_at", "wallet_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _table_exists("usage") and _index_exists("usage", "idx_usage_billing_finalized_wallet"):
|
||||||
|
op.drop_index("idx_usage_billing_finalized_wallet", table_name="usage")
|
||||||
|
|
||||||
|
if _table_exists("wallet_daily_usage_ledgers"):
|
||||||
|
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||||
|
op.drop_index("idx_wallet_daily_usage_date", table_name="wallet_daily_usage_ledgers")
|
||||||
|
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||||
|
op.drop_index(
|
||||||
|
"idx_wallet_daily_usage_wallet_date",
|
||||||
|
table_name="wallet_daily_usage_ledgers",
|
||||||
|
)
|
||||||
|
op.drop_table("wallet_daily_usage_ledgers")
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
"""add provider_api_keys usage total columns
|
||||||
|
|
||||||
|
Revision ID: 9b7c6d5e4f3a
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-03-11 22:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9b7c6d5e4f3a"
|
||||||
|
down_revision: str | None = "d4e5f6a7b8c9"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("total_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
if column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.alter_column("provider_api_keys", "total_tokens", server_default=None)
|
||||||
|
|
||||||
|
if not column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"total_cost_usd",
|
||||||
|
sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0.0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.alter_column("provider_api_keys", "total_cost_usd", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.drop_column("provider_api_keys", "total_cost_usd")
|
||||||
|
|
||||||
|
if column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.drop_column("provider_api_keys", "total_tokens")
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
"""cleanup stale provider references after provider deletion
|
||||||
|
|
||||||
|
Revision ID: c1d2e3f4a5b6
|
||||||
|
Revises: 9b7c6d5e4f3a
|
||||||
|
Create Date: 2026-03-11 23:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c1d2e3f4a5b6"
|
||||||
|
down_revision = "9b7c6d5e4f3a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_users = sa.table(
|
||||||
|
"users",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("allowed_providers", sa.JSON()),
|
||||||
|
)
|
||||||
|
_api_keys = sa.table(
|
||||||
|
"api_keys",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("allowed_providers", sa.JSON()),
|
||||||
|
)
|
||||||
|
_user_preferences = sa.table(
|
||||||
|
"user_preferences",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("default_provider_id", sa.String(36)),
|
||||||
|
)
|
||||||
|
_video_tasks = sa.table(
|
||||||
|
"video_tasks",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("provider_id", sa.String(36)),
|
||||||
|
sa.column("endpoint_id", sa.String(36)),
|
||||||
|
)
|
||||||
|
_providers = sa.table("providers", sa.column("id", sa.String(36)))
|
||||||
|
_provider_endpoints = sa.table("provider_endpoints", sa.column("id", sa.String(36)))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_valid_ids(conn: sa.Connection, table: sa.Table) -> set[str]:
|
||||||
|
return {str(row[0]) for row in conn.execute(sa.select(table.c.id)).fetchall() if row[0]}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_allowed_providers(
|
||||||
|
conn: sa.Connection,
|
||||||
|
table: sa.Table,
|
||||||
|
valid_provider_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(table.c.id, table.c.allowed_providers).where(
|
||||||
|
table.c.allowed_providers.isnot(None)
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for row_id, allowed_providers in rows:
|
||||||
|
if not isinstance(allowed_providers, list):
|
||||||
|
continue
|
||||||
|
filtered = [
|
||||||
|
provider_id for provider_id in allowed_providers if provider_id in valid_provider_ids
|
||||||
|
]
|
||||||
|
if filtered == allowed_providers:
|
||||||
|
continue
|
||||||
|
conn.execute(table.update().where(table.c.id == row_id).values(allowed_providers=filtered))
|
||||||
|
|
||||||
|
|
||||||
|
def _nullify_missing_fk(
|
||||||
|
conn: sa.Connection,
|
||||||
|
table: sa.Table,
|
||||||
|
id_column: sa.ColumnElement[str],
|
||||||
|
fk_column: sa.ColumnElement[str],
|
||||||
|
valid_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
rows = conn.execute(sa.select(id_column, fk_column).where(fk_column.isnot(None))).fetchall()
|
||||||
|
invalid_row_ids = [row_id for row_id, fk_value in rows if fk_value not in valid_ids]
|
||||||
|
if not invalid_row_ids:
|
||||||
|
return
|
||||||
|
conn.execute(table.update().where(id_column.in_(invalid_row_ids)).values({fk_column.key: None}))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
valid_provider_ids = _load_valid_ids(conn, _providers)
|
||||||
|
valid_endpoint_ids = _load_valid_ids(conn, _provider_endpoints)
|
||||||
|
|
||||||
|
_cleanup_allowed_providers(conn, _users, valid_provider_ids)
|
||||||
|
_cleanup_allowed_providers(conn, _api_keys, valid_provider_ids)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_user_preferences,
|
||||||
|
_user_preferences.c.id,
|
||||||
|
_user_preferences.c.default_provider_id,
|
||||||
|
valid_provider_ids,
|
||||||
|
)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_video_tasks,
|
||||||
|
_video_tasks.c.id,
|
||||||
|
_video_tasks.c.provider_id,
|
||||||
|
valid_provider_ids,
|
||||||
|
)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_video_tasks,
|
||||||
|
_video_tasks.c.id,
|
||||||
|
_video_tasks.c.endpoint_id,
|
||||||
|
valid_endpoint_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: c1d2e3f4a5b6
|
||||||
|
Create Date: 2026-03-12 19:15:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b7c8d9e0f1a2"
|
||||||
|
down_revision = "c1d2e3f4a5b6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||||
|
op.drop_constraint(
|
||||||
|
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE request_candidates rc "
|
||||||
|
"SET key_id = NULL "
|
||||||
|
"WHERE key_id IS NOT NULL "
|
||||||
|
" AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
|
||||||
|
" )"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"request_candidates_key_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"provider_api_keys",
|
||||||
|
["key_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""add user rate_limit and backfill normal api key limits
|
||||||
|
|
||||||
|
Revision ID: b7e8f9a0c1d2
|
||||||
|
Revises: b7c8d9e0f1a2
|
||||||
|
Create Date: 2026-03-13 12:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b7e8f9a0c1d2"
|
||||||
|
down_revision: str | None = "b7c8d9e0f1a2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("users", "rate_limit"):
|
||||||
|
op.add_column("users", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 普通 Key 新语义不再允许 NULL;存量 NULL 统一回填为 0(不限制)。
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET rate_limit = 0
|
||||||
|
WHERE is_standalone = FALSE
|
||||||
|
AND rate_limit IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复普通 Key 的 rate_limit 为 NULL(与 upgrade 中回填 0 对应)
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET rate_limit = NULL
|
||||||
|
WHERE is_standalone = FALSE
|
||||||
|
AND rate_limit = 0
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if column_exists("users", "rate_limit"):
|
||||||
|
op.drop_column("users", "rate_limit")
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
"""add user sessions table for device-level auth
|
||||||
|
|
||||||
|
Revision ID: f6e7d8c9b0a1
|
||||||
|
Revises: b7e8f9a0c1d2
|
||||||
|
Create Date: 2026-03-15 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f6e7d8c9b0a1"
|
||||||
|
down_revision = "b7e8f9a0c1d2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "user_sessions" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_sessions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("client_device_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("device_label", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("device_type", sa.String(length=20), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("browser_name", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("browser_version", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("os_name", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("os_version", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("device_model", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(length=45), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("client_hints", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("prev_refresh_token_hash", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoke_reason", sa.String(length=100), nullable=True),
|
||||||
|
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()
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_sessions_client_device_id",
|
||||||
|
"user_sessions",
|
||||||
|
["client_device_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_user_sessions_user_active",
|
||||||
|
"user_sessions",
|
||||||
|
["user_id", "revoked_at", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_user_sessions_user_device",
|
||||||
|
"user_sessions",
|
||||||
|
["user_id", "client_device_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_user_sessions_user_device", table_name="user_sessions")
|
||||||
|
op.drop_index("idx_user_sessions_user_active", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_user_sessions_client_device_id", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
|
||||||
|
op.drop_table("user_sessions")
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"""add status_snapshot column to provider_api_keys
|
||||||
|
|
||||||
|
Revision ID: c9d8e7f6a5b4
|
||||||
|
Revises: f6e7d8c9b0a1
|
||||||
|
Create Date: 2026-03-20 12:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c9d8e7f6a5b4"
|
||||||
|
down_revision: str | None = "f6e7d8c9b0a1"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("status_snapshot", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.drop_column("provider_api_keys", "status_snapshot")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Aether - 数据库迁移说明
|
||||||
|
|
||||||
|
## 当前版本
|
||||||
|
|
||||||
|
- **Revision ID**: `aether_baseline`
|
||||||
|
- **创建日期**: 2025-12-06
|
||||||
|
- **状态**: 全新基线
|
||||||
|
|
||||||
|
## 迁移历史
|
||||||
|
|
||||||
|
所有历史增量迁移已清理,当前以完整 schema 作为新起点。
|
||||||
|
|
||||||
|
## 核心数据库结构
|
||||||
|
|
||||||
|
### 用户系统
|
||||||
|
- **users**: 用户账户管理
|
||||||
|
- **api_keys**: API 密钥管理
|
||||||
|
- **wallets**: 统一钱包账户(充值余额/赠款余额/无限制模式)
|
||||||
|
- **user_preferences**: 用户偏好设置
|
||||||
|
|
||||||
|
### Provider 三层架构
|
||||||
|
- **providers**: LLM 提供商配置
|
||||||
|
- **provider_endpoints**: Provider 的 API 端点配置
|
||||||
|
- **provider_api_keys**: Endpoint 的具体 API 密钥
|
||||||
|
- **api_key_provider_mappings**: 用户 API Key 到 Provider 的映射关系
|
||||||
|
|
||||||
|
### 模型系统
|
||||||
|
- **global_models**: 统一模型定义(GlobalModel)
|
||||||
|
- **models**: Provider 的模型实现和价格配置
|
||||||
|
- **model_mappings**: 统一的别名与降级映射表
|
||||||
|
|
||||||
|
### 监控和追踪
|
||||||
|
- **usage**: API 使用记录
|
||||||
|
- **request_candidates**: 请求候选记录
|
||||||
|
- **provider_usage_tracking**: Provider 使用统计
|
||||||
|
- **audit_logs**: 系统审计日志
|
||||||
|
|
||||||
|
### 系统功能
|
||||||
|
- **announcements**: 系统公告
|
||||||
|
- **announcement_reads**: 公告阅读记录
|
||||||
|
- **system_configs**: 系统配置
|
||||||
|
|
||||||
|
## 从旧数据库迁移
|
||||||
|
|
||||||
|
如需从旧数据库迁移数据,请使用迁移脚本:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置环境变量
|
||||||
|
export OLD_DATABASE_URL="postgresql://user:pass@old-host:5432/old_db"
|
||||||
|
export NEW_DATABASE_URL="postgresql://user:pass@new-host:5432/aether"
|
||||||
|
|
||||||
|
# 干运行(查看迁移量)
|
||||||
|
python scripts/migrate_data.py --dry-run
|
||||||
|
|
||||||
|
# 执行迁移
|
||||||
|
python scripts/migrate_data.py
|
||||||
|
|
||||||
|
# 只迁移特定表
|
||||||
|
python scripts/migrate_data.py --tables users,providers,api_keys
|
||||||
|
|
||||||
|
# 跳过大表
|
||||||
|
python scripts/migrate_data.py --skip usage,audit_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新数据库初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 运行迁移创建表结构
|
||||||
|
DATABASE_URL="postgresql://user:pass@host:5432/aether" uv run alembic upgrade head
|
||||||
|
|
||||||
|
# 2. 初始化管理员账户
|
||||||
|
python -m src.database.init_db
|
||||||
|
```
|
||||||
|
|
||||||
|
## 未来迁移
|
||||||
|
|
||||||
|
基于 `aether_baseline` 创建增量迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 修改模型后,生成新的迁移
|
||||||
|
DATABASE_URL="..." uv run alembic revision --autogenerate -m "描述变更"
|
||||||
|
|
||||||
|
# 应用迁移
|
||||||
|
DATABASE_URL="..." uv run alembic upgrade head
|
||||||
|
```
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
"""Admin API routers.
|
||||||
|
|
||||||
|
The admin surface remains Python-only host/control-plane scope. It is not part
|
||||||
|
of the compatibility frontdoor manifest that Rust is preparing to absorb.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from starlette.routing import BaseRoute
|
||||||
|
|
||||||
|
from .adaptive import router as adaptive_router
|
||||||
|
from .api_keys import router as api_keys_router
|
||||||
|
from .billing import router as billing_router
|
||||||
|
from .endpoints import router as endpoints_router
|
||||||
|
from .models import router as models_router
|
||||||
|
from .modules import router as modules_router
|
||||||
|
from .monitoring import router as monitoring_router
|
||||||
|
from .payments import router as payments_router
|
||||||
|
from .pool import router as pool_router
|
||||||
|
from .provider_oauth import router as provider_oauth_router
|
||||||
|
from .provider_ops import router as provider_ops_router
|
||||||
|
from .provider_query import router as provider_query_router
|
||||||
|
from .provider_strategy import router as provider_strategy_router
|
||||||
|
from .providers import router as providers_router
|
||||||
|
from .security import router as security_router
|
||||||
|
from .stats import router as stats_router
|
||||||
|
from .system import router as system_router
|
||||||
|
from .usage import router as usage_router
|
||||||
|
from .users import router as users_router
|
||||||
|
from .video_tasks import router as video_tasks_router
|
||||||
|
from .wallets import router as wallets_router
|
||||||
|
|
||||||
|
_RUST_OWNED_ADMIN_ROUTE_SIGNATURES = frozenset(
|
||||||
|
{
|
||||||
|
("GET", "/api/admin/modules/status"),
|
||||||
|
("GET", "/api/admin/modules/status/{module_name}"),
|
||||||
|
("PUT", "/api/admin/modules/status/{module_name}/enabled"),
|
||||||
|
("GET", "/api/admin/system/version"),
|
||||||
|
("GET", "/api/admin/system/check-update"),
|
||||||
|
("GET", "/api/admin/system/aws-regions"),
|
||||||
|
("GET", "/api/admin/system/stats"),
|
||||||
|
("GET", "/api/admin/system/settings"),
|
||||||
|
("GET", "/api/admin/system/config/export"),
|
||||||
|
("GET", "/api/admin/system/users/export"),
|
||||||
|
("POST", "/api/admin/system/config/import"),
|
||||||
|
("POST", "/api/admin/system/users/import"),
|
||||||
|
("POST", "/api/admin/system/smtp/test"),
|
||||||
|
("POST", "/api/admin/system/cleanup"),
|
||||||
|
("POST", "/api/admin/system/purge/config"),
|
||||||
|
("POST", "/api/admin/system/purge/users"),
|
||||||
|
("POST", "/api/admin/system/purge/usage"),
|
||||||
|
("POST", "/api/admin/system/purge/audit-logs"),
|
||||||
|
("POST", "/api/admin/system/purge/request-bodies"),
|
||||||
|
("POST", "/api/admin/system/purge/stats"),
|
||||||
|
("PUT", "/api/admin/system/settings"),
|
||||||
|
("GET", "/api/admin/system/configs"),
|
||||||
|
("GET", "/api/admin/system/configs/{key}"),
|
||||||
|
("PUT", "/api/admin/system/configs/{key}"),
|
||||||
|
("DELETE", "/api/admin/system/configs/{key}"),
|
||||||
|
("GET", "/api/admin/system/api-formats"),
|
||||||
|
("GET", "/api/admin/system/email/templates"),
|
||||||
|
("GET", "/api/admin/system/email/templates/{template_type}"),
|
||||||
|
("PUT", "/api/admin/system/email/templates/{template_type}"),
|
||||||
|
("POST", "/api/admin/system/email/templates/{template_type}/preview"),
|
||||||
|
("POST", "/api/admin/system/email/templates/{template_type}/reset"),
|
||||||
|
("GET", "/api/admin/providers/"),
|
||||||
|
("POST", "/api/admin/providers/"),
|
||||||
|
("PATCH", "/api/admin/providers/{provider_id}"),
|
||||||
|
("DELETE", "/api/admin/providers/{provider_id}"),
|
||||||
|
("GET", "/api/admin/providers/summary"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/summary"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/health-monitor"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/mapping-preview"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/delete-task/{task_id}"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/pool-status"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/pool/clear-cooldown/{key_id}"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/pool/reset-cost/{key_id}"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/models"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/models"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||||
|
("PATCH", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||||
|
("DELETE", "/api/admin/providers/{provider_id}/models/{model_id}"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/models/batch"),
|
||||||
|
("GET", "/api/admin/providers/{provider_id}/available-source-models"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/assign-global-models"),
|
||||||
|
("POST", "/api/admin/providers/{provider_id}/import-from-upstream"),
|
||||||
|
("GET", "/api/admin/endpoints/providers/{provider_id}/endpoints"),
|
||||||
|
("POST", "/api/admin/endpoints/providers/{provider_id}/endpoints"),
|
||||||
|
("GET", "/api/admin/endpoints/defaults/{api_format}/body-rules"),
|
||||||
|
("GET", "/api/admin/endpoints/{endpoint_id}"),
|
||||||
|
("PUT", "/api/admin/endpoints/{endpoint_id}"),
|
||||||
|
("DELETE", "/api/admin/endpoints/{endpoint_id}"),
|
||||||
|
("PUT", "/api/admin/endpoints/keys/{key_id}"),
|
||||||
|
("GET", "/api/admin/endpoints/keys/grouped-by-format"),
|
||||||
|
("GET", "/api/admin/endpoints/keys/{key_id}/reveal"),
|
||||||
|
("GET", "/api/admin/endpoints/keys/{key_id}/export"),
|
||||||
|
("DELETE", "/api/admin/endpoints/keys/{key_id}"),
|
||||||
|
("POST", "/api/admin/endpoints/keys/batch-delete"),
|
||||||
|
("POST", "/api/admin/endpoints/keys/{key_id}/clear-oauth-invalid"),
|
||||||
|
("GET", "/api/admin/endpoints/providers/{provider_id}/keys"),
|
||||||
|
("POST", "/api/admin/endpoints/providers/{provider_id}/keys"),
|
||||||
|
("POST", "/api/admin/endpoints/providers/{provider_id}/refresh-quota"),
|
||||||
|
("GET", "/api/admin/endpoints/rpm/key/{key_id}"),
|
||||||
|
("DELETE", "/api/admin/endpoints/rpm/key/{key_id}"),
|
||||||
|
("GET", "/api/admin/endpoints/health/summary"),
|
||||||
|
("GET", "/api/admin/endpoints/health/status"),
|
||||||
|
("GET", "/api/admin/endpoints/health/api-formats"),
|
||||||
|
("GET", "/api/admin/endpoints/health/key/{key_id}"),
|
||||||
|
("PATCH", "/api/admin/endpoints/health/keys/{key_id}"),
|
||||||
|
("PATCH", "/api/admin/endpoints/health/keys"),
|
||||||
|
("GET", "/api/admin/provider-oauth/supported-types"),
|
||||||
|
("POST", "/api/admin/provider-oauth/keys/{key_id}/start"),
|
||||||
|
("POST", "/api/admin/provider-oauth/keys/{key_id}/complete"),
|
||||||
|
("POST", "/api/admin/provider-oauth/keys/{key_id}/refresh"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/start"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/complete"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/import-refresh-token"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/device-authorize"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/device-poll"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/batch-import"),
|
||||||
|
("POST", "/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks"),
|
||||||
|
("GET", "/api/admin/provider-oauth/providers/{provider_id}/batch-import/tasks/{task_id}"),
|
||||||
|
("GET", "/api/admin/adaptive/keys"),
|
||||||
|
("PATCH", "/api/admin/adaptive/keys/{key_id}/mode"),
|
||||||
|
("GET", "/api/admin/adaptive/keys/{key_id}/stats"),
|
||||||
|
("DELETE", "/api/admin/adaptive/keys/{key_id}/learning"),
|
||||||
|
("PATCH", "/api/admin/adaptive/keys/{key_id}/limit"),
|
||||||
|
("GET", "/api/admin/adaptive/summary"),
|
||||||
|
("GET", "/api/admin/provider-ops/architectures"),
|
||||||
|
("GET", "/api/admin/provider-ops/architectures/{architecture_id}"),
|
||||||
|
("GET", "/api/admin/provider-ops/providers/{provider_id}/status"),
|
||||||
|
("GET", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||||
|
("PUT", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||||
|
("DELETE", "/api/admin/provider-ops/providers/{provider_id}/config"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/connect"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/disconnect"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/verify"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/actions/{action_type}"),
|
||||||
|
("GET", "/api/admin/provider-ops/providers/{provider_id}/balance"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/balance"),
|
||||||
|
("POST", "/api/admin/provider-ops/providers/{provider_id}/checkin"),
|
||||||
|
("POST", "/api/admin/provider-ops/batch/balance"),
|
||||||
|
("GET", "/api/admin/billing/presets"),
|
||||||
|
("POST", "/api/admin/billing/presets/apply"),
|
||||||
|
("GET", "/api/admin/billing/rules"),
|
||||||
|
("GET", "/api/admin/billing/rules/{rule_id}"),
|
||||||
|
("POST", "/api/admin/billing/rules"),
|
||||||
|
("PUT", "/api/admin/billing/rules/{rule_id}"),
|
||||||
|
("GET", "/api/admin/billing/collectors"),
|
||||||
|
("GET", "/api/admin/billing/collectors/{collector_id}"),
|
||||||
|
("POST", "/api/admin/billing/collectors"),
|
||||||
|
("PUT", "/api/admin/billing/collectors/{collector_id}"),
|
||||||
|
("PUT", "/api/admin/provider-strategy/providers/{provider_id}/billing"),
|
||||||
|
("GET", "/api/admin/provider-strategy/providers/{provider_id}/stats"),
|
||||||
|
("GET", "/api/admin/provider-strategy/strategies"),
|
||||||
|
("DELETE", "/api/admin/provider-strategy/providers/{provider_id}/quota"),
|
||||||
|
("POST", "/api/admin/provider-query/models"),
|
||||||
|
("POST", "/api/admin/provider-query/test-model"),
|
||||||
|
("POST", "/api/admin/provider-query/test-model-failover"),
|
||||||
|
("GET", "/api/admin/payments/orders"),
|
||||||
|
("GET", "/api/admin/payments/orders/{order_id}"),
|
||||||
|
("POST", "/api/admin/payments/orders/{order_id}/expire"),
|
||||||
|
("POST", "/api/admin/payments/orders/{order_id}/credit"),
|
||||||
|
("POST", "/api/admin/payments/orders/{order_id}/fail"),
|
||||||
|
("GET", "/api/admin/payments/callbacks"),
|
||||||
|
("POST", "/api/admin/security/ip/blacklist"),
|
||||||
|
("DELETE", "/api/admin/security/ip/blacklist/{ip_address}"),
|
||||||
|
("GET", "/api/admin/security/ip/blacklist/stats"),
|
||||||
|
("POST", "/api/admin/security/ip/whitelist"),
|
||||||
|
("DELETE", "/api/admin/security/ip/whitelist/{ip_address}"),
|
||||||
|
("GET", "/api/admin/security/ip/whitelist"),
|
||||||
|
("GET", "/api/admin/stats/providers/quota-usage"),
|
||||||
|
("GET", "/api/admin/stats/comparison"),
|
||||||
|
("GET", "/api/admin/stats/errors/distribution"),
|
||||||
|
("GET", "/api/admin/stats/performance/percentiles"),
|
||||||
|
("GET", "/api/admin/stats/cost/forecast"),
|
||||||
|
("GET", "/api/admin/stats/cost/savings"),
|
||||||
|
("GET", "/api/admin/stats/leaderboard/api-keys"),
|
||||||
|
("GET", "/api/admin/stats/leaderboard/models"),
|
||||||
|
("GET", "/api/admin/stats/leaderboard/users"),
|
||||||
|
("GET", "/api/admin/stats/time-series"),
|
||||||
|
("GET", "/api/admin/monitoring/audit-logs"),
|
||||||
|
("GET", "/api/admin/monitoring/system-status"),
|
||||||
|
("GET", "/api/admin/monitoring/suspicious-activities"),
|
||||||
|
("GET", "/api/admin/monitoring/user-behavior/{user_id}"),
|
||||||
|
("GET", "/api/admin/monitoring/resilience-status"),
|
||||||
|
("GET", "/api/admin/monitoring/resilience/circuit-history"),
|
||||||
|
("DELETE", "/api/admin/monitoring/resilience/error-stats"),
|
||||||
|
("GET", "/api/admin/monitoring/trace/{request_id}"),
|
||||||
|
("GET", "/api/admin/monitoring/trace/stats/provider/{provider_id}"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/stats"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/affinity/{user_identifier}"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/affinities"),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache/users/{user_identifier}"),
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/api/admin/monitoring/cache/affinity/{affinity_key}/{endpoint_id}/{model_id}/{api_format}",
|
||||||
|
),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache"),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache/providers/{provider_id}"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/config"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/metrics"),
|
||||||
|
("GET", "/api/admin/monitoring/cache/model-mapping/stats"),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache/model-mapping"),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache/model-mapping/{model_name}"),
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/api/admin/monitoring/cache/model-mapping/provider/{provider_id}/{global_model_id}",
|
||||||
|
),
|
||||||
|
("GET", "/api/admin/monitoring/cache/redis-keys"),
|
||||||
|
("DELETE", "/api/admin/monitoring/cache/redis-keys/{category}"),
|
||||||
|
("GET", "/api/admin/usage/aggregation/stats"),
|
||||||
|
("GET", "/api/admin/usage/stats"),
|
||||||
|
("GET", "/api/admin/usage/heatmap"),
|
||||||
|
("GET", "/api/admin/usage/records"),
|
||||||
|
("GET", "/api/admin/usage/active"),
|
||||||
|
("GET", "/api/admin/usage/cache-affinity/hit-analysis"),
|
||||||
|
("GET", "/api/admin/usage/cache-affinity/interval-timeline"),
|
||||||
|
("GET", "/api/admin/usage/cache-affinity/ttl-analysis"),
|
||||||
|
("GET", "/api/admin/usage/{usage_id}/curl"),
|
||||||
|
("GET", "/api/admin/usage/{usage_id}"),
|
||||||
|
("POST", "/api/admin/usage/{usage_id}/replay"),
|
||||||
|
("GET", "/api/admin/video-tasks"),
|
||||||
|
("GET", "/api/admin/video-tasks/stats"),
|
||||||
|
("GET", "/api/admin/video-tasks/{task_id}"),
|
||||||
|
("POST", "/api/admin/video-tasks/{task_id}/cancel"),
|
||||||
|
("GET", "/api/admin/video-tasks/{task_id}/video"),
|
||||||
|
("GET", "/api/admin/wallets"),
|
||||||
|
("GET", "/api/admin/wallets/ledger"),
|
||||||
|
("GET", "/api/admin/wallets/refund-requests"),
|
||||||
|
("GET", "/api/admin/wallets/{wallet_id}"),
|
||||||
|
("GET", "/api/admin/wallets/{wallet_id}/transactions"),
|
||||||
|
("GET", "/api/admin/wallets/{wallet_id}/refunds"),
|
||||||
|
("POST", "/api/admin/wallets/{wallet_id}/adjust"),
|
||||||
|
("POST", "/api/admin/wallets/{wallet_id}/recharge"),
|
||||||
|
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/process"),
|
||||||
|
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/complete"),
|
||||||
|
("POST", "/api/admin/wallets/{wallet_id}/refunds/{refund_id}/fail"),
|
||||||
|
("GET", "/api/admin/api-keys"),
|
||||||
|
("POST", "/api/admin/api-keys"),
|
||||||
|
("GET", "/api/admin/api-keys/{key_id}"),
|
||||||
|
("PUT", "/api/admin/api-keys/{key_id}"),
|
||||||
|
("PATCH", "/api/admin/api-keys/{key_id}"),
|
||||||
|
("DELETE", "/api/admin/api-keys/{key_id}"),
|
||||||
|
("GET", "/api/admin/users"),
|
||||||
|
("POST", "/api/admin/users"),
|
||||||
|
("GET", "/api/admin/users/{user_id}"),
|
||||||
|
("PUT", "/api/admin/users/{user_id}"),
|
||||||
|
("DELETE", "/api/admin/users/{user_id}"),
|
||||||
|
("GET", "/api/admin/users/{user_id}/sessions"),
|
||||||
|
("DELETE", "/api/admin/users/{user_id}/sessions"),
|
||||||
|
("DELETE", "/api/admin/users/{user_id}/sessions/{session_id}"),
|
||||||
|
("GET", "/api/admin/users/{user_id}/api-keys"),
|
||||||
|
("POST", "/api/admin/users/{user_id}/api-keys"),
|
||||||
|
("DELETE", "/api/admin/users/{user_id}/api-keys/{key_id}"),
|
||||||
|
("PUT", "/api/admin/users/{user_id}/api-keys/{key_id}"),
|
||||||
|
("PATCH", "/api/admin/users/{user_id}/api-keys/{key_id}/lock"),
|
||||||
|
("GET", "/api/admin/users/{user_id}/api-keys/{key_id}/full-key"),
|
||||||
|
("GET", "/api/admin/pool/overview"),
|
||||||
|
("GET", "/api/admin/pool/scheduling-presets"),
|
||||||
|
("GET", "/api/admin/pool/{provider_id}/keys"),
|
||||||
|
("GET", "/api/admin/pool/{provider_id}/keys/batch-delete-task/{task_id}"),
|
||||||
|
("POST", "/api/admin/pool/{provider_id}/keys/batch-action"),
|
||||||
|
("POST", "/api/admin/pool/{provider_id}/keys/batch-import"),
|
||||||
|
("POST", "/api/admin/pool/{provider_id}/keys/cleanup-banned"),
|
||||||
|
("POST", "/api/admin/pool/{provider_id}/keys/resolve-selection"),
|
||||||
|
("GET", "/api/admin/proxy-nodes"),
|
||||||
|
("GET", "/api/admin/models/catalog"),
|
||||||
|
("GET", "/api/admin/models/external"),
|
||||||
|
("DELETE", "/api/admin/models/external/cache"),
|
||||||
|
("GET", "/api/admin/models/global"),
|
||||||
|
("POST", "/api/admin/models/global"),
|
||||||
|
("GET", "/api/admin/models/global/{global_model_id}"),
|
||||||
|
("PATCH", "/api/admin/models/global/{global_model_id}"),
|
||||||
|
("DELETE", "/api/admin/models/global/{global_model_id}"),
|
||||||
|
("POST", "/api/admin/models/global/batch-delete"),
|
||||||
|
("POST", "/api/admin/models/global/{global_model_id}/assign-to-providers"),
|
||||||
|
("GET", "/api/admin/models/global/{global_model_id}/providers"),
|
||||||
|
("GET", "/api/admin/models/global/{global_model_id}/routing"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_is_rust_owned(route: BaseRoute) -> bool:
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
methods = getattr(route, "methods", None)
|
||||||
|
if not isinstance(path, str) or not methods:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
(method, path) in _RUST_OWNED_ADMIN_ROUTE_SIGNATURES
|
||||||
|
for method in methods
|
||||||
|
if method not in {"HEAD", "OPTIONS"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_python_admin_router() -> APIRouter:
|
||||||
|
"""Admin/control-plane routes that still require the Python host."""
|
||||||
|
admin_router = APIRouter()
|
||||||
|
admin_router.include_router(system_router)
|
||||||
|
admin_router.include_router(users_router)
|
||||||
|
admin_router.include_router(providers_router)
|
||||||
|
admin_router.include_router(api_keys_router)
|
||||||
|
admin_router.include_router(billing_router)
|
||||||
|
admin_router.include_router(usage_router)
|
||||||
|
admin_router.include_router(monitoring_router)
|
||||||
|
admin_router.include_router(payments_router)
|
||||||
|
admin_router.include_router(endpoints_router)
|
||||||
|
admin_router.include_router(provider_strategy_router)
|
||||||
|
admin_router.include_router(provider_oauth_router)
|
||||||
|
admin_router.include_router(adaptive_router)
|
||||||
|
admin_router.include_router(models_router)
|
||||||
|
admin_router.include_router(security_router)
|
||||||
|
admin_router.include_router(stats_router)
|
||||||
|
admin_router.include_router(provider_query_router)
|
||||||
|
admin_router.include_router(modules_router)
|
||||||
|
admin_router.include_router(pool_router)
|
||||||
|
admin_router.include_router(provider_ops_router)
|
||||||
|
admin_router.include_router(video_tasks_router)
|
||||||
|
admin_router.include_router(wallets_router)
|
||||||
|
admin_router.routes = [route for route in admin_router.routes if not _route_is_rust_owned(route)]
|
||||||
|
return admin_router
|
||||||
|
|
||||||
|
|
||||||
|
# Admin/control-plane 在本轮 frontdoor cutover 后仍保留在 Python 宿主。
|
||||||
|
python_admin_router = _build_python_admin_router()
|
||||||
|
router = python_admin_router
|
||||||
|
|
||||||
|
# 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
|
||||||
|
# - ldap_router: 当 LDAP_AVAILABLE=true 时注册
|
||||||
|
# - management_tokens_router: 当 MANAGEMENT_TOKENS_AVAILABLE=true 时注册
|
||||||
|
# - proxy_nodes_router: 当 PROXY_NODES_AVAILABLE=true 时注册
|
||||||
|
|
||||||
|
__all__ = ["python_admin_router", "router"]
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"""
|
||||||
|
自适应 RPM 管理 API 端点
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- 自适应模式由 rpm_limit 字段决定:
|
||||||
|
- rpm_limit = NULL:启用自适应模式,系统自动学习并调整 RPM 限制
|
||||||
|
- rpm_limit = 数字:固定限制模式,使用用户指定的 RPM 限制
|
||||||
|
- learned_rpm_limit:自适应模式下学习到的 RPM 限制值
|
||||||
|
- adaptive_mode 是计算字段,基于 rpm_limit 是否为 NULL
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session, load_only
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/adaptive", tags=["Adaptive RPM"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Pydantic Models ====================
|
||||||
|
|
||||||
|
|
||||||
|
class EnableAdaptiveRequest(BaseModel):
|
||||||
|
"""启用自适应模式请求"""
|
||||||
|
|
||||||
|
enabled: bool = Field(..., description="是否启用自适应模式(true=自适应,false=固定限制)")
|
||||||
|
fixed_limit: int | None = Field(
|
||||||
|
None, ge=1, le=100, description="固定 RPM 限制(仅当 enabled=false 时生效,1-100)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveStatsResponse(BaseModel):
|
||||||
|
"""自适应统计响应"""
|
||||||
|
|
||||||
|
adaptive_mode: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||||
|
rpm_limit: int | None = Field(None, description="用户配置的固定限制(NULL=自适应)")
|
||||||
|
effective_limit: int | None = Field(
|
||||||
|
None, description="当前有效限制(自适应使用学习值,固定使用配置值)"
|
||||||
|
)
|
||||||
|
learned_limit: int | None = Field(None, description="学习到的 RPM 限制")
|
||||||
|
concurrent_429_count: int
|
||||||
|
rpm_429_count: int
|
||||||
|
last_429_at: str | None
|
||||||
|
last_429_type: str | None
|
||||||
|
adjustment_count: int
|
||||||
|
recent_adjustments: list[dict]
|
||||||
|
# 置信度相关
|
||||||
|
learning_confidence: float | None = Field(None, description="学习置信度 (0.0-1.0)")
|
||||||
|
enforcement_active: bool | None = Field(None, description="是否正在执行本地 RPM 限制")
|
||||||
|
observation_count: int = Field(0, description="429 观察记录数")
|
||||||
|
header_observation_count: int = Field(0, description="带 header 的观察记录数")
|
||||||
|
latest_upstream_limit: int | None = Field(None, description="最近一次上游 header 限制值")
|
||||||
|
|
||||||
|
|
||||||
|
class KeyListItem(BaseModel):
|
||||||
|
"""Key 列表项"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str | None
|
||||||
|
provider_id: str
|
||||||
|
api_formats: list[str] = Field(default_factory=list)
|
||||||
|
is_adaptive: bool = Field(..., description="是否为自适应模式(rpm_limit=NULL)")
|
||||||
|
rpm_limit: int | None = Field(None, description="固定 RPM 限制(NULL=自适应)")
|
||||||
|
effective_limit: int | None = Field(None, description="当前有效限制")
|
||||||
|
learned_rpm_limit: int | None = Field(None, description="学习到的 RPM 限制")
|
||||||
|
concurrent_429_count: int
|
||||||
|
rpm_429_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== API Endpoints ====================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/keys",
|
||||||
|
response_model=list[KeyListItem],
|
||||||
|
summary="获取所有启用自适应模式的Key",
|
||||||
|
)
|
||||||
|
async def list_adaptive_keys(
|
||||||
|
request: Request,
|
||||||
|
provider_id: str | None = Query(None, description="按 Provider 过滤"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取所有启用自适应模式的Key列表
|
||||||
|
|
||||||
|
可选参数:
|
||||||
|
- provider_id: 按 Provider 过滤
|
||||||
|
"""
|
||||||
|
adapter = ListAdaptiveKeysAdapter(provider_id=provider_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/keys/{key_id}/mode",
|
||||||
|
summary="Toggle key's RPM control mode",
|
||||||
|
)
|
||||||
|
async def toggle_adaptive_mode(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Toggle the RPM control mode for a specific key
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- enabled: true=adaptive mode (rpm_limit=NULL), false=fixed limit mode
|
||||||
|
- fixed_limit: fixed limit value (required when enabled=false)
|
||||||
|
"""
|
||||||
|
adapter = ToggleAdaptiveModeAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/keys/{key_id}/stats",
|
||||||
|
response_model=AdaptiveStatsResponse,
|
||||||
|
summary="获取Key的自适应统计",
|
||||||
|
)
|
||||||
|
async def get_adaptive_stats(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取指定Key的自适应 RPM 统计信息
|
||||||
|
|
||||||
|
包括:
|
||||||
|
- 当前配置
|
||||||
|
- 学习到的限制
|
||||||
|
- 429错误统计
|
||||||
|
- 调整历史
|
||||||
|
"""
|
||||||
|
adapter = GetAdaptiveStatsAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/keys/{key_id}/learning",
|
||||||
|
summary="Reset key's learning state",
|
||||||
|
)
|
||||||
|
async def reset_adaptive_learning(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Reset the adaptive learning state for a specific key
|
||||||
|
|
||||||
|
Clears:
|
||||||
|
- Learned RPM limit (learned_rpm_limit)
|
||||||
|
- 429 error counts
|
||||||
|
- Adjustment history
|
||||||
|
|
||||||
|
Does not change:
|
||||||
|
- rpm_limit config (determines adaptive mode)
|
||||||
|
"""
|
||||||
|
adapter = ResetAdaptiveLearningAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/keys/{key_id}/limit",
|
||||||
|
summary="Set key to fixed RPM limit mode",
|
||||||
|
)
|
||||||
|
async def set_rpm_limit(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(..., ge=1, le=100, description="RPM limit value (1-100)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Set key to fixed RPM limit mode
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- After setting this value, key switches to fixed limit mode and won't auto-adjust
|
||||||
|
- To restore adaptive mode, use PATCH /keys/{key_id}/mode
|
||||||
|
"""
|
||||||
|
adapter = SetRPMLimitAdapter(key_id=key_id, limit=limit)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/summary",
|
||||||
|
summary="获取自适应 RPM 的全局统计",
|
||||||
|
)
|
||||||
|
async def get_adaptive_summary(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取自适应 RPM 的全局统计摘要
|
||||||
|
|
||||||
|
包括:
|
||||||
|
- 启用自适应模式的Key数量
|
||||||
|
- 总429错误数
|
||||||
|
- RPM 限制调整次数
|
||||||
|
"""
|
||||||
|
adapter = AdaptiveSummaryAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Pipeline 适配器 ====================
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ListAdaptiveKeysAdapter(AdminApiAdapter):
|
||||||
|
provider_id: str | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
# 自适应模式:rpm_limit = NULL
|
||||||
|
query = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.rpm_limit.is_(None))
|
||||||
|
if self.provider_id:
|
||||||
|
query = query.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||||
|
|
||||||
|
keys = query.all()
|
||||||
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
|
return [
|
||||||
|
KeyListItem(
|
||||||
|
id=key.id,
|
||||||
|
name=key.name,
|
||||||
|
provider_id=key.provider_id,
|
||||||
|
api_formats=key.api_formats or [],
|
||||||
|
is_adaptive=key.rpm_limit is None,
|
||||||
|
rpm_limit=key.rpm_limit,
|
||||||
|
effective_limit=adaptive_manager.get_effective_limit(key),
|
||||||
|
learned_rpm_limit=key.learned_rpm_limit,
|
||||||
|
concurrent_429_count=key.concurrent_429_count or 0,
|
||||||
|
rpm_429_count=key.rpm_429_count or 0,
|
||||||
|
)
|
||||||
|
for key in keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToggleAdaptiveModeAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
body = EnableAdaptiveRequest.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
if body.enabled:
|
||||||
|
# 启用自适应模式:将 rpm_limit 设为 NULL
|
||||||
|
key.rpm_limit = None
|
||||||
|
message = "已切换为自适应模式,系统将自动学习并调整 RPM 限制"
|
||||||
|
else:
|
||||||
|
# 禁用自适应模式:设置固定限制
|
||||||
|
if body.fixed_limit is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="禁用自适应模式时必须提供 fixed_limit 参数"
|
||||||
|
)
|
||||||
|
key.rpm_limit = body.fixed_limit
|
||||||
|
message = f"已切换为固定限制模式,RPM 限制设为 {body.fixed_limit}"
|
||||||
|
|
||||||
|
context.db.commit()
|
||||||
|
context.db.refresh(key)
|
||||||
|
|
||||||
|
is_adaptive = key.rpm_limit is None
|
||||||
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
|
return {
|
||||||
|
"message": message,
|
||||||
|
"key_id": key.id,
|
||||||
|
"is_adaptive": is_adaptive,
|
||||||
|
"rpm_limit": key.rpm_limit,
|
||||||
|
"effective_limit": adaptive_manager.get_effective_limit(key),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetAdaptiveStatsAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
|
stats = adaptive_manager.get_adjustment_stats(key)
|
||||||
|
|
||||||
|
# 转换字段名以匹配响应模型
|
||||||
|
return AdaptiveStatsResponse(
|
||||||
|
adaptive_mode=stats["adaptive_mode"],
|
||||||
|
rpm_limit=stats["rpm_limit"],
|
||||||
|
effective_limit=stats["effective_limit"],
|
||||||
|
learned_limit=stats["learned_limit"],
|
||||||
|
concurrent_429_count=stats["concurrent_429_count"],
|
||||||
|
rpm_429_count=stats["rpm_429_count"],
|
||||||
|
last_429_at=stats["last_429_at"],
|
||||||
|
last_429_type=stats["last_429_type"],
|
||||||
|
adjustment_count=stats["adjustment_count"],
|
||||||
|
recent_adjustments=stats["recent_adjustments"],
|
||||||
|
learning_confidence=stats.get("learning_confidence"),
|
||||||
|
enforcement_active=stats.get("enforcement_active"),
|
||||||
|
observation_count=stats.get("observation_count", 0),
|
||||||
|
header_observation_count=stats.get("header_observation_count", 0),
|
||||||
|
latest_upstream_limit=stats.get("latest_upstream_limit"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResetAdaptiveLearningAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
|
adaptive_manager = get_adaptive_rpm_manager()
|
||||||
|
adaptive_manager.reset_learning(context.db, key)
|
||||||
|
return {"message": "学习状态已重置", "key_id": key.id}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SetRPMLimitAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
key = context.db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
|
||||||
|
was_adaptive = key.rpm_limit is None
|
||||||
|
key.rpm_limit = self.limit
|
||||||
|
context.db.commit()
|
||||||
|
context.db.refresh(key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"已设置为固定限制模式,RPM 限制为 {self.limit}",
|
||||||
|
"key_id": key.id,
|
||||||
|
"is_adaptive": False,
|
||||||
|
"rpm_limit": key.rpm_limit,
|
||||||
|
"previous_mode": "adaptive" if was_adaptive else "fixed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveSummaryAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
is_adaptive = ProviderAPIKey.rpm_limit.is_(None)
|
||||||
|
|
||||||
|
# SQL 聚合获取 count / sum,避免全表 ORM 加载
|
||||||
|
total_keys, total_concurrent_429, total_rpm_429 = (
|
||||||
|
db.query(
|
||||||
|
func.count(ProviderAPIKey.id),
|
||||||
|
func.coalesce(func.sum(ProviderAPIKey.concurrent_429_count), 0),
|
||||||
|
func.coalesce(func.sum(ProviderAPIKey.rpm_429_count), 0),
|
||||||
|
)
|
||||||
|
.filter(is_adaptive)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
# adjustment_history 是 JSON 列,长度只能在 Python 侧统计;
|
||||||
|
# 只加载有历史记录的 key 的必要列
|
||||||
|
keys_with_history = (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.options(
|
||||||
|
load_only(ProviderAPIKey.id, ProviderAPIKey.name, ProviderAPIKey.adjustment_history)
|
||||||
|
)
|
||||||
|
.filter(is_adaptive, ProviderAPIKey.adjustment_history.isnot(None))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
total_adjustments = sum(len(key.adjustment_history or []) for key in keys_with_history)
|
||||||
|
|
||||||
|
recent_adjustments = []
|
||||||
|
for key in keys_with_history:
|
||||||
|
if key.adjustment_history:
|
||||||
|
for adj in key.adjustment_history[-3:]:
|
||||||
|
recent_adjustments.append(
|
||||||
|
{
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
**adj,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_adjustments.sort(key=lambda item: item.get("timestamp", ""), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_adaptive_keys": total_keys,
|
||||||
|
"total_concurrent_429_errors": int(total_concurrent_429),
|
||||||
|
"total_rpm_429_errors": int(total_rpm_429),
|
||||||
|
"total_adjustments": total_adjustments,
|
||||||
|
"recent_adjustments": recent_adjustments[:10],
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""API key admin routes export."""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
"""管理员独立余额 API Key 管理路由。
|
||||||
|
|
||||||
|
独立余额Key:不关联用户配额,可配置独立余额限制或无限额度,用于给非注册用户使用。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Literal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.concurrency import run_in_threadpool
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.config import config
|
||||||
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db, get_db_context
|
||||||
|
from src.models.api import CreateApiKeyRequest
|
||||||
|
from src.models.database import ApiKey, Usage, Wallet
|
||||||
|
from src.services.user.apikey import ApiKeyService
|
||||||
|
from src.services.user.bulk_cleanup import pre_clean_api_key
|
||||||
|
from src.services.wallet import WalletService
|
||||||
|
|
||||||
|
APP_TIMEZONE = ZoneInfo(config.app_timezone)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expiry_date(date_str: str | None) -> datetime | None:
|
||||||
|
"""解析过期日期字符串为 datetime 对象。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date_str: 日期字符串,支持 "YYYY-MM-DD" 或 ISO 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
datetime 对象(当天 23:59:59.999999,应用时区),或 None 如果输入为空
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
BadRequestException: 日期格式无效
|
||||||
|
"""
|
||||||
|
if not date_str or not date_str.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_str = date_str.strip()
|
||||||
|
|
||||||
|
# 尝试 YYYY-MM-DD 格式
|
||||||
|
try:
|
||||||
|
parsed_date = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
# 设置为当天结束时间 (23:59:59.999999,应用时区)
|
||||||
|
return parsed_date.replace(
|
||||||
|
hour=23, minute=59, second=59, microsecond=999999, tzinfo=APP_TIMEZONE
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 尝试完整 ISO 格式
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise InvalidRequestException(f"无效的日期格式: {date_str},请使用 YYYY-MM-DD 格式")
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standalone)"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_standalone_key_item(
|
||||||
|
api_key: ApiKey, *, total_tokens: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": api_key.id,
|
||||||
|
"user_id": api_key.user_id,
|
||||||
|
"name": api_key.name,
|
||||||
|
"key_display": api_key.get_display_key(),
|
||||||
|
"is_active": api_key.is_active,
|
||||||
|
"is_standalone": api_key.is_standalone,
|
||||||
|
"total_requests": api_key.total_requests,
|
||||||
|
"total_tokens": int(total_tokens or 0),
|
||||||
|
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||||
|
"rate_limit": api_key.rate_limit,
|
||||||
|
"allowed_providers": api_key.allowed_providers,
|
||||||
|
"allowed_api_formats": api_key.allowed_api_formats,
|
||||||
|
"allowed_models": api_key.allowed_models,
|
||||||
|
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
||||||
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||||
|
"created_at": api_key.created_at.isoformat(),
|
||||||
|
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
|
||||||
|
"auto_delete_on_expiry": api_key.auto_delete_on_expiry,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_standalone_api_keys_sync(
|
||||||
|
skip: int,
|
||||||
|
limit: int,
|
||||||
|
is_active: bool | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with get_db_context() as db:
|
||||||
|
query = db.query(ApiKey).filter(ApiKey.is_standalone == True)
|
||||||
|
if is_active is not None:
|
||||||
|
query = query.filter(ApiKey.is_active == is_active)
|
||||||
|
|
||||||
|
total = int(query.with_entities(func.count(ApiKey.id)).scalar() or 0)
|
||||||
|
api_keys = query.order_by(ApiKey.created_at.desc()).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
wallet_initialized = False
|
||||||
|
for api_key in api_keys:
|
||||||
|
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||||
|
if wallet is None:
|
||||||
|
_ensure_standalone_wallet(db, api_key)
|
||||||
|
wallet_initialized = True
|
||||||
|
if wallet_initialized:
|
||||||
|
db.commit()
|
||||||
|
for api_key in api_keys:
|
||||||
|
db.refresh(api_key)
|
||||||
|
|
||||||
|
token_map: dict[str, int] = {}
|
||||||
|
if api_keys:
|
||||||
|
stats_rows = (
|
||||||
|
db.query(
|
||||||
|
Usage.api_key_id,
|
||||||
|
func.sum(Usage.total_tokens).label("total_tokens"),
|
||||||
|
)
|
||||||
|
.filter(Usage.api_key_id.in_([api_key.id for api_key in api_keys]))
|
||||||
|
.group_by(Usage.api_key_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
token_map = {row.api_key_id: int(row.total_tokens or 0) for row in stats_rows}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_keys": [
|
||||||
|
_serialize_standalone_key_item(
|
||||||
|
api_key,
|
||||||
|
total_tokens=token_map.get(api_key.id, 0),
|
||||||
|
)
|
||||||
|
for api_key in api_keys
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"skip": skip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _create_standalone_api_key_sync(
|
||||||
|
admin_user_id: str,
|
||||||
|
key_data: CreateApiKeyRequest,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
with get_db_context() as db:
|
||||||
|
if key_data.initial_balance_usd is not None and key_data.initial_balance_usd <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="创建独立余额Key时,初始余额必须大于 0(或设置为 null 表示无限制)",
|
||||||
|
)
|
||||||
|
|
||||||
|
expires_at_dt = parse_expiry_date(key_data.expires_at)
|
||||||
|
api_key, plain_key = ApiKeyService.create_api_key(
|
||||||
|
db=db,
|
||||||
|
user_id=admin_user_id,
|
||||||
|
name=key_data.name,
|
||||||
|
allowed_providers=key_data.allowed_providers,
|
||||||
|
allowed_api_formats=key_data.allowed_api_formats,
|
||||||
|
allowed_models=key_data.allowed_models,
|
||||||
|
rate_limit=key_data.rate_limit,
|
||||||
|
expire_days=key_data.expire_days,
|
||||||
|
expires_at=expires_at_dt,
|
||||||
|
is_standalone=True,
|
||||||
|
auto_delete_on_expiry=key_data.auto_delete_on_expiry,
|
||||||
|
)
|
||||||
|
|
||||||
|
wallet = WalletService.initialize_api_key_wallet(
|
||||||
|
db,
|
||||||
|
api_key=api_key,
|
||||||
|
initial_balance_usd=key_data.initial_balance_usd,
|
||||||
|
unlimited=key_data.initial_balance_usd is None,
|
||||||
|
operator_id=admin_user_id,
|
||||||
|
description="独立密钥初始调账",
|
||||||
|
)
|
||||||
|
if wallet is None:
|
||||||
|
raise InvalidRequestException("独立密钥钱包初始化失败")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(api_key)
|
||||||
|
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"id": api_key.id,
|
||||||
|
"key": plain_key,
|
||||||
|
"name": api_key.name,
|
||||||
|
"key_display": api_key.get_display_key(),
|
||||||
|
"is_standalone": True,
|
||||||
|
"rate_limit": api_key.rate_limit,
|
||||||
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||||
|
"created_at": api_key.created_at.isoformat(),
|
||||||
|
"wallet": wallet_summary,
|
||||||
|
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "create_standalone_api_key",
|
||||||
|
"key_id": api_key.id,
|
||||||
|
"initial_balance_usd": key_data.initial_balance_usd,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_standalone_api_key_sync(
|
||||||
|
key_id: str,
|
||||||
|
key_data: CreateApiKeyRequest,
|
||||||
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
with get_db_context() as db:
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not api_key:
|
||||||
|
raise NotFoundException("API密钥不存在", "api_key")
|
||||||
|
if not api_key.is_standalone:
|
||||||
|
raise InvalidRequestException("仅支持更新独立密钥")
|
||||||
|
|
||||||
|
update_data: dict[str, Any] = {}
|
||||||
|
if key_data.name is not None:
|
||||||
|
update_data["name"] = key_data.name
|
||||||
|
if "rate_limit" in key_data.model_fields_set:
|
||||||
|
update_data["rate_limit"] = key_data.rate_limit
|
||||||
|
if (
|
||||||
|
hasattr(key_data, "auto_delete_on_expiry")
|
||||||
|
and key_data.auto_delete_on_expiry is not None
|
||||||
|
):
|
||||||
|
update_data["auto_delete_on_expiry"] = key_data.auto_delete_on_expiry
|
||||||
|
if hasattr(key_data, "allowed_providers"):
|
||||||
|
update_data["allowed_providers"] = key_data.allowed_providers
|
||||||
|
if hasattr(key_data, "allowed_api_formats"):
|
||||||
|
update_data["allowed_api_formats"] = key_data.allowed_api_formats
|
||||||
|
if hasattr(key_data, "allowed_models"):
|
||||||
|
update_data["allowed_models"] = key_data.allowed_models
|
||||||
|
|
||||||
|
if key_data.expires_at and key_data.expires_at.strip():
|
||||||
|
update_data["expires_at"] = parse_expiry_date(key_data.expires_at)
|
||||||
|
elif "expires_at" in key_data.model_fields_set:
|
||||||
|
update_data["expires_at"] = None
|
||||||
|
elif "expire_days" in key_data.model_fields_set:
|
||||||
|
if key_data.expire_days is not None and key_data.expire_days > 0:
|
||||||
|
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
|
||||||
|
days=key_data.expire_days
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
update_data["expires_at"] = None
|
||||||
|
|
||||||
|
changed_fields = list(update_data.keys())
|
||||||
|
|
||||||
|
if "initial_balance_usd" in key_data.model_fields_set:
|
||||||
|
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
|
||||||
|
|
||||||
|
if (
|
||||||
|
"unlimited_balance" in key_data.model_fields_set
|
||||||
|
and key_data.unlimited_balance is not None
|
||||||
|
):
|
||||||
|
wallet = _ensure_standalone_wallet(db, api_key)
|
||||||
|
desired_mode: Literal["finite", "unlimited"] = (
|
||||||
|
"unlimited" if key_data.unlimited_balance else "finite"
|
||||||
|
)
|
||||||
|
if wallet.limit_mode != desired_mode:
|
||||||
|
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
|
||||||
|
changed_fields.append("unlimited_balance")
|
||||||
|
|
||||||
|
updated_key = ApiKeyService.update_api_key(db, key_id, **update_data)
|
||||||
|
if not updated_key:
|
||||||
|
raise NotFoundException("更新失败", "api_key")
|
||||||
|
|
||||||
|
wallet = _ensure_standalone_wallet(db, updated_key)
|
||||||
|
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"id": updated_key.id,
|
||||||
|
"name": updated_key.name,
|
||||||
|
"key_display": updated_key.get_display_key(),
|
||||||
|
"is_active": updated_key.is_active,
|
||||||
|
"rate_limit": updated_key.rate_limit,
|
||||||
|
"expires_at": (
|
||||||
|
updated_key.expires_at.isoformat() if updated_key.expires_at else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
updated_key.updated_at.isoformat() if updated_key.updated_at else None
|
||||||
|
),
|
||||||
|
"wallet": wallet_summary,
|
||||||
|
"message": "API密钥已更新",
|
||||||
|
},
|
||||||
|
changed_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _toggle_standalone_api_key_sync(key_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
with get_db_context() as db:
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not api_key:
|
||||||
|
raise NotFoundException("API密钥不存在", "api_key")
|
||||||
|
if not api_key.is_standalone:
|
||||||
|
raise InvalidRequestException("仅支持操作独立密钥")
|
||||||
|
|
||||||
|
api_key.is_active = not api_key.is_active
|
||||||
|
api_key.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(api_key)
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"id": api_key.id,
|
||||||
|
"is_active": api_key.is_active,
|
||||||
|
"message": f"API密钥已{'启用' if api_key.is_active else '禁用'}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "toggle_api_key",
|
||||||
|
"target_key_id": api_key.id,
|
||||||
|
"user_id": api_key.user_id,
|
||||||
|
"new_status": "enabled" if api_key.is_active else "disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_standalone_api_key_sync(
|
||||||
|
key_id: str,
|
||||||
|
) -> tuple[dict[str, str], dict[str, Any], str | None]:
|
||||||
|
with get_db_context() as db:
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code=404, detail="API密钥不存在")
|
||||||
|
if not api_key.is_standalone:
|
||||||
|
raise InvalidRequestException("仅支持删除独立密钥")
|
||||||
|
|
||||||
|
user = api_key.user
|
||||||
|
pre_clean_api_key(db, api_key.id)
|
||||||
|
db.delete(api_key)
|
||||||
|
return (
|
||||||
|
{"message": "API密钥已删除"},
|
||||||
|
{
|
||||||
|
"action": "delete_api_key",
|
||||||
|
"target_key_id": key_id,
|
||||||
|
"user_id": user.id if user else None,
|
||||||
|
"user_email": user.email if user else None,
|
||||||
|
},
|
||||||
|
user.email if user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_standalone_wallet(
|
||||||
|
db: Session,
|
||||||
|
api_key: ApiKey,
|
||||||
|
*,
|
||||||
|
limit_mode: Literal["finite", "unlimited"] | None = None,
|
||||||
|
) -> Wallet:
|
||||||
|
"""确保独立 Key 已绑定钱包,并可选同步额度模式。"""
|
||||||
|
wallet = WalletService.get_or_create_wallet(db, api_key=api_key)
|
||||||
|
if wallet is None:
|
||||||
|
raise InvalidRequestException("独立密钥钱包初始化失败")
|
||||||
|
|
||||||
|
if limit_mode is not None and wallet.limit_mode != limit_mode:
|
||||||
|
wallet = WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=limit_mode)
|
||||||
|
|
||||||
|
return wallet
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_standalone_api_keys(
|
||||||
|
request: Request,
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
is_active: bool | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
列出所有独立余额 API Keys
|
||||||
|
|
||||||
|
获取系统中所有独立余额 API Key 的列表。独立余额 Key 不关联用户配额,
|
||||||
|
有独立的余额限制,主要用于给非注册用户使用。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数(分页偏移量),默认 0
|
||||||
|
- `limit`: 返回的记录数(分页限制),默认 100,最大 500
|
||||||
|
- `is_active`: 可选,根据启用状态筛选(true/false)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, is_standalone,
|
||||||
|
total_requests, total_cost_usd, rate_limit, allowed_providers, allowed_api_formats,
|
||||||
|
allowed_models, last_used_at, expires_at, created_at, updated_at, auto_delete_on_expiry 等字段
|
||||||
|
- `total`: 符合条件的总记录数
|
||||||
|
- `limit`: 当前分页限制
|
||||||
|
- `skip`: 当前分页偏移量
|
||||||
|
"""
|
||||||
|
adapter = AdminListStandaloneKeysAdapter(skip=skip, limit=limit, is_active=is_active)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_standalone_api_key(
|
||||||
|
request: Request,
|
||||||
|
key_data: CreateApiKeyRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
创建独立余额 API Key
|
||||||
|
|
||||||
|
创建一个新的独立余额 API Key。独立余额 Key 可设置初始余额,或使用无限额度。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `name`: API Key 的名称
|
||||||
|
- `initial_balance_usd`: 可选,初始余额(美元),null 表示无限制额度
|
||||||
|
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||||
|
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||||
|
- `allowed_models`: 可选,允许使用的模型列表
|
||||||
|
- `rate_limit`: 可选,每分钟请求限制(null 表示跟随系统默认,0 表示不限制)
|
||||||
|
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||||
|
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
|
||||||
|
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `key`: 完整的 API Key(仅在创建时返回一次)
|
||||||
|
- `name`: API Key 名称
|
||||||
|
- `key_display`: 脱敏显示的 Key
|
||||||
|
- `is_standalone`: 是否为独立余额 Key(始终为 true)
|
||||||
|
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||||
|
- `rate_limit`: 速率限制配置
|
||||||
|
- `expires_at`: 过期时间
|
||||||
|
- `created_at`: 创建时间
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
|
adapter = AdminCreateStandaloneKeyAdapter(key_data=key_data)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key_id}")
|
||||||
|
async def update_api_key(
|
||||||
|
key_id: str, request: Request, key_data: CreateApiKeyRequest, db: Session = Depends(get_db)
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
更新独立余额 API Key
|
||||||
|
|
||||||
|
更新指定 ID 的独立余额 API Key 的配置信息。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `name`: 可选,API Key 的名称
|
||||||
|
- `unlimited_balance`: 可选,是否无限余额(true=无限,false=有限,不修改余额数值)
|
||||||
|
- `rate_limit`: 可选,每分钟请求限制(null 表示跟随系统默认,0 表示不限制)
|
||||||
|
- `allowed_providers`: 可选,允许使用的提供商列表
|
||||||
|
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
|
||||||
|
- `allowed_models`: 可选,允许使用的模型列表
|
||||||
|
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
|
||||||
|
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
|
||||||
|
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `name`: API Key 名称
|
||||||
|
- `key_display`: 脱敏显示的 Key
|
||||||
|
- `is_active`: 是否启用
|
||||||
|
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
|
||||||
|
- `rate_limit`: 速率限制配置
|
||||||
|
- `expires_at`: 过期时间
|
||||||
|
- `updated_at`: 更新时间
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
|
adapter = AdminUpdateApiKeyAdapter(key_id=key_id, key_data=key_data)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{key_id}")
|
||||||
|
async def toggle_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
切换 API Key 启用状态
|
||||||
|
|
||||||
|
切换指定 API Key 的启用/禁用状态。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: API Key ID
|
||||||
|
- `is_active`: 新的启用状态
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
|
adapter = AdminToggleApiKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{key_id}")
|
||||||
|
async def delete_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> None:
|
||||||
|
"""
|
||||||
|
删除 API Key
|
||||||
|
|
||||||
|
删除指定的 API Key。此操作不可逆。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 提示信息
|
||||||
|
"""
|
||||||
|
adapter = AdminDeleteApiKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{key_id}")
|
||||||
|
async def get_api_key_detail(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
include_key: bool = Query(False, description="Include full decrypted key in response"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取 API Key 详情
|
||||||
|
|
||||||
|
获取指定 API Key 的详细信息。可选择是否返回完整的解密密钥。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `include_key`: 是否包含完整的解密密钥,默认 false
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
|
||||||
|
is_standalone, total_requests, total_cost_usd, rate_limit, allowed_providers,
|
||||||
|
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
|
||||||
|
wallet
|
||||||
|
- 当 include_key=true 时,返回完整密钥:key
|
||||||
|
"""
|
||||||
|
if include_key:
|
||||||
|
adapter = AdminGetFullKeyAdapter(key_id=key_id)
|
||||||
|
else:
|
||||||
|
# Return basic key info without full key
|
||||||
|
adapter = AdminGetKeyDetailAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminListStandaloneKeysAdapter(AdminApiAdapter):
|
||||||
|
"""列出独立余额Keys"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
skip: int,
|
||||||
|
limit: int,
|
||||||
|
is_active: bool | None,
|
||||||
|
):
|
||||||
|
self.skip = skip
|
||||||
|
self.limit = limit
|
||||||
|
self.is_active = is_active
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
result = await run_in_threadpool(
|
||||||
|
_list_standalone_api_keys_sync,
|
||||||
|
self.skip,
|
||||||
|
self.limit,
|
||||||
|
self.is_active,
|
||||||
|
)
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="list_standalone_api_keys",
|
||||||
|
filter_is_active=self.is_active,
|
||||||
|
limit=self.limit,
|
||||||
|
skip=self.skip,
|
||||||
|
total=result["total"],
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
|
||||||
|
"""创建独立余额Key"""
|
||||||
|
|
||||||
|
def __init__(self, key_data: CreateApiKeyRequest):
|
||||||
|
self.key_data = key_data
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
result, audit_meta = await run_in_threadpool(
|
||||||
|
_create_standalone_api_key_sync,
|
||||||
|
context.user.id,
|
||||||
|
self.key_data,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"管理员创建独立余额Key: ID {result['id']}, 初始余额 ${self.key_data.initial_balance_usd}"
|
||||||
|
)
|
||||||
|
context.add_audit_metadata(**audit_meta)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdateApiKeyAdapter(AdminApiAdapter):
|
||||||
|
"""更新独立余额Key"""
|
||||||
|
|
||||||
|
def __init__(self, key_id: str, key_data: CreateApiKeyRequest):
|
||||||
|
self.key_id = key_id
|
||||||
|
self.key_data = key_data
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
result, changed_fields = await run_in_threadpool(
|
||||||
|
_update_standalone_api_key_sync,
|
||||||
|
self.key_id,
|
||||||
|
self.key_data,
|
||||||
|
)
|
||||||
|
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {changed_fields}")
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="update_standalone_api_key",
|
||||||
|
key_id=self.key_id,
|
||||||
|
updated_fields=changed_fields,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminToggleApiKeyAdapter(AdminApiAdapter):
|
||||||
|
def __init__(self, key_id: str):
|
||||||
|
self.key_id = key_id
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
result, audit_meta = await run_in_threadpool(_toggle_standalone_api_key_sync, self.key_id)
|
||||||
|
logger.info(
|
||||||
|
f"管理员切换API密钥状态: Key ID {self.key_id}, 新状态 {'启用' if result['is_active'] else '禁用'}"
|
||||||
|
)
|
||||||
|
context.add_audit_metadata(**audit_meta)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
|
||||||
|
def __init__(self, key_id: str):
|
||||||
|
self.key_id = key_id
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
result, audit_meta, user_email = await run_in_threadpool(
|
||||||
|
_delete_standalone_api_key_sync,
|
||||||
|
self.key_id,
|
||||||
|
)
|
||||||
|
logger.info(f"管理员删除API密钥: Key ID {self.key_id}, 用户 {user_email or '未知'}")
|
||||||
|
context.add_audit_metadata(**audit_meta)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetFullKeyAdapter(AdminApiAdapter):
|
||||||
|
"""获取完整的API密钥"""
|
||||||
|
|
||||||
|
def __init__(self, key_id: str):
|
||||||
|
self.key_id = key_id
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
# 查找API密钥
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
|
||||||
|
if not api_key:
|
||||||
|
raise NotFoundException("API密钥不存在", "api_key")
|
||||||
|
if not api_key.is_standalone:
|
||||||
|
raise InvalidRequestException("仅支持查看独立密钥")
|
||||||
|
|
||||||
|
# 解密完整密钥
|
||||||
|
if not api_key.key_encrypted:
|
||||||
|
raise HTTPException(status_code=400, detail="该密钥没有存储完整密钥信息")
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_key = crypto_service.decrypt(api_key.key_encrypted)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"解密API密钥失败: Key ID {self.key_id}, 错误: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="解密密钥失败")
|
||||||
|
|
||||||
|
logger.info(f"管理员查看完整API密钥: Key ID {self.key_id}")
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="view_full_api_key",
|
||||||
|
key_id=self.key_id,
|
||||||
|
key_name=api_key.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key": full_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetKeyDetailAdapter(AdminApiAdapter):
|
||||||
|
"""Get API key detail without full key"""
|
||||||
|
|
||||||
|
def __init__(self, key_id: str):
|
||||||
|
self.key_id = key_id
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
|
||||||
|
if not api_key:
|
||||||
|
raise NotFoundException("API密钥不存在", "api_key")
|
||||||
|
if not api_key.is_standalone:
|
||||||
|
raise InvalidRequestException("仅支持查看独立密钥")
|
||||||
|
|
||||||
|
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
|
||||||
|
wallet_summary = WalletService.serialize_wallet_summary(wallet)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="get_api_key_detail",
|
||||||
|
key_id=self.key_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": api_key.id,
|
||||||
|
"user_id": api_key.user_id,
|
||||||
|
"name": api_key.name,
|
||||||
|
"key_display": api_key.get_display_key(),
|
||||||
|
"is_active": api_key.is_active,
|
||||||
|
"is_standalone": api_key.is_standalone,
|
||||||
|
"total_requests": api_key.total_requests,
|
||||||
|
"total_tokens": int(
|
||||||
|
(
|
||||||
|
db.query(func.sum(Usage.total_tokens))
|
||||||
|
.filter(Usage.api_key_id == api_key.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
),
|
||||||
|
"total_cost_usd": float(api_key.total_cost_usd or 0),
|
||||||
|
"rate_limit": api_key.rate_limit,
|
||||||
|
"allowed_providers": api_key.allowed_providers,
|
||||||
|
"allowed_api_formats": api_key.allowed_api_formats,
|
||||||
|
"allowed_models": api_key.allowed_models,
|
||||||
|
"last_used_at": api_key.last_used_at.isoformat() if api_key.last_used_at else None,
|
||||||
|
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||||
|
"created_at": api_key.created_at.isoformat(),
|
||||||
|
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
|
||||||
|
"wallet": wallet_summary,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Billing 配置管理 API 模块(billing_rules / dimension_collectors)。"""
|
||||||
|
|
||||||
|
from .routes import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
"""Billing 配置管理 API 路由。
|
||||||
|
|
||||||
|
包含:
|
||||||
|
- billing_rules: 计费规则(公式/变量/维度映射)
|
||||||
|
- dimension_collectors: 维度采集器(request/response/metadata/computed)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import BillingRule, DimensionCollector
|
||||||
|
from src.services.billing.formula_engine import SafeExpressionEvaluator, UnsafeExpressionError
|
||||||
|
from src.services.billing.presets import BillingPresetService, PresetApplyMode, list_preset_packs
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/billing", tags=["Admin - Billing"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
_expr_validator = SafeExpressionEvaluator()
|
||||||
|
|
||||||
|
|
||||||
|
AllowedTaskType = Literal["chat", "video", "image", "audio"]
|
||||||
|
AllowedCollectorSourceType = Literal["request", "response", "metadata", "computed"]
|
||||||
|
AllowedValueType = Literal["float", "int", "string"]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRuleUpsertRequest(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
task_type: AllowedTaskType = "chat"
|
||||||
|
|
||||||
|
global_model_id: str | None = None
|
||||||
|
model_id: str | None = None
|
||||||
|
|
||||||
|
expression: str = Field(..., min_length=1)
|
||||||
|
variables: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
dimension_mappings: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRuleResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
task_type: str
|
||||||
|
global_model_id: str | None
|
||||||
|
model_id: str | None
|
||||||
|
expression: str
|
||||||
|
variables: dict[str, Any]
|
||||||
|
dimension_mappings: dict[str, Any]
|
||||||
|
is_enabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_orm_obj(cls, rule: BillingRule) -> "BillingRuleResponse":
|
||||||
|
return cls(
|
||||||
|
id=rule.id,
|
||||||
|
name=rule.name,
|
||||||
|
task_type=rule.task_type,
|
||||||
|
global_model_id=rule.global_model_id,
|
||||||
|
model_id=rule.model_id,
|
||||||
|
expression=rule.expression,
|
||||||
|
variables=rule.variables or {},
|
||||||
|
dimension_mappings=rule.dimension_mappings or {},
|
||||||
|
is_enabled=bool(rule.is_enabled),
|
||||||
|
created_at=rule.created_at,
|
||||||
|
updated_at=rule.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DimensionCollectorUpsertRequest(BaseModel):
|
||||||
|
api_format: str = Field(..., min_length=1, max_length=50)
|
||||||
|
task_type: str = Field(..., min_length=1, max_length=20)
|
||||||
|
dimension_name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
|
||||||
|
source_type: AllowedCollectorSourceType
|
||||||
|
source_path: str | None = None
|
||||||
|
value_type: AllowedValueType = "float"
|
||||||
|
transform_expression: str | None = None
|
||||||
|
default_value: str | None = None
|
||||||
|
|
||||||
|
priority: int = 0
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class DimensionCollectorResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
api_format: str
|
||||||
|
task_type: str
|
||||||
|
dimension_name: str
|
||||||
|
source_type: str
|
||||||
|
source_path: str | None
|
||||||
|
value_type: str
|
||||||
|
transform_expression: str | None
|
||||||
|
default_value: str | None
|
||||||
|
priority: int
|
||||||
|
is_enabled: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_orm_obj(cls, c: DimensionCollector) -> "DimensionCollectorResponse":
|
||||||
|
return cls(
|
||||||
|
id=c.id,
|
||||||
|
api_format=c.api_format,
|
||||||
|
task_type=c.task_type,
|
||||||
|
dimension_name=c.dimension_name,
|
||||||
|
source_type=c.source_type,
|
||||||
|
source_path=c.source_path,
|
||||||
|
value_type=c.value_type,
|
||||||
|
transform_expression=c.transform_expression,
|
||||||
|
default_value=c.default_value,
|
||||||
|
priority=int(c.priority or 0),
|
||||||
|
is_enabled=bool(c.is_enabled),
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillingPresetInfoResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
collector_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyBillingPresetRequest(BaseModel):
|
||||||
|
preset: str = Field(..., min_length=1, max_length=100)
|
||||||
|
mode: PresetApplyMode = "merge"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/presets")
|
||||||
|
async def list_billing_presets(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingPresetListAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/presets/apply")
|
||||||
|
async def apply_billing_preset(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingPresetApplyAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules")
|
||||||
|
async def list_billing_rules(
|
||||||
|
request: Request,
|
||||||
|
task_type: str | None = Query(None),
|
||||||
|
is_enabled: bool | None = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = BillingRuleListAdapter(
|
||||||
|
task_type=task_type,
|
||||||
|
is_enabled=is_enabled,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules/{rule_id}")
|
||||||
|
async def get_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingRuleDetailAdapter(rule_id=rule_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules")
|
||||||
|
async def create_billing_rule(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingRuleCreateAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rules/{rule_id}")
|
||||||
|
async def update_billing_rule(rule_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = BillingRuleUpdateAdapter(rule_id=rule_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collectors")
|
||||||
|
async def list_dimension_collectors(
|
||||||
|
request: Request,
|
||||||
|
api_format: str | None = Query(None),
|
||||||
|
task_type: str | None = Query(None),
|
||||||
|
dimension_name: str | None = Query(None),
|
||||||
|
is_enabled: bool | None = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = DimensionCollectorListAdapter(
|
||||||
|
api_format=api_format,
|
||||||
|
task_type=task_type,
|
||||||
|
dimension_name=dimension_name,
|
||||||
|
is_enabled=is_enabled,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collectors/{collector_id}")
|
||||||
|
async def get_dimension_collector(
|
||||||
|
collector_id: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
) -> Any:
|
||||||
|
adapter = DimensionCollectorDetailAdapter(collector_id=collector_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collectors")
|
||||||
|
async def create_dimension_collector(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
adapter = DimensionCollectorCreateAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/collectors/{collector_id}")
|
||||||
|
async def update_dimension_collector(
|
||||||
|
collector_id: str, request: Request, db: Session = Depends(get_db)
|
||||||
|
) -> Any:
|
||||||
|
adapter = DimensionCollectorUpdateAdapter(collector_id=collector_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingRuleListAdapter(AdminApiAdapter):
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
task_type: str | None = None
|
||||||
|
is_enabled: bool | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
q = context.db.query(BillingRule)
|
||||||
|
if self.task_type:
|
||||||
|
q = q.filter(BillingRule.task_type == self.task_type.lower())
|
||||||
|
if self.is_enabled is not None:
|
||||||
|
q = q.filter(BillingRule.is_enabled == self.is_enabled)
|
||||||
|
|
||||||
|
total = int(q.with_entities(func.count(BillingRule.id)).scalar() or 0)
|
||||||
|
items = (
|
||||||
|
q.order_by(BillingRule.updated_at.desc())
|
||||||
|
.offset((self.page - 1) * self.page_size)
|
||||||
|
.limit(self.page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": [BillingRuleResponse.from_orm_obj(r).model_dump() for r in items],
|
||||||
|
"total": total,
|
||||||
|
"page": self.page,
|
||||||
|
"page_size": self.page_size,
|
||||||
|
"pages": (total + self.page_size - 1) // self.page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingRuleDetailAdapter(AdminApiAdapter):
|
||||||
|
rule_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
|
||||||
|
if not rule:
|
||||||
|
raise NotFoundException("Billing rule not found")
|
||||||
|
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class BillingRuleCreateAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = BillingRuleUpsertRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
_validate_billing_rule_request(req)
|
||||||
|
|
||||||
|
rule = BillingRule(
|
||||||
|
name=req.name,
|
||||||
|
task_type=req.task_type,
|
||||||
|
global_model_id=req.global_model_id,
|
||||||
|
model_id=req.model_id,
|
||||||
|
expression=req.expression,
|
||||||
|
variables=req.variables,
|
||||||
|
dimension_mappings=req.dimension_mappings,
|
||||||
|
is_enabled=req.is_enabled,
|
||||||
|
)
|
||||||
|
context.db.add(rule)
|
||||||
|
try:
|
||||||
|
context.db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
context.db.rollback()
|
||||||
|
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||||
|
|
||||||
|
context.db.refresh(rule)
|
||||||
|
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingRuleUpdateAdapter(AdminApiAdapter):
|
||||||
|
rule_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
rule = context.db.query(BillingRule).filter(BillingRule.id == self.rule_id).first()
|
||||||
|
if not rule:
|
||||||
|
raise NotFoundException("Billing rule not found")
|
||||||
|
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = BillingRuleUpsertRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
_validate_billing_rule_request(req)
|
||||||
|
|
||||||
|
rule.name = req.name
|
||||||
|
rule.task_type = req.task_type
|
||||||
|
rule.global_model_id = req.global_model_id
|
||||||
|
rule.model_id = req.model_id
|
||||||
|
rule.expression = req.expression
|
||||||
|
rule.variables = req.variables
|
||||||
|
rule.dimension_mappings = req.dimension_mappings
|
||||||
|
rule.is_enabled = req.is_enabled
|
||||||
|
|
||||||
|
try:
|
||||||
|
context.db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
context.db.rollback()
|
||||||
|
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||||
|
|
||||||
|
context.db.refresh(rule)
|
||||||
|
return BillingRuleResponse.from_orm_obj(rule).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DimensionCollectorListAdapter(AdminApiAdapter):
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
api_format: str | None = None
|
||||||
|
task_type: str | None = None
|
||||||
|
dimension_name: str | None = None
|
||||||
|
is_enabled: bool | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
q = context.db.query(DimensionCollector)
|
||||||
|
if self.api_format:
|
||||||
|
q = q.filter(DimensionCollector.api_format == self.api_format.upper())
|
||||||
|
if self.task_type:
|
||||||
|
q = q.filter(DimensionCollector.task_type == self.task_type.lower())
|
||||||
|
if self.dimension_name:
|
||||||
|
q = q.filter(DimensionCollector.dimension_name == self.dimension_name)
|
||||||
|
if self.is_enabled is not None:
|
||||||
|
q = q.filter(DimensionCollector.is_enabled == self.is_enabled)
|
||||||
|
|
||||||
|
total = int(q.with_entities(func.count(DimensionCollector.id)).scalar() or 0)
|
||||||
|
items = (
|
||||||
|
q.order_by(DimensionCollector.updated_at.desc())
|
||||||
|
.offset((self.page - 1) * self.page_size)
|
||||||
|
.limit(self.page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"items": [DimensionCollectorResponse.from_orm_obj(c).model_dump() for c in items],
|
||||||
|
"total": total,
|
||||||
|
"page": self.page,
|
||||||
|
"page_size": self.page_size,
|
||||||
|
"pages": (total + self.page_size - 1) // self.page_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DimensionCollectorDetailAdapter(AdminApiAdapter):
|
||||||
|
collector_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
c = (
|
||||||
|
context.db.query(DimensionCollector)
|
||||||
|
.filter(DimensionCollector.id == self.collector_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not c:
|
||||||
|
raise NotFoundException("Dimension collector not found")
|
||||||
|
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class DimensionCollectorCreateAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = DimensionCollectorUpsertRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
_validate_dimension_collector_request(context.db, req, existing_id=None)
|
||||||
|
|
||||||
|
c = DimensionCollector(
|
||||||
|
api_format=req.api_format.upper(),
|
||||||
|
task_type=req.task_type.lower(),
|
||||||
|
dimension_name=req.dimension_name,
|
||||||
|
source_type=req.source_type,
|
||||||
|
source_path=req.source_path,
|
||||||
|
value_type=req.value_type,
|
||||||
|
transform_expression=req.transform_expression,
|
||||||
|
default_value=req.default_value,
|
||||||
|
priority=req.priority,
|
||||||
|
is_enabled=req.is_enabled,
|
||||||
|
)
|
||||||
|
context.db.add(c)
|
||||||
|
try:
|
||||||
|
context.db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
context.db.rollback()
|
||||||
|
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||||
|
|
||||||
|
context.db.refresh(c)
|
||||||
|
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DimensionCollectorUpdateAdapter(AdminApiAdapter):
|
||||||
|
collector_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
c = (
|
||||||
|
context.db.query(DimensionCollector)
|
||||||
|
.filter(DimensionCollector.id == self.collector_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not c:
|
||||||
|
raise NotFoundException("Dimension collector not found")
|
||||||
|
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = DimensionCollectorUpsertRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
_validate_dimension_collector_request(context.db, req, existing_id=self.collector_id)
|
||||||
|
|
||||||
|
c.api_format = req.api_format.upper()
|
||||||
|
c.task_type = req.task_type.lower()
|
||||||
|
c.dimension_name = req.dimension_name
|
||||||
|
c.source_type = req.source_type
|
||||||
|
c.source_path = req.source_path
|
||||||
|
c.value_type = req.value_type
|
||||||
|
c.transform_expression = req.transform_expression
|
||||||
|
c.default_value = req.default_value
|
||||||
|
c.priority = req.priority
|
||||||
|
c.is_enabled = req.is_enabled
|
||||||
|
|
||||||
|
try:
|
||||||
|
context.db.commit()
|
||||||
|
except IntegrityError as exc:
|
||||||
|
context.db.rollback()
|
||||||
|
raise InvalidRequestException(f"Integrity error: {exc}")
|
||||||
|
|
||||||
|
context.db.refresh(c)
|
||||||
|
return DimensionCollectorResponse.from_orm_obj(c).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_billing_rule_request(req: BillingRuleUpsertRequest) -> None:
|
||||||
|
# model/global_model 二选一
|
||||||
|
if bool(req.global_model_id) == bool(req.model_id):
|
||||||
|
raise InvalidRequestException("Exactly one of global_model_id or model_id must be provided")
|
||||||
|
|
||||||
|
# task_type 校验:Pydantic Literal 已限制为 "chat", "video", "image", "audio"
|
||||||
|
# 注:CLI 在计费域等同于 chat,billing_rules 不存储 "cli"
|
||||||
|
|
||||||
|
# expression 安全校验
|
||||||
|
try:
|
||||||
|
_expr_validator.validate(req.expression)
|
||||||
|
except UnsafeExpressionError as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid expression: {exc}")
|
||||||
|
|
||||||
|
# variables 必须为数值(JSON 可包含 int/float)
|
||||||
|
if not isinstance(req.variables, dict):
|
||||||
|
raise InvalidRequestException("variables must be a JSON object")
|
||||||
|
for k, v in req.variables.items():
|
||||||
|
if not isinstance(k, str) or not k:
|
||||||
|
raise InvalidRequestException("variables keys must be non-empty strings")
|
||||||
|
if isinstance(v, bool) or not isinstance(v, (int, float)):
|
||||||
|
raise InvalidRequestException(f"variables['{k}'] must be a number")
|
||||||
|
|
||||||
|
# dimension_mappings 结构做轻量校验(详细 schema 由业务侧保障)
|
||||||
|
if not isinstance(req.dimension_mappings, dict):
|
||||||
|
raise InvalidRequestException("dimension_mappings must be a JSON object")
|
||||||
|
for var_name, mapping in req.dimension_mappings.items():
|
||||||
|
if not isinstance(var_name, str) or not var_name:
|
||||||
|
raise InvalidRequestException("dimension_mappings keys must be non-empty strings")
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
raise InvalidRequestException(f"dimension_mappings['{var_name}'] must be an object")
|
||||||
|
if "source" not in mapping:
|
||||||
|
raise InvalidRequestException(f"dimension_mappings['{var_name}'].source is required")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_dimension_collector_request(
|
||||||
|
db: Session,
|
||||||
|
req: DimensionCollectorUpsertRequest,
|
||||||
|
*,
|
||||||
|
existing_id: str | None,
|
||||||
|
) -> None:
|
||||||
|
src = req.source_type
|
||||||
|
if src == "computed":
|
||||||
|
if req.source_path is not None:
|
||||||
|
raise InvalidRequestException("computed collector must have source_path=null")
|
||||||
|
if not req.transform_expression:
|
||||||
|
raise InvalidRequestException("computed collector must have transform_expression")
|
||||||
|
else:
|
||||||
|
if not req.source_path:
|
||||||
|
raise InvalidRequestException("non-computed collector must have source_path")
|
||||||
|
|
||||||
|
# transform_expression 安全校验(如配置)
|
||||||
|
if req.transform_expression:
|
||||||
|
try:
|
||||||
|
_expr_validator.validate(req.transform_expression)
|
||||||
|
except UnsafeExpressionError as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid transform_expression: {exc}")
|
||||||
|
|
||||||
|
# default_value 仅允许同一维度一条(enabled=true)
|
||||||
|
if req.default_value is not None and req.is_enabled:
|
||||||
|
q = db.query(DimensionCollector).filter(
|
||||||
|
DimensionCollector.api_format == req.api_format.upper(),
|
||||||
|
DimensionCollector.task_type == req.task_type.lower(),
|
||||||
|
DimensionCollector.dimension_name == req.dimension_name,
|
||||||
|
DimensionCollector.is_enabled.is_(True),
|
||||||
|
DimensionCollector.default_value.isnot(None),
|
||||||
|
)
|
||||||
|
if existing_id:
|
||||||
|
q = q.filter(DimensionCollector.id != existing_id)
|
||||||
|
exists = db.query(q.exists()).scalar()
|
||||||
|
if exists:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"default_value already exists for this (api_format, task_type, dimension_name)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BillingPresetListAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
items = []
|
||||||
|
for p in list_preset_packs():
|
||||||
|
items.append(
|
||||||
|
BillingPresetInfoResponse(
|
||||||
|
name=p.name,
|
||||||
|
version=p.version,
|
||||||
|
description=p.description,
|
||||||
|
collector_count=len(p.collectors or []),
|
||||||
|
).model_dump()
|
||||||
|
)
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
|
||||||
|
class BillingPresetApplyAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
try:
|
||||||
|
req = ApplyBillingPresetRequest.model_validate(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"Invalid request body: {exc}")
|
||||||
|
|
||||||
|
result = BillingPresetService.apply_preset(
|
||||||
|
context.db,
|
||||||
|
preset_name=req.preset,
|
||||||
|
mode=req.mode,
|
||||||
|
)
|
||||||
|
if result.errors:
|
||||||
|
# still return counts; caller can display partial results
|
||||||
|
return {"ok": False, **result.to_dict()}
|
||||||
|
return {"ok": True, **result.to_dict()}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Endpoint management API routers."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .concurrency import router as concurrency_router
|
||||||
|
from .health import router as health_router
|
||||||
|
from .keys import router as keys_router
|
||||||
|
from .routes import router as routes_router
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/endpoints", tags=["Admin - Endpoints"])
|
||||||
|
|
||||||
|
# Endpoint CRUD
|
||||||
|
router.include_router(routes_router)
|
||||||
|
|
||||||
|
# Endpoint Keys management
|
||||||
|
router.include_router(keys_router)
|
||||||
|
|
||||||
|
# Health monitoring
|
||||||
|
router.include_router(health_router)
|
||||||
|
|
||||||
|
# Concurrency control
|
||||||
|
router.include_router(concurrency_router)
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Key RPM 限制管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import ProviderAPIKey
|
||||||
|
from src.models.endpoint_models import KeyRpmStatusResponse
|
||||||
|
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
|
||||||
|
|
||||||
|
router = APIRouter(tags=["RPM Control"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rpm/key/{key_id}", response_model=KeyRpmStatusResponse)
|
||||||
|
async def get_key_rpm(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> KeyRpmStatusResponse:
|
||||||
|
"""
|
||||||
|
获取 Key 当前 RPM 状态
|
||||||
|
|
||||||
|
查询指定 API Key 的实时 RPM 使用情况,包括当前 RPM 计数和最大 RPM 限制。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
- `current_rpm`: 当前 RPM 计数
|
||||||
|
- `rpm_limit`: RPM 限制
|
||||||
|
"""
|
||||||
|
adapter = AdminKeyRpmAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/rpm/key/{key_id}")
|
||||||
|
async def reset_key_rpm(
|
||||||
|
key_id: str,
|
||||||
|
http_request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
重置 Key RPM 计数器
|
||||||
|
|
||||||
|
重置指定 API Key 的 RPM 计数器,用于解决计数不准确的问题。
|
||||||
|
管理员功能,请谨慎使用。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminResetKeyRpmAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=http_request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminKeyRpmAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == self.key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
|
concurrency_manager = await get_concurrency_manager()
|
||||||
|
key_count = await concurrency_manager.get_key_rpm_count(key_id=self.key_id)
|
||||||
|
|
||||||
|
return KeyRpmStatusResponse(
|
||||||
|
key_id=self.key_id,
|
||||||
|
current_rpm=key_count,
|
||||||
|
rpm_limit=key.rpm_limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminResetKeyRpmAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
concurrency_manager = await get_concurrency_manager()
|
||||||
|
await concurrency_manager.reset_key_rpm(key_id=self.key_id)
|
||||||
|
return {"message": "RPM 计数已重置"}
|
||||||
@@ -0,0 +1,601 @@
|
|||||||
|
"""
|
||||||
|
Endpoint 健康监控 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.exceptions import NotFoundException
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, RequestCandidate
|
||||||
|
from src.models.endpoint_models import (
|
||||||
|
ApiFormatHealthMonitor,
|
||||||
|
ApiFormatHealthMonitorResponse,
|
||||||
|
EndpointHealthEvent,
|
||||||
|
HealthStatusResponse,
|
||||||
|
HealthSummaryResponse,
|
||||||
|
)
|
||||||
|
from src.services.health.endpoint import EndpointHealthService
|
||||||
|
from src.services.health.monitor import HealthMonitor, get_health_monitor
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Endpoint Health"])
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_key_health_sync(db: Session, key_id: str, api_format: str | None) -> dict[str, Any]:
|
||||||
|
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise NotFoundException(f"Key {key_id} 不存在")
|
||||||
|
|
||||||
|
success = get_health_monitor().reset_health(db, key_id=key_id, api_format=api_format)
|
||||||
|
if not success:
|
||||||
|
raise Exception("重置健康度失败")
|
||||||
|
|
||||||
|
if not key.is_active:
|
||||||
|
key.is_active = True # type: ignore[assignment]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"is_active": bool(key.is_active),
|
||||||
|
"api_format": api_format,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_all_keys_health_sync(db: Session) -> list[dict[str, Any]]:
|
||||||
|
candidates = (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.filter(
|
||||||
|
ProviderAPIKey.circuit_breaker_by_format.isnot(None),
|
||||||
|
ProviderAPIKey.circuit_breaker_by_format != "{}",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
circuit_open_keys = [
|
||||||
|
key
|
||||||
|
for key in candidates
|
||||||
|
if any(cb.get("open") for cb in (key.circuit_breaker_by_format or {}).values())
|
||||||
|
]
|
||||||
|
|
||||||
|
recovered_keys: list[dict[str, Any]] = []
|
||||||
|
for key in circuit_open_keys:
|
||||||
|
key.health_by_format = {} # type: ignore[assignment]
|
||||||
|
key.circuit_breaker_by_format = {} # type: ignore[assignment]
|
||||||
|
recovered_keys.append(
|
||||||
|
{
|
||||||
|
"key_id": key.id,
|
||||||
|
"key_name": key.name,
|
||||||
|
"provider_id": key.provider_id,
|
||||||
|
"api_formats": key.api_formats,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if recovered_keys:
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return recovered_keys
|
||||||
|
|
||||||
|
|
||||||
|
def _format_str(api_format_enum: Any) -> str:
|
||||||
|
"""将 DB 查询返回的 api_format(可能是 enum 或 str)统一转为 str。"""
|
||||||
|
return api_format_enum.value if hasattr(api_format_enum, "value") else str(api_format_enum)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_recent_attempts_for_api_format(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
api_format: str,
|
||||||
|
since: datetime,
|
||||||
|
per_format_limit: int,
|
||||||
|
) -> list[RequestCandidate]:
|
||||||
|
"""获取单个 API 格式最近的最终态请求,用于事件展示。"""
|
||||||
|
final_statuses = ["success", "failed", "skipped"]
|
||||||
|
return (
|
||||||
|
db.query(RequestCandidate)
|
||||||
|
.join(ProviderEndpoint, RequestCandidate.endpoint_id == ProviderEndpoint.id)
|
||||||
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
ProviderEndpoint.is_active.is_(True),
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
ProviderEndpoint.api_format == api_format,
|
||||||
|
RequestCandidate.created_at >= since,
|
||||||
|
RequestCandidate.status.in_(final_statuses),
|
||||||
|
)
|
||||||
|
.order_by(RequestCandidate.created_at.desc())
|
||||||
|
.limit(per_format_limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/summary", response_model=HealthSummaryResponse)
|
||||||
|
async def get_health_summary(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> HealthSummaryResponse:
|
||||||
|
"""
|
||||||
|
获取健康状态摘要
|
||||||
|
|
||||||
|
获取系统整体健康状态摘要,包括所有 Provider、Endpoint 和 Key 的健康状态统计。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `total_providers`: Provider 总数
|
||||||
|
- `active_providers`: 活跃 Provider 数量
|
||||||
|
- `total_endpoints`: Endpoint 总数
|
||||||
|
- `active_endpoints`: 活跃 Endpoint 数量
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `circuit_breaker_open_keys`: 熔断的 Key 数量
|
||||||
|
"""
|
||||||
|
adapter = AdminHealthSummaryAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/status")
|
||||||
|
async def get_endpoint_health_status(
|
||||||
|
request: Request,
|
||||||
|
lookback_hours: int = Query(6, ge=1, le=72, description="回溯的小时数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
获取端点健康状态(简化视图,与用户端点统一)
|
||||||
|
|
||||||
|
获取按 API 格式聚合的端点健康状态时间线,基于 Usage 表统计,
|
||||||
|
返回 50 个时间段的聚合状态,适用于快速查看整体健康趋势。
|
||||||
|
|
||||||
|
与 /health/api-formats 的区别:
|
||||||
|
- /health/status: 返回聚合的时间线状态(50个时间段),基于 Usage 表
|
||||||
|
- /health/api-formats: 返回详细的事件列表,基于 RequestCandidate 表
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_format`: API 格式名称
|
||||||
|
- `timeline`: 时间线数据(50个时间段)
|
||||||
|
- `time_range_start`: 时间范围起始
|
||||||
|
- `time_range_end`: 时间范围结束
|
||||||
|
"""
|
||||||
|
adapter = AdminEndpointHealthStatusAdapter(lookback_hours=lookback_hours)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/api-formats", response_model=ApiFormatHealthMonitorResponse)
|
||||||
|
async def get_api_format_health_monitor(
|
||||||
|
request: Request,
|
||||||
|
lookback_hours: int = Query(6, ge=1, le=72, description="回溯的小时数"),
|
||||||
|
per_format_limit: int = Query(60, ge=10, le=200, description="每个 API 格式的事件数量"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ApiFormatHealthMonitorResponse:
|
||||||
|
"""
|
||||||
|
获取按 API 格式聚合的健康监控时间线(详细事件列表)
|
||||||
|
|
||||||
|
获取每个 API 格式的详细健康监控数据,包括请求事件列表、成功率统计、
|
||||||
|
时间线数据等,基于 RequestCandidate 表查询,适用于详细分析。
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `lookback_hours`: 回溯的小时数(1-72),默认 6
|
||||||
|
- `per_format_limit`: 每个 API 格式返回的事件数量(10-200),默认 60
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `generated_at`: 数据生成时间
|
||||||
|
- `formats`: API 格式健康监控数据列表
|
||||||
|
- `api_format`: API 格式名称
|
||||||
|
- `total_attempts`: 总请求数
|
||||||
|
- `success_count`: 成功请求数
|
||||||
|
- `failed_count`: 失败请求数
|
||||||
|
- `skipped_count`: 跳过请求数
|
||||||
|
- `success_rate`: 成功率
|
||||||
|
- `provider_count`: Provider 数量
|
||||||
|
- `key_count`: Key 数量
|
||||||
|
- `last_event_at`: 最后事件时间
|
||||||
|
- `events`: 事件列表
|
||||||
|
- `timeline`: 时间线数据
|
||||||
|
- `time_range_start`: 时间范围起始
|
||||||
|
- `time_range_end`: 时间范围结束
|
||||||
|
"""
|
||||||
|
adapter = AdminApiFormatHealthMonitorAdapter(
|
||||||
|
lookback_hours=lookback_hours,
|
||||||
|
per_format_limit=per_format_limit,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/key/{key_id}", response_model=HealthStatusResponse)
|
||||||
|
async def get_key_health(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
api_format: str | None = Query(None, description="API 格式(可选,如 CLAUDE、OPENAI)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> HealthStatusResponse:
|
||||||
|
"""
|
||||||
|
获取 Key 健康状态
|
||||||
|
|
||||||
|
获取指定 API Key 的健康状态详情,包括健康分数、连续失败次数、
|
||||||
|
熔断器状态等信息。支持按 API 格式查询。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)。
|
||||||
|
- 指定时返回该格式的健康度详情
|
||||||
|
- 不指定时返回所有格式的健康度摘要
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
- `key_health_score`: 健康分数(0.0-1.0)
|
||||||
|
- `key_is_active`: 是否活跃
|
||||||
|
- `key_statistics`: 统计信息
|
||||||
|
- `health_by_format`: 按格式的健康度数据(无 api_format 参数时)
|
||||||
|
- `circuit_breaker_open`: 熔断器是否打开(有 api_format 参数时)
|
||||||
|
"""
|
||||||
|
adapter = AdminKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/health/keys/{key_id}")
|
||||||
|
async def recover_key_health(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
api_format: str | None = Query(None, description="API 格式(可选,不指定则恢复所有格式)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
恢复 Key 健康状态
|
||||||
|
|
||||||
|
手动恢复指定 Key 的健康状态,将健康分数重置为 1.0,关闭熔断器,
|
||||||
|
取消自动禁用,并重置所有失败计数。支持按 API 格式恢复。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: API Key ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `api_format`: 可选,指定 API 格式(如 CLAUDE、OPENAI)
|
||||||
|
- 指定时仅恢复该格式的健康度
|
||||||
|
- 不指定时恢复所有格式
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `details`: 详细信息
|
||||||
|
- `health_score`: 健康分数
|
||||||
|
- `circuit_breaker_open`: 熔断器状态
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
"""
|
||||||
|
adapter = AdminRecoverKeyHealthAdapter(key_id=key_id, api_format=api_format)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/health/keys")
|
||||||
|
async def recover_all_keys_health(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
批量恢复所有熔断 Key 的健康状态
|
||||||
|
|
||||||
|
查找所有处于熔断状态的 Key(circuit_breaker_open=True),
|
||||||
|
并批量执行以下操作:
|
||||||
|
1. 将健康分数重置为 1.0
|
||||||
|
2. 关闭熔断器
|
||||||
|
3. 重置失败计数
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `recovered_count`: 恢复的 Key 数量
|
||||||
|
- `recovered_keys`: 恢复的 Key 列表
|
||||||
|
- `key_id`: Key ID
|
||||||
|
- `key_name`: Key 名称
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
"""
|
||||||
|
adapter = AdminRecoverAllKeysHealthAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
|
class AdminHealthSummaryAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
summary = get_health_monitor().get_all_health_status(context.db)
|
||||||
|
return HealthSummaryResponse(**summary)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminEndpointHealthStatusAdapter(AdminApiAdapter):
|
||||||
|
"""管理员端点健康状态适配器(与用户端点统一,但包含管理员字段)"""
|
||||||
|
|
||||||
|
lookback_hours: int
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
|
||||||
|
# 使用共享服务获取健康状态(管理员视图)
|
||||||
|
result = EndpointHealthService.get_endpoint_health_by_format(
|
||||||
|
db=db,
|
||||||
|
lookback_hours=self.lookback_hours,
|
||||||
|
include_admin_fields=True, # 包含管理员字段
|
||||||
|
use_cache=False, # 管理员不使用缓存,确保实时性
|
||||||
|
)
|
||||||
|
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="endpoint_health_status",
|
||||||
|
format_count=len(result),
|
||||||
|
lookback_hours=self.lookback_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminApiFormatHealthMonitorAdapter(AdminApiAdapter):
|
||||||
|
lookback_hours: int
|
||||||
|
per_format_limit: int
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since = now - timedelta(hours=self.lookback_hours)
|
||||||
|
|
||||||
|
# 1. 单次查询获取所有活跃 endpoint 行,在内存中聚合 provider_count / endpoint_map
|
||||||
|
endpoint_rows = (
|
||||||
|
db.query(ProviderEndpoint.api_format, ProviderEndpoint.id, ProviderEndpoint.provider_id)
|
||||||
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
ProviderEndpoint.is_active.is_(True),
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
all_formats: dict[str, int] = {} # api_format -> distinct provider count
|
||||||
|
endpoint_map: dict[str, list[str]] = defaultdict(list)
|
||||||
|
active_provider_formats: set[tuple[str, str]] = set()
|
||||||
|
_provider_sets: dict[str, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
|
for api_format_enum, endpoint_id, provider_id in endpoint_rows:
|
||||||
|
fmt = _format_str(api_format_enum)
|
||||||
|
endpoint_map[fmt].append(endpoint_id)
|
||||||
|
_provider_sets[fmt].add(str(provider_id))
|
||||||
|
active_provider_formats.add((str(provider_id), fmt))
|
||||||
|
|
||||||
|
for fmt, pids in _provider_sets.items():
|
||||||
|
all_formats[fmt] = len(pids)
|
||||||
|
|
||||||
|
# 1.2 统计每个 API 格式可用的活跃 Key 数量(Key 属于 Provider,通过 api_formats 关联格式)
|
||||||
|
key_counts: dict[str, int] = {}
|
||||||
|
if active_provider_formats:
|
||||||
|
active_provider_keys = (
|
||||||
|
db.query(ProviderAPIKey.provider_id, ProviderAPIKey.api_formats)
|
||||||
|
.join(Provider, ProviderAPIKey.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
ProviderAPIKey.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for provider_id, api_formats in active_provider_keys:
|
||||||
|
pid = str(provider_id)
|
||||||
|
for fmt in api_formats or []:
|
||||||
|
if (pid, fmt) not in active_provider_formats:
|
||||||
|
continue
|
||||||
|
key_counts[fmt] = key_counts.get(fmt, 0) + 1
|
||||||
|
|
||||||
|
# 2. 统计窗口内每个 API 格式的请求状态分布(真实统计)
|
||||||
|
# 只统计最终状态:success, failed, skipped
|
||||||
|
final_statuses = ["success", "failed", "skipped"]
|
||||||
|
status_counts_query = (
|
||||||
|
db.query(
|
||||||
|
ProviderEndpoint.api_format,
|
||||||
|
RequestCandidate.status,
|
||||||
|
func.count(RequestCandidate.id).label("count"),
|
||||||
|
)
|
||||||
|
.join(RequestCandidate, ProviderEndpoint.id == RequestCandidate.endpoint_id)
|
||||||
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
|
.filter(
|
||||||
|
ProviderEndpoint.is_active.is_(True),
|
||||||
|
Provider.is_active.is_(True),
|
||||||
|
RequestCandidate.created_at >= since,
|
||||||
|
RequestCandidate.status.in_(final_statuses),
|
||||||
|
)
|
||||||
|
.group_by(ProviderEndpoint.api_format, RequestCandidate.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 构建每个格式的状态统计
|
||||||
|
status_counts: dict[str, dict[str, int]] = {}
|
||||||
|
for api_format_enum, status, count in status_counts_query:
|
||||||
|
fmt = _format_str(api_format_enum)
|
||||||
|
if fmt not in status_counts:
|
||||||
|
status_counts[fmt] = {"success": 0, "failed": 0, "skipped": 0}
|
||||||
|
status_counts[fmt][status] = count
|
||||||
|
|
||||||
|
# 3. 为所有活跃格式生成监控数据(包括没有请求记录的)
|
||||||
|
monitors: list[ApiFormatHealthMonitor] = []
|
||||||
|
for api_format in all_formats:
|
||||||
|
attempts = _fetch_recent_attempts_for_api_format(
|
||||||
|
db=db,
|
||||||
|
api_format=api_format,
|
||||||
|
since=since,
|
||||||
|
per_format_limit=self.per_format_limit,
|
||||||
|
)
|
||||||
|
# 获取窗口内的真实统计数据
|
||||||
|
# 只统计最终状态:success, failed, skipped
|
||||||
|
# 中间状态(available, pending, used, started)不计入统计
|
||||||
|
format_stats = status_counts.get(api_format, {"success": 0, "failed": 0, "skipped": 0})
|
||||||
|
real_success_count = format_stats.get("success", 0)
|
||||||
|
real_failed_count = format_stats.get("failed", 0)
|
||||||
|
real_skipped_count = format_stats.get("skipped", 0)
|
||||||
|
# total_attempts 只包含最终状态的请求数
|
||||||
|
total_attempts = real_success_count + real_failed_count + real_skipped_count
|
||||||
|
|
||||||
|
# 时间线按时间正序
|
||||||
|
attempts_sorted = list(reversed(attempts))
|
||||||
|
events: list[EndpointHealthEvent] = []
|
||||||
|
for attempt in attempts_sorted:
|
||||||
|
event_timestamp = attempt.finished_at or attempt.started_at or attempt.created_at
|
||||||
|
events.append(
|
||||||
|
EndpointHealthEvent(
|
||||||
|
timestamp=event_timestamp,
|
||||||
|
status=attempt.status,
|
||||||
|
status_code=attempt.status_code,
|
||||||
|
latency_ms=attempt.latency_ms,
|
||||||
|
error_type=attempt.error_type,
|
||||||
|
error_message=attempt.error_message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 成功率 = success / (success + failed)
|
||||||
|
# skipped 不算失败,不计入成功率分母
|
||||||
|
# 无实际完成请求时成功率为 1.0(灰色状态)
|
||||||
|
actual_completed = real_success_count + real_failed_count
|
||||||
|
success_rate = real_success_count / actual_completed if actual_completed > 0 else 1.0
|
||||||
|
last_event_at = events[-1].timestamp if events else None
|
||||||
|
|
||||||
|
# 生成 Usage 基于时间窗口的健康时间线
|
||||||
|
timeline_data = EndpointHealthService._generate_timeline_from_usage(
|
||||||
|
db=db,
|
||||||
|
endpoint_ids=endpoint_map.get(api_format, []),
|
||||||
|
now=now,
|
||||||
|
lookback_hours=self.lookback_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
monitors.append(
|
||||||
|
ApiFormatHealthMonitor(
|
||||||
|
api_format=api_format,
|
||||||
|
total_attempts=total_attempts, # 真实总请求数
|
||||||
|
success_count=real_success_count, # 真实成功数
|
||||||
|
failed_count=real_failed_count, # 真实失败数
|
||||||
|
skipped_count=real_skipped_count, # 真实跳过数
|
||||||
|
success_rate=success_rate, # 基于真实统计的成功率
|
||||||
|
provider_count=all_formats[api_format],
|
||||||
|
key_count=key_counts.get(api_format, 0),
|
||||||
|
last_event_at=last_event_at,
|
||||||
|
events=events, # 限制为 per_format_limit 条(用于时间线显示)
|
||||||
|
timeline=timeline_data.get("timeline", []),
|
||||||
|
time_range_start=timeline_data.get("time_range_start"),
|
||||||
|
time_range_end=timeline_data.get("time_range_end"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ApiFormatHealthMonitorResponse(
|
||||||
|
generated_at=now,
|
||||||
|
formats=monitors,
|
||||||
|
)
|
||||||
|
context.add_audit_metadata(
|
||||||
|
action="api_format_health_monitor",
|
||||||
|
format_count=len(monitors),
|
||||||
|
lookback_hours=self.lookback_hours,
|
||||||
|
per_format_limit=self.per_format_limit,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminKeyHealthAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
api_format: str | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
health_data = get_health_monitor().get_key_health(context.db, self.key_id, self.api_format)
|
||||||
|
if not health_data:
|
||||||
|
raise NotFoundException(f"Key {self.key_id} 不存在")
|
||||||
|
|
||||||
|
# 构建响应
|
||||||
|
response_data = {
|
||||||
|
"key_id": health_data["key_id"],
|
||||||
|
"key_is_active": health_data["is_active"],
|
||||||
|
"key_statistics": health_data.get("statistics"),
|
||||||
|
"key_health_score": health_data.get("health_score", 1.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.api_format:
|
||||||
|
# 单格式查询
|
||||||
|
response_data["api_format"] = self.api_format
|
||||||
|
response_data["key_consecutive_failures"] = health_data.get("consecutive_failures")
|
||||||
|
response_data["key_last_failure_at"] = health_data.get("last_failure_at")
|
||||||
|
circuit = health_data.get("circuit_breaker", {})
|
||||||
|
response_data["circuit_breaker_open"] = circuit.get("open", False)
|
||||||
|
response_data["circuit_breaker_open_at"] = circuit.get("open_at")
|
||||||
|
response_data["next_probe_at"] = circuit.get("next_probe_at")
|
||||||
|
response_data["half_open_until"] = circuit.get("half_open_until")
|
||||||
|
response_data["half_open_successes"] = circuit.get("half_open_successes", 0)
|
||||||
|
response_data["half_open_failures"] = circuit.get("half_open_failures", 0)
|
||||||
|
else:
|
||||||
|
# 全格式查询
|
||||||
|
response_data["any_circuit_open"] = health_data.get("any_circuit_open", False)
|
||||||
|
response_data["health_by_format"] = health_data.get("health_by_format")
|
||||||
|
|
||||||
|
return HealthStatusResponse(**response_data)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRecoverKeyHealthAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
api_format: str | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
await asyncio.to_thread(_recover_key_health_sync, db, self.key_id, self.api_format)
|
||||||
|
|
||||||
|
if self.api_format:
|
||||||
|
logger.info(f"管理员恢复Key健康状态: {self.key_id}/{self.api_format}")
|
||||||
|
return {
|
||||||
|
"message": f"Key 的 {self.api_format} 格式已恢复",
|
||||||
|
"details": {
|
||||||
|
"api_format": self.api_format,
|
||||||
|
"health_score": 1.0,
|
||||||
|
"circuit_breaker_open": False,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
logger.info(f"管理员恢复Key健康状态: {self.key_id} (所有格式)")
|
||||||
|
return {
|
||||||
|
"message": "Key 所有格式已恢复",
|
||||||
|
"details": {
|
||||||
|
"health_score": 1.0,
|
||||||
|
"circuit_breaker_open": False,
|
||||||
|
"is_active": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminRecoverAllKeysHealthAdapter(AdminApiAdapter):
|
||||||
|
"""批量恢复所有熔断 Key 的健康状态"""
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
recovered_keys = await asyncio.to_thread(_recover_all_keys_health_sync, db)
|
||||||
|
|
||||||
|
if not recovered_keys:
|
||||||
|
return {
|
||||||
|
"message": "没有需要恢复的 Key",
|
||||||
|
"recovered_count": 0,
|
||||||
|
"recovered_keys": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 重置健康监控器的熔断计数
|
||||||
|
HealthMonitor.reset_open_circuit_count()
|
||||||
|
|
||||||
|
logger.info(f"管理员批量恢复 {len(recovered_keys)} 个 Key 的健康状态")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"已恢复 {len(recovered_keys)} 个 Key",
|
||||||
|
"recovered_count": len(recovered_keys),
|
||||||
|
"recovered_keys": recovered_keys,
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
"""
|
||||||
|
Provider API Keys 管理
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Query, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import User
|
||||||
|
from src.models.endpoint_models import (
|
||||||
|
EndpointAPIKeyCreate,
|
||||||
|
EndpointAPIKeyResponse,
|
||||||
|
EndpointAPIKeyUpdate,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys import (
|
||||||
|
batch_delete_endpoint_keys_response,
|
||||||
|
clear_oauth_invalid_response,
|
||||||
|
create_provider_key_response,
|
||||||
|
delete_endpoint_key_response,
|
||||||
|
export_oauth_key_data,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys import get_keys_grouped_by_format as query_keys_grouped_by_format
|
||||||
|
from src.services.provider_keys import (
|
||||||
|
list_provider_keys_responses,
|
||||||
|
refresh_provider_quota_for_provider,
|
||||||
|
reveal_endpoint_key_payload,
|
||||||
|
update_endpoint_key_response,
|
||||||
|
)
|
||||||
|
from src.services.provider_keys.key_quota_service import (
|
||||||
|
CODEX_WHAM_USAGE_URL as _CODEX_WHAM_USAGE_URL,
|
||||||
|
)
|
||||||
|
from src.utils.auth_utils import require_admin
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Provider Keys"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/keys/{key_id}", response_model=EndpointAPIKeyResponse)
|
||||||
|
async def update_endpoint_key(
|
||||||
|
key_id: str,
|
||||||
|
key_data: EndpointAPIKeyUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> EndpointAPIKeyResponse:
|
||||||
|
"""
|
||||||
|
更新 Provider Key
|
||||||
|
|
||||||
|
更新指定 Key 的配置,支持修改并发限制、速率倍数、优先级、
|
||||||
|
配额限制、能力限制等。支持部分更新。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**请求体字段**(均为可选):
|
||||||
|
- `api_key`: 新的 API Key 原文
|
||||||
|
- `name`: Key 名称
|
||||||
|
- `note`: 备注
|
||||||
|
- `rate_multipliers`: 按 API 格式的成本倍率
|
||||||
|
- `internal_priority`: 内部优先级
|
||||||
|
- `rpm_limit`: RPM 限制(设置为 null 可切换到自适应模式)
|
||||||
|
- `allowed_models`: 允许的模型列表
|
||||||
|
- `capabilities`: 能力配置
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含更新后的完整 Key 信息
|
||||||
|
"""
|
||||||
|
adapter = AdminUpdateEndpointKeyAdapter(key_id=key_id, key_data=key_data)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys/grouped-by-format")
|
||||||
|
async def get_keys_grouped_by_format(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
获取按 API 格式分组的所有 Keys
|
||||||
|
|
||||||
|
获取所有活跃的 Key,按 API 格式分组返回,用于全局优先级管理。
|
||||||
|
每个 Key 包含基本信息、健康度指标、能力标签等。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 返回一个字典,键为 API 格式,值为该格式下的 Key 列表
|
||||||
|
- 每个 Key 包含:
|
||||||
|
- `id`: Key ID
|
||||||
|
- `name`: Key 名称
|
||||||
|
- `api_key_masked`: 脱敏后的 API Key
|
||||||
|
- `internal_priority`: 内部优先级
|
||||||
|
- `global_priority_by_format`: 按 API 格式的全局优先级
|
||||||
|
- `format_priority`: 当前格式的优先级
|
||||||
|
- `rate_multipliers`: 按 API 格式的成本倍率
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `circuit_breaker_open`: 熔断器状态
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `endpoint_base_url`: Endpoint 基础 URL
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `capabilities`: 能力简称列表
|
||||||
|
- `success_rate`: 成功率
|
||||||
|
- `avg_response_time_ms`: 平均响应时间
|
||||||
|
- `request_count`: 请求总数
|
||||||
|
"""
|
||||||
|
adapter = AdminGetKeysGroupedByFormatAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys/{key_id}/reveal")
|
||||||
|
async def reveal_endpoint_key(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
获取完整的 API Key
|
||||||
|
|
||||||
|
解密并返回指定 Key 的完整原文,用于查看和复制。
|
||||||
|
此操作会被记录到审计日志。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `api_key`: 完整的 API Key 原文
|
||||||
|
"""
|
||||||
|
adapter = AdminRevealEndpointKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/keys/{key_id}/export")
|
||||||
|
async def export_key(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
导出 OAuth Key 凭据(用于跨实例迁移)
|
||||||
|
|
||||||
|
解密 auth_config,返回精简的扁平 JSON,去掉 null 和临时字段。
|
||||||
|
所有 OAuth Provider 格式统一。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
"""
|
||||||
|
adapter = AdminExportKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/keys/{key_id}")
|
||||||
|
async def delete_endpoint_key(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
删除 Provider Key
|
||||||
|
|
||||||
|
删除指定的 API Key。此操作不可逆,请谨慎使用。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminDeleteEndpointKeyAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys/batch-delete")
|
||||||
|
async def batch_delete_endpoint_keys(
|
||||||
|
request: Request,
|
||||||
|
ids: list[str] = Body(..., embed=True, max_length=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
批量删除 Provider Keys
|
||||||
|
|
||||||
|
一次性删除多个 Key,按 Provider 聚合执行副作用(缓存失效、模型关联检查),
|
||||||
|
避免逐个删除导致的重复 Redis 操作和性能问题。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `ids`: Key ID 列表(最多 100 个)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success_count`: 成功删除的数量
|
||||||
|
- `failed_count`: 失败的数量
|
||||||
|
- `failed`: 失败的详情列表
|
||||||
|
"""
|
||||||
|
adapter = AdminBatchDeleteEndpointKeysAdapter(ids=ids)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/keys/{key_id}/clear-oauth-invalid")
|
||||||
|
async def clear_oauth_invalid(
|
||||||
|
key_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
清除 Key 的 OAuth 失效标记
|
||||||
|
|
||||||
|
手动清除指定 Key 的 oauth_invalid_at / oauth_invalid_reason 状态,
|
||||||
|
通常在管理员确认账号已完成验证后使用。
|
||||||
|
|
||||||
|
这是 admin/status 维修入口,不是 AI 运行时请求恢复路径。
|
||||||
|
Rust 热路径迁移完成后,这里仍负责人工解除 OAuth invalid 标记。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `key_id`: Key ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminClearOAuthInvalidAdapter(key_id=key_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Provider Keys API ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{provider_id}/keys", response_model=list[EndpointAPIKeyResponse])
|
||||||
|
async def list_provider_keys(
|
||||||
|
provider_id: str,
|
||||||
|
request: Request,
|
||||||
|
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[EndpointAPIKeyResponse]:
|
||||||
|
"""
|
||||||
|
获取 Provider 的所有 Keys
|
||||||
|
|
||||||
|
获取指定 Provider 下的所有 API Key 列表,支持多 API 格式。
|
||||||
|
结果按优先级和创建时间排序。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||||
|
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||||
|
"""
|
||||||
|
adapter = AdminListProviderKeysAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{provider_id}/keys", response_model=EndpointAPIKeyResponse)
|
||||||
|
async def add_provider_key(
|
||||||
|
provider_id: str,
|
||||||
|
key_data: EndpointAPIKeyCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> EndpointAPIKeyResponse:
|
||||||
|
"""
|
||||||
|
为 Provider 添加 Key
|
||||||
|
|
||||||
|
为指定 Provider 添加新的 API Key,支持配置多个 API 格式。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `api_formats`: 支持的 API 格式列表(必填)
|
||||||
|
- `api_key`: API Key 原文(将被加密存储)
|
||||||
|
- `name`: Key 名称
|
||||||
|
- 其他配置字段同 Key
|
||||||
|
"""
|
||||||
|
adapter = AdminCreateProviderKeyAdapter(provider_id=provider_id, key_data=key_data)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
key_data: EndpointAPIKeyUpdate
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await update_endpoint_key_response(
|
||||||
|
db=context.db,
|
||||||
|
key_id=self.key_id,
|
||||||
|
key_data=self.key_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRevealEndpointKeyAdapter(AdminApiAdapter):
|
||||||
|
"""获取完整的 API Key 或 Auth Config(用于查看和复制)"""
|
||||||
|
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return reveal_endpoint_key_payload(context.db, self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminExportKeyAdapter(AdminApiAdapter):
|
||||||
|
"""导出 OAuth Key 凭据:解密 auth_config,委托 provider-specific builder 构建导出数据。"""
|
||||||
|
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return export_oauth_key_data(context.db, self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await delete_endpoint_key_response(db=context.db, key_id=self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminBatchDeleteEndpointKeysAdapter(AdminApiAdapter):
|
||||||
|
"""批量删除多个 Provider Key"""
|
||||||
|
|
||||||
|
ids: list[str]
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await batch_delete_endpoint_keys_response(db=context.db, key_ids=self.ids)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminClearOAuthInvalidAdapter(AdminApiAdapter):
|
||||||
|
"""清除 Key 的 OAuth 失效标记。"""
|
||||||
|
|
||||||
|
key_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return clear_oauth_invalid_response(context.db, self.key_id)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetKeysGroupedByFormatAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return query_keys_grouped_by_format(context.db)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminListProviderKeysAdapter(AdminApiAdapter):
|
||||||
|
"""获取 Provider 的所有 Keys"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return list_provider_keys_responses(context.db, self.provider_id, self.skip, self.limit)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminCreateProviderKeyAdapter(AdminApiAdapter):
|
||||||
|
"""为 Provider 添加 Key"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
key_data: EndpointAPIKeyCreate
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await create_provider_key_response(
|
||||||
|
db=context.db,
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
key_data=self.key_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Quota Refresh API ==========
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshProviderQuotaRequest(BaseModel):
|
||||||
|
key_ids: list[str] | None = Field(default=None, description="仅刷新指定 Key 列表(可选)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{provider_id}/refresh-quota")
|
||||||
|
async def refresh_provider_quota(
|
||||||
|
provider_id: str,
|
||||||
|
request: Request,
|
||||||
|
payload: RefreshProviderQuotaRequest | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
刷新 Provider 所有 Keys 的限额信息
|
||||||
|
|
||||||
|
支持的 Provider 类型:
|
||||||
|
- Codex: 调用 wham/usage API 获取限额
|
||||||
|
- Antigravity: 调用 fetchAvailableModels 获取配额
|
||||||
|
- Kiro: 调用 getUsageLimits API 获取使用额度
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
**请求体**(可选):
|
||||||
|
- `key_ids`: 仅刷新指定 Key 列表,不传时刷新所有活跃 Key
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success`: 成功刷新的 Key 数量
|
||||||
|
- `failed`: 失败的 Key 数量
|
||||||
|
- `results`: 每个 Key 的刷新结果
|
||||||
|
"""
|
||||||
|
adapter = AdminRefreshProviderQuotaAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
key_ids=payload.key_ids if payload else None,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminRefreshProviderQuotaAdapter(AdminApiAdapter):
|
||||||
|
"""刷新 Provider 所有 Keys 的限额信息"""
|
||||||
|
|
||||||
|
provider_id: str
|
||||||
|
key_ids: list[str] | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await refresh_provider_quota_for_provider(
|
||||||
|
db=context.db,
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
codex_wham_usage_url=_CODEX_WHAM_USAGE_URL,
|
||||||
|
key_ids=self.key_ids,
|
||||||
|
)
|
||||||
@@ -0,0 +1,637 @@
|
|||||||
|
"""
|
||||||
|
ProviderEndpoint CRUD 管理 API
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy import and_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.models_service import invalidate_models_list_cache
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.api_format.metadata import get_default_body_rules_for_endpoint
|
||||||
|
from src.core.api_format.signature import parse_signature_key
|
||||||
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||||
|
from src.core.provider_types import ProviderType
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
|
||||||
|
from src.models.endpoint_models import (
|
||||||
|
ProviderEndpointCreate,
|
||||||
|
ProviderEndpointResponse,
|
||||||
|
ProviderEndpointUpdate,
|
||||||
|
)
|
||||||
|
from src.services.provider.stream_policy import UpstreamStreamPolicy, parse_upstream_stream_policy
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Endpoint Management"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
|
||||||
|
def mask_proxy_password(proxy_config: dict | None) -> dict | None:
|
||||||
|
"""对代理配置中的密码进行脱敏处理"""
|
||||||
|
if not proxy_config:
|
||||||
|
return None
|
||||||
|
masked = dict(proxy_config)
|
||||||
|
if masked.get("password"):
|
||||||
|
masked["password"] = "***"
|
||||||
|
return masked
|
||||||
|
|
||||||
|
|
||||||
|
def _is_fixed_provider(provider_type: str | None) -> bool:
|
||||||
|
"""Whether this provider_type is managed by fixed-provider templates."""
|
||||||
|
normalized = (provider_type or "custom").strip().lower()
|
||||||
|
if normalized == ProviderType.CUSTOM.value:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return ProviderType(normalized) in FIXED_PROVIDERS
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers/{provider_id}/endpoints", response_model=list[ProviderEndpointResponse])
|
||||||
|
async def list_provider_endpoints(
|
||||||
|
provider_id: str,
|
||||||
|
request: Request,
|
||||||
|
skip: int = Query(0, ge=0, description="跳过的记录数"),
|
||||||
|
limit: int = Query(100, ge=1, le=1000, description="返回的最大记录数"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> list[ProviderEndpointResponse]:
|
||||||
|
"""
|
||||||
|
获取指定 Provider 的所有 Endpoints
|
||||||
|
|
||||||
|
获取指定 Provider 下的所有 Endpoint 列表,包括配置、统计信息等。
|
||||||
|
结果按创建时间倒序排列。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**查询参数**:
|
||||||
|
- `skip`: 跳过的记录数,用于分页(默认 0)
|
||||||
|
- `limit`: 返回的最大记录数(1-1000,默认 100)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: Endpoint ID
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `proxy`: 代理配置(密码已脱敏)
|
||||||
|
- 其他配置字段
|
||||||
|
"""
|
||||||
|
adapter = AdminListProviderEndpointsAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/providers/{provider_id}/endpoints", response_model=ProviderEndpointResponse)
|
||||||
|
async def create_provider_endpoint(
|
||||||
|
provider_id: str,
|
||||||
|
endpoint_data: ProviderEndpointCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProviderEndpointResponse:
|
||||||
|
"""
|
||||||
|
为 Provider 创建新的 Endpoint
|
||||||
|
|
||||||
|
为指定 Provider 创建新的 Endpoint,每个 Provider 的每种 API 格式
|
||||||
|
只能创建一个 Endpoint。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `provider_id`: Provider ID(必须与路径参数一致)
|
||||||
|
- `api_format`: API 格式(如 claude、openai、gemini 等)
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径(可选)
|
||||||
|
- `header_rules`: 请求头规则列表(可选,支持 set/drop/rename 操作)
|
||||||
|
- `max_retries`: 最大重试次数(默认 2)
|
||||||
|
- `config`: 额外配置(可选)
|
||||||
|
- `proxy`: 代理配置(可选)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含完整的 Endpoint 信息
|
||||||
|
"""
|
||||||
|
adapter = AdminCreateProviderEndpointAdapter(
|
||||||
|
provider_id=provider_id,
|
||||||
|
endpoint_data=endpoint_data,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{api_format}/body-rules")
|
||||||
|
async def get_default_endpoint_body_rules(
|
||||||
|
api_format: str,
|
||||||
|
request: Request,
|
||||||
|
provider_type: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取指定 endpoint signature 的默认 body_rules。"""
|
||||||
|
adapter = AdminGetDefaultBodyRulesAdapter(
|
||||||
|
api_format=api_format, provider_type=provider_type or None
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
||||||
|
async def get_endpoint(
|
||||||
|
endpoint_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProviderEndpointResponse:
|
||||||
|
"""
|
||||||
|
获取 Endpoint 详情
|
||||||
|
|
||||||
|
获取指定 Endpoint 的详细信息,包括配置、统计信息等。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `id`: Endpoint ID
|
||||||
|
- `provider_id`: Provider ID
|
||||||
|
- `provider_name`: Provider 名称
|
||||||
|
- `api_format`: API 格式
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `total_keys`: Key 总数
|
||||||
|
- `active_keys`: 活跃 Key 数量
|
||||||
|
- `proxy`: 代理配置(密码已脱敏)
|
||||||
|
- 其他配置字段
|
||||||
|
"""
|
||||||
|
adapter = AdminGetProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
||||||
|
async def update_endpoint(
|
||||||
|
endpoint_id: str,
|
||||||
|
endpoint_data: ProviderEndpointUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProviderEndpointResponse:
|
||||||
|
"""
|
||||||
|
更新 Endpoint
|
||||||
|
|
||||||
|
更新指定 Endpoint 的配置。支持部分更新。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**请求体字段**(均为可选):
|
||||||
|
- `base_url`: 基础 URL
|
||||||
|
- `custom_path`: 自定义路径
|
||||||
|
- `header_rules`: 请求头规则列表
|
||||||
|
- `max_retries`: 最大重试次数
|
||||||
|
- `is_active`: 是否活跃
|
||||||
|
- `config`: 额外配置
|
||||||
|
- `proxy`: 代理配置(设置为 null 可清除代理)
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- 包含更新后的完整 Endpoint 信息
|
||||||
|
"""
|
||||||
|
adapter = AdminUpdateProviderEndpointAdapter(
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
endpoint_data=endpoint_data,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{endpoint_id}")
|
||||||
|
async def delete_endpoint(
|
||||||
|
endpoint_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
删除 Endpoint
|
||||||
|
|
||||||
|
删除指定的 Endpoint,会影响该 Provider 在该 API 格式下的路由能力。
|
||||||
|
Key 不会被删除,但包含该 API 格式的 Key 将无法被调度使用(直到重新创建该格式的 Endpoint)。
|
||||||
|
|
||||||
|
**路径参数**:
|
||||||
|
- `endpoint_id`: Endpoint ID
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
- `affected_keys_count`: 受影响的 Key 数量(包含该 API 格式)
|
||||||
|
"""
|
||||||
|
adapter = AdminDeleteProviderEndpointAdapter(endpoint_id=endpoint_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# -------- Adapters --------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminListProviderEndpointsAdapter(AdminApiAdapter):
|
||||||
|
provider_id: str
|
||||||
|
skip: int
|
||||||
|
limit: int
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||||
|
|
||||||
|
endpoints = (
|
||||||
|
db.query(ProviderEndpoint)
|
||||||
|
.filter(ProviderEndpoint.provider_id == self.provider_id)
|
||||||
|
.order_by(ProviderEndpoint.created_at.desc())
|
||||||
|
.offset(self.skip)
|
||||||
|
.limit(self.limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Key 是 Provider 级别资源:按 key.api_formats 归类到各 Endpoint.api_format 下
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
|
.filter(ProviderAPIKey.provider_id == self.provider_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_keys_map: dict[str, int] = {}
|
||||||
|
active_keys_map: dict[str, int] = {}
|
||||||
|
for api_formats, is_active in keys:
|
||||||
|
for fmt in api_formats or []:
|
||||||
|
total_keys_map[fmt] = total_keys_map.get(fmt, 0) + 1
|
||||||
|
if is_active:
|
||||||
|
active_keys_map[fmt] = active_keys_map.get(fmt, 0) + 1
|
||||||
|
|
||||||
|
result: list[ProviderEndpointResponse] = []
|
||||||
|
for endpoint in endpoints:
|
||||||
|
endpoint_format = (
|
||||||
|
endpoint.api_format
|
||||||
|
if isinstance(endpoint.api_format, str)
|
||||||
|
else endpoint.api_format.value
|
||||||
|
)
|
||||||
|
endpoint_dict = {
|
||||||
|
**endpoint.__dict__,
|
||||||
|
"provider_name": provider.name,
|
||||||
|
"api_format": endpoint.api_format,
|
||||||
|
"total_keys": total_keys_map.get(endpoint_format, 0),
|
||||||
|
"active_keys": active_keys_map.get(endpoint_format, 0),
|
||||||
|
"proxy": mask_proxy_password(endpoint.proxy),
|
||||||
|
}
|
||||||
|
endpoint_dict.pop("_sa_instance_state", None)
|
||||||
|
result.append(ProviderEndpointResponse(**endpoint_dict))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
||||||
|
provider_id: str
|
||||||
|
endpoint_data: ProviderEndpointCreate
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
|
if not provider:
|
||||||
|
raise NotFoundException(f"Provider {self.provider_id} 不存在")
|
||||||
|
|
||||||
|
# 固定类型 Provider:禁止通过该接口新增 Endpoints(端点由模板自动创建并锁定)
|
||||||
|
provider_type = getattr(provider, "provider_type", None) or "custom"
|
||||||
|
if _is_fixed_provider(provider_type):
|
||||||
|
raise InvalidRequestException("固定类型 Provider 不允许手动新增 Endpoint")
|
||||||
|
|
||||||
|
if self.endpoint_data.provider_id != self.provider_id:
|
||||||
|
raise InvalidRequestException("provider_id 不匹配")
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
db.query(ProviderEndpoint)
|
||||||
|
.filter(
|
||||||
|
and_(
|
||||||
|
ProviderEndpoint.provider_id == self.provider_id,
|
||||||
|
ProviderEndpoint.api_format == self.endpoint_data.api_format,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
f"Provider {provider.name} 已存在 {self.endpoint_data.api_format} 格式的 Endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
sig = parse_signature_key(self.endpoint_data.api_format)
|
||||||
|
api_family = sig.api_family.value
|
||||||
|
endpoint_kind = sig.endpoint_kind.value
|
||||||
|
# 使用归一化后的 signature key,确保格式一致性
|
||||||
|
normalized_api_format = sig.key
|
||||||
|
body_rules = self.endpoint_data.body_rules
|
||||||
|
if body_rules is None:
|
||||||
|
body_rules = (
|
||||||
|
get_default_body_rules_for_endpoint(
|
||||||
|
normalized_api_format, provider_type=provider_type
|
||||||
|
)
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
|
||||||
|
new_endpoint = ProviderEndpoint(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
provider_id=self.provider_id,
|
||||||
|
api_format=normalized_api_format,
|
||||||
|
api_family=api_family,
|
||||||
|
endpoint_kind=endpoint_kind,
|
||||||
|
base_url=self.endpoint_data.base_url,
|
||||||
|
custom_path=self.endpoint_data.custom_path,
|
||||||
|
header_rules=self.endpoint_data.header_rules,
|
||||||
|
body_rules=body_rules,
|
||||||
|
max_retries=self.endpoint_data.max_retries,
|
||||||
|
is_active=True,
|
||||||
|
config=self.endpoint_data.config,
|
||||||
|
proxy=self.endpoint_data.proxy.model_dump() if self.endpoint_data.proxy else None,
|
||||||
|
format_acceptance_config=self.endpoint_data.format_acceptance_config,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_endpoint)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_endpoint)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[OK] 创建 Endpoint: Provider={provider.name}, Format={self.endpoint_data.api_format}, ID={new_endpoint.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint_dict = {
|
||||||
|
k: v
|
||||||
|
for k, v in new_endpoint.__dict__.items()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetProviderEndpointAdapter(AdminApiAdapter):
|
||||||
|
endpoint_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
endpoint = (
|
||||||
|
db.query(ProviderEndpoint, Provider)
|
||||||
|
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
|
||||||
|
.filter(ProviderEndpoint.id == self.endpoint_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not endpoint:
|
||||||
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
|
endpoint_obj, provider = endpoint
|
||||||
|
endpoint_format = (
|
||||||
|
endpoint_obj.api_format
|
||||||
|
if isinstance(endpoint_obj.api_format, str)
|
||||||
|
else endpoint_obj.api_format.value
|
||||||
|
)
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
|
.filter(ProviderAPIKey.provider_id == endpoint_obj.provider_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_keys = 0
|
||||||
|
active_keys = 0
|
||||||
|
for api_formats, is_active in keys:
|
||||||
|
if endpoint_format in (api_formats or []):
|
||||||
|
total_keys += 1
|
||||||
|
if is_active:
|
||||||
|
active_keys += 1
|
||||||
|
|
||||||
|
endpoint_dict = {
|
||||||
|
k: v
|
||||||
|
for k, v in endpoint_obj.__dict__.items()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminUpdateProviderEndpointAdapter(AdminApiAdapter):
|
||||||
|
endpoint_id: str
|
||||||
|
endpoint_data: ProviderEndpointUpdate
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
endpoint = (
|
||||||
|
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
||||||
|
)
|
||||||
|
if not endpoint:
|
||||||
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
|
update_data = self.endpoint_data.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
# 固定类型 Provider 的 endpoint:锁定 base_url/custom_path(前端禁用仅是 UX,后端必须强校验)
|
||||||
|
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||||
|
if provider:
|
||||||
|
provider_type = getattr(provider, "provider_type", "custom")
|
||||||
|
if _is_fixed_provider(provider_type):
|
||||||
|
if "base_url" in update_data or "custom_path" in update_data:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"固定类型 Provider 的 Endpoint 不允许修改 base_url/custom_path"
|
||||||
|
)
|
||||||
|
normalized_provider_type = str(provider_type or "custom").strip().lower()
|
||||||
|
endpoint_sig = str(getattr(endpoint, "api_format", "") or "").strip().lower()
|
||||||
|
if (
|
||||||
|
normalized_provider_type == ProviderType.CODEX.value
|
||||||
|
and endpoint_sig == "openai:cli"
|
||||||
|
):
|
||||||
|
has_config_in_payload = "config" in update_data
|
||||||
|
cfg_payload = (
|
||||||
|
update_data.get("config")
|
||||||
|
if has_config_in_payload
|
||||||
|
else getattr(endpoint, "config", None)
|
||||||
|
)
|
||||||
|
cfg = dict(cfg_payload) if isinstance(cfg_payload, dict) else {}
|
||||||
|
requested = (
|
||||||
|
cfg.get("upstream_stream_policy")
|
||||||
|
or cfg.get("upstreamStreamPolicy")
|
||||||
|
or cfg.get("upstream_stream")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
has_config_in_payload
|
||||||
|
and requested is not None
|
||||||
|
and parse_upstream_stream_policy(requested)
|
||||||
|
!= UpstreamStreamPolicy.FORCE_STREAM
|
||||||
|
):
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"Codex OpenAI CLI 端点固定为强制流式,不允许修改"
|
||||||
|
)
|
||||||
|
cfg.pop("upstreamStreamPolicy", None)
|
||||||
|
cfg.pop("upstream_stream", None)
|
||||||
|
cfg["upstream_stream_policy"] = "force_stream"
|
||||||
|
update_data["config"] = cfg
|
||||||
|
|
||||||
|
# 把 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 时保留,用于清除代理配置
|
||||||
|
|
||||||
|
# JSON 列需要 flag_modified 以确保 SQLAlchemy 检测到变更
|
||||||
|
json_fields = {"header_rules", "body_rules", "config", "proxy", "format_acceptance_config"}
|
||||||
|
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(endpoint, field, value)
|
||||||
|
if field in json_fields:
|
||||||
|
flag_modified(endpoint, field)
|
||||||
|
|
||||||
|
# Phase 3/4: 自动维护新架构字段,确保新增/历史数据都能被调度器按 family/kind 查询
|
||||||
|
sig = parse_signature_key(endpoint.api_format)
|
||||||
|
endpoint.api_family = sig.api_family.value
|
||||||
|
endpoint.endpoint_kind = sig.endpoint_kind.value
|
||||||
|
endpoint.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(endpoint)
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存(is_active 变更会影响模型可用性)
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
provider = db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
|
||||||
|
logger.info(
|
||||||
|
f"[OK] 更新 Endpoint: ID={self.endpoint_id}, Updates={list(update_data.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
endpoint_format = (
|
||||||
|
endpoint.api_format
|
||||||
|
if isinstance(endpoint.api_format, str)
|
||||||
|
else endpoint.api_format.value
|
||||||
|
)
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey.api_formats, ProviderAPIKey.is_active)
|
||||||
|
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_keys = 0
|
||||||
|
active_keys = 0
|
||||||
|
for api_formats, is_active in keys:
|
||||||
|
if endpoint_format in (api_formats or []):
|
||||||
|
total_keys += 1
|
||||||
|
if is_active:
|
||||||
|
active_keys += 1
|
||||||
|
|
||||||
|
endpoint_dict = {
|
||||||
|
k: v
|
||||||
|
for k, v in endpoint.__dict__.items()
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
|
||||||
|
endpoint_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
endpoint = (
|
||||||
|
db.query(ProviderEndpoint).filter(ProviderEndpoint.id == self.endpoint_id).first()
|
||||||
|
)
|
||||||
|
if not endpoint:
|
||||||
|
raise NotFoundException(f"Endpoint {self.endpoint_id} 不存在")
|
||||||
|
|
||||||
|
endpoint_format = (
|
||||||
|
endpoint.api_format
|
||||||
|
if isinstance(endpoint.api_format, str)
|
||||||
|
else endpoint.api_format.value
|
||||||
|
)
|
||||||
|
|
||||||
|
# 查询包含该格式的所有 Key,并从 api_formats 中移除该格式
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.filter(ProviderAPIKey.provider_id == endpoint.provider_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
affected_keys_count = 0
|
||||||
|
for key in keys:
|
||||||
|
if key.api_formats and endpoint_format in key.api_formats:
|
||||||
|
affected_keys_count += 1
|
||||||
|
# 移除该格式
|
||||||
|
new_formats = [f for f in key.api_formats if f != endpoint_format]
|
||||||
|
key.api_formats = new_formats if new_formats else []
|
||||||
|
flag_modified(key, "api_formats")
|
||||||
|
|
||||||
|
db.delete(endpoint)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 清除 /v1/models 列表缓存
|
||||||
|
await invalidate_models_list_cache()
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"[DELETE] 删除 Endpoint: ID={self.endpoint_id}, Format={endpoint_format}, "
|
||||||
|
f"AffectedKeys={affected_keys_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Endpoint {self.endpoint_id} 已删除",
|
||||||
|
"affected_keys_count": affected_keys_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetDefaultBodyRulesAdapter(AdminApiAdapter):
|
||||||
|
api_format: str
|
||||||
|
provider_type: str | None = None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
try:
|
||||||
|
normalized_api_format = parse_signature_key(self.api_format).key
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"无效的 api_format: {self.api_format}") from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_format": normalized_api_format,
|
||||||
|
"body_rules": get_default_body_rules_for_endpoint(
|
||||||
|
normalized_api_format, provider_type=self.provider_type
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""
|
||||||
|
Gemini Files 管理 API
|
||||||
|
|
||||||
|
提供文件映射管理与能力查询;上传入口已收成 Rust-only 兼容壳。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import delete, func
|
||||||
|
from sqlalchemy.orm import Session, load_only
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import GeminiFileMapping, ProviderAPIKey, User
|
||||||
|
from src.services.gemini_files_mapping import delete_file_key_mapping
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/gemini-files", tags=["Gemini Files Management"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
_RUST_UPLOADER_DETAIL = "Admin Gemini file upload requires Rust uploader"
|
||||||
|
|
||||||
|
|
||||||
|
class FileMappingResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
file_name: str
|
||||||
|
key_id: str
|
||||||
|
key_name: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
mime_type: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
expires_at: datetime
|
||||||
|
is_expired: bool
|
||||||
|
|
||||||
|
|
||||||
|
class FileMappingListResponse(BaseModel):
|
||||||
|
items: list[FileMappingResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class FileMappingStatsResponse(BaseModel):
|
||||||
|
total_mappings: int
|
||||||
|
active_mappings: int
|
||||||
|
expired_mappings: int
|
||||||
|
by_mime_type: dict[str, int]
|
||||||
|
capable_keys_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CapableKeyResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
provider_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResultItem(BaseModel):
|
||||||
|
key_id: str
|
||||||
|
key_name: str | None = None
|
||||||
|
success: bool
|
||||||
|
file_name: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResponse(BaseModel):
|
||||||
|
display_name: str
|
||||||
|
mime_type: str
|
||||||
|
size_bytes: int
|
||||||
|
results: list[UploadResultItem]
|
||||||
|
success_count: int
|
||||||
|
fail_count: int
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_file_mappings_response(
|
||||||
|
*,
|
||||||
|
db: Session,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
include_expired: bool,
|
||||||
|
search: str | None,
|
||||||
|
) -> FileMappingListResponse:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
query = db.query(GeminiFileMapping)
|
||||||
|
count_query = db.query(func.count(GeminiFileMapping.id))
|
||||||
|
|
||||||
|
if not include_expired:
|
||||||
|
active_filter = GeminiFileMapping.expires_at > now
|
||||||
|
query = query.filter(active_filter)
|
||||||
|
count_query = count_query.filter(active_filter)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
search_filter = (GeminiFileMapping.file_name.ilike(search_pattern)) | (
|
||||||
|
GeminiFileMapping.display_name.ilike(search_pattern)
|
||||||
|
)
|
||||||
|
query = query.filter(search_filter)
|
||||||
|
count_query = count_query.filter(search_filter)
|
||||||
|
|
||||||
|
total = int(count_query.scalar() or 0)
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
mappings = (
|
||||||
|
query.options(
|
||||||
|
load_only(
|
||||||
|
GeminiFileMapping.id,
|
||||||
|
GeminiFileMapping.file_name,
|
||||||
|
GeminiFileMapping.key_id,
|
||||||
|
GeminiFileMapping.user_id,
|
||||||
|
GeminiFileMapping.display_name,
|
||||||
|
GeminiFileMapping.mime_type,
|
||||||
|
GeminiFileMapping.created_at,
|
||||||
|
GeminiFileMapping.expires_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(GeminiFileMapping.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(page_size)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
key_ids = {m.key_id for m in mappings}
|
||||||
|
user_ids = {m.user_id for m in mappings if m.user_id}
|
||||||
|
|
||||||
|
keys_map: dict[str, str | None] = {}
|
||||||
|
if key_ids:
|
||||||
|
keys = (
|
||||||
|
db.query(ProviderAPIKey)
|
||||||
|
.options(load_only(ProviderAPIKey.id, ProviderAPIKey.name))
|
||||||
|
.filter(ProviderAPIKey.id.in_(key_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
keys_map = {str(k.id): k.name for k in keys}
|
||||||
|
|
||||||
|
users_map: dict[str, str | None] = {}
|
||||||
|
if user_ids:
|
||||||
|
users = (
|
||||||
|
db.query(User)
|
||||||
|
.options(load_only(User.id, User.username))
|
||||||
|
.filter(User.id.in_(user_ids))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
users_map = {str(u.id): u.username for u in users}
|
||||||
|
|
||||||
|
return FileMappingListResponse(
|
||||||
|
items=[
|
||||||
|
FileMappingResponse(
|
||||||
|
id=str(m.id),
|
||||||
|
file_name=m.file_name,
|
||||||
|
key_id=str(m.key_id),
|
||||||
|
key_name=keys_map.get(str(m.key_id)),
|
||||||
|
user_id=str(m.user_id) if m.user_id else None,
|
||||||
|
username=users_map.get(str(m.user_id)) if m.user_id else None,
|
||||||
|
display_name=m.display_name,
|
||||||
|
mime_type=m.mime_type,
|
||||||
|
created_at=m.created_at,
|
||||||
|
expires_at=m.expires_at,
|
||||||
|
is_expired=m.expires_at <= now,
|
||||||
|
)
|
||||||
|
for m in mappings
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_file_mapping_stats_response(*, db: Session) -> FileMappingStatsResponse:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
total_mappings = db.query(func.count(GeminiFileMapping.id)).scalar() or 0
|
||||||
|
active_mappings = (
|
||||||
|
db.query(func.count(GeminiFileMapping.id))
|
||||||
|
.filter(GeminiFileMapping.expires_at > now)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
expired_mappings = total_mappings - active_mappings
|
||||||
|
mime_stats = (
|
||||||
|
db.query(GeminiFileMapping.mime_type, func.count(GeminiFileMapping.id))
|
||||||
|
.filter(GeminiFileMapping.expires_at > now)
|
||||||
|
.group_by(GeminiFileMapping.mime_type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_mime_type = {(mime_type or "unknown"): count for mime_type, count in mime_stats}
|
||||||
|
keys = db.query(ProviderAPIKey.capabilities).filter(ProviderAPIKey.is_active.is_(True)).all()
|
||||||
|
capable_keys_count = sum(
|
||||||
|
1
|
||||||
|
for (capabilities,) in keys
|
||||||
|
if isinstance(capabilities, dict) and capabilities.get("gemini_files", False)
|
||||||
|
)
|
||||||
|
return FileMappingStatsResponse(
|
||||||
|
total_mappings=total_mappings,
|
||||||
|
active_mappings=active_mappings,
|
||||||
|
expired_mappings=expired_mappings,
|
||||||
|
by_mime_type=by_mime_type,
|
||||||
|
capable_keys_count=capable_keys_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_mapping_response(*, db: Session, mapping_id: str) -> dict[str, Any]:
|
||||||
|
mapping = db.query(GeminiFileMapping).filter(GeminiFileMapping.id == mapping_id).first()
|
||||||
|
if not mapping:
|
||||||
|
raise HTTPException(status_code=404, detail="Mapping not found")
|
||||||
|
file_name = mapping.file_name
|
||||||
|
db.delete(mapping)
|
||||||
|
db.commit()
|
||||||
|
await delete_file_key_mapping(file_name)
|
||||||
|
return {"message": "Mapping deleted successfully", "file_name": file_name}
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_expired_mappings_response(*, db: Session) -> dict[str, Any]:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = db.execute(delete(GeminiFileMapping).where(GeminiFileMapping.expires_at <= now))
|
||||||
|
db.commit()
|
||||||
|
deleted_count = result.rowcount
|
||||||
|
return {
|
||||||
|
"message": f"Cleaned up {deleted_count} expired mappings",
|
||||||
|
"deleted_count": deleted_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_capable_keys_response(*, db: Session) -> list[CapableKeyResponse]:
|
||||||
|
from src.models.database import Provider
|
||||||
|
|
||||||
|
key_rows = (
|
||||||
|
db.query(
|
||||||
|
ProviderAPIKey.id,
|
||||||
|
ProviderAPIKey.name,
|
||||||
|
ProviderAPIKey.provider_id,
|
||||||
|
ProviderAPIKey.capabilities,
|
||||||
|
)
|
||||||
|
.filter(ProviderAPIKey.is_active.is_(True))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
capable_keys = [
|
||||||
|
key
|
||||||
|
for key in key_rows
|
||||||
|
if isinstance(key.capabilities, dict) and key.capabilities.get("gemini_files", False)
|
||||||
|
]
|
||||||
|
|
||||||
|
provider_ids = {key.provider_id for key in capable_keys if key.provider_id}
|
||||||
|
provider_map: dict[str, str] = {}
|
||||||
|
if provider_ids:
|
||||||
|
providers = db.query(Provider.id, Provider.name).filter(Provider.id.in_(provider_ids)).all()
|
||||||
|
provider_map = {str(provider_id): provider_name for provider_id, provider_name in providers}
|
||||||
|
|
||||||
|
return [
|
||||||
|
CapableKeyResponse(
|
||||||
|
id=str(key.id),
|
||||||
|
name=key.name,
|
||||||
|
provider_name=provider_map.get(str(key.provider_id)),
|
||||||
|
)
|
||||||
|
for key in capable_keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload_file_response(*, file: UploadFile, key_ids: str) -> Any:
|
||||||
|
del file, key_ids
|
||||||
|
raise HTTPException(status_code=503, detail=_RUST_UPLOADER_DETAIL)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGeminiFilesListMappingsAdapter(AdminApiAdapter):
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
include_expired: bool
|
||||||
|
search: str | None
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await _list_file_mappings_response(
|
||||||
|
db=context.db,
|
||||||
|
page=self.page,
|
||||||
|
page_size=self.page_size,
|
||||||
|
include_expired=self.include_expired,
|
||||||
|
search=self.search,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGeminiFilesStatsAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await _get_file_mapping_stats_response(db=context.db)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGeminiFilesDeleteMappingAdapter(AdminApiAdapter):
|
||||||
|
mapping_id: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await _delete_mapping_response(db=context.db, mapping_id=self.mapping_id)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGeminiFilesCleanupMappingsAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await _cleanup_expired_mappings_response(db=context.db)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGeminiFilesCapableKeysAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
return await _list_capable_keys_response(db=context.db)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGeminiFilesUploadAdapter(AdminApiAdapter):
|
||||||
|
file: UploadFile
|
||||||
|
key_ids: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
del context
|
||||||
|
return await _upload_file_response(file=self.file, key_ids=self.key_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/mappings", response_model=FileMappingListResponse)
|
||||||
|
async def list_file_mappings(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
include_expired: bool = Query(False),
|
||||||
|
search: str | None = Query(None),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesListMappingsAdapter(
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
include_expired=include_expired,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=FileMappingStatsResponse)
|
||||||
|
async def get_file_mapping_stats(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesStatsAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mappings/{mapping_id}")
|
||||||
|
async def delete_mapping(
|
||||||
|
mapping_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesDeleteMappingAdapter(mapping_id=mapping_id)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/mappings")
|
||||||
|
async def cleanup_expired_mappings(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesCleanupMappingsAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capable-keys", response_model=list[CapableKeyResponse])
|
||||||
|
async def list_capable_keys(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesCapableKeysAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload", response_model=UploadResponse)
|
||||||
|
async def upload_file(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
key_ids: str = Query(..., description="逗号分隔的 Key ID 列表"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Any:
|
||||||
|
adapter = AdminGeminiFilesUploadAdapter(file=file, key_ids=key_ids)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""LDAP配置管理API端点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel, Field, ValidationError, field_validator
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from src.api.base.admin_adapter import AdminApiAdapter
|
||||||
|
from src.api.base.context import ApiRequestContext
|
||||||
|
from src.api.base.pipeline import get_pipeline
|
||||||
|
from src.core.crypto import crypto_service
|
||||||
|
from src.core.enums import AuthSource
|
||||||
|
from src.core.exceptions import InvalidRequestException, translate_pydantic_error
|
||||||
|
from src.core.logger import logger
|
||||||
|
from src.database import get_db
|
||||||
|
from src.models.database import AuditEventType, LDAPConfig, User, UserRole
|
||||||
|
from src.services.system.audit import AuditService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/ldap", tags=["Admin - LDAP"])
|
||||||
|
pipeline = get_pipeline()
|
||||||
|
|
||||||
|
# bcrypt 哈希格式正则:$2a$, $2b$, $2y$ + 2位cost + $ + 53字符(22位salt + 31位hash)
|
||||||
|
BCRYPT_HASH_PATTERN = re.compile(r"^\$2[aby]\$\d{2}\$.{53}$")
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Request/Response Models ==========
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigResponse(BaseModel):
|
||||||
|
"""LDAP配置响应(不返回密码)"""
|
||||||
|
|
||||||
|
server_url: str | None = None
|
||||||
|
bind_dn: str | None = None
|
||||||
|
base_dn: str | None = None
|
||||||
|
has_bind_password: bool = False
|
||||||
|
user_search_filter: str
|
||||||
|
username_attr: str
|
||||||
|
email_attr: str
|
||||||
|
display_name_attr: str
|
||||||
|
is_enabled: bool
|
||||||
|
is_exclusive: bool
|
||||||
|
use_starttls: bool
|
||||||
|
connect_timeout: int
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigUpdate(BaseModel):
|
||||||
|
"""LDAP配置更新请求"""
|
||||||
|
|
||||||
|
server_url: str = Field(..., min_length=1, max_length=255)
|
||||||
|
bind_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
# 允许空字符串表示"清除密码";非空时自动 strip 并校验不能为空
|
||||||
|
bind_password: str | None = Field(None, max_length=1024)
|
||||||
|
base_dn: str = Field(..., min_length=1, max_length=255)
|
||||||
|
user_search_filter: str = Field(default="(uid={username})", max_length=500)
|
||||||
|
username_attr: str = Field(default="uid", max_length=50)
|
||||||
|
email_attr: str = Field(default="mail", max_length=50)
|
||||||
|
display_name_attr: str = Field(default="cn", max_length=50)
|
||||||
|
is_enabled: bool = False
|
||||||
|
is_exclusive: bool = False
|
||||||
|
use_starttls: bool = False
|
||||||
|
connect_timeout: int = Field(default=10, ge=1, le=60) # 单次操作超时,跨国网络建议 15-30 秒
|
||||||
|
|
||||||
|
@field_validator("bind_password")
|
||||||
|
@classmethod
|
||||||
|
def validate_bind_password(cls, v: str | None) -> str | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return v
|
||||||
|
v = v.strip()
|
||||||
|
if not v:
|
||||||
|
raise ValueError("绑定密码不能为空")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: str) -> str:
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度,防止构造复杂查询
|
||||||
|
# 检查嵌套层数而非括号总数
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPTestResponse(BaseModel):
|
||||||
|
"""LDAP连接测试响应"""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class LDAPConfigTest(BaseModel):
|
||||||
|
"""LDAP配置测试请求(全部可选,用于临时覆盖)"""
|
||||||
|
|
||||||
|
server_url: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_dn: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
bind_password: str | None = Field(None, min_length=1)
|
||||||
|
base_dn: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
user_search_filter: str | None = Field(None, max_length=500)
|
||||||
|
username_attr: str | None = Field(None, max_length=50)
|
||||||
|
email_attr: str | None = Field(None, max_length=50)
|
||||||
|
display_name_attr: str | None = Field(None, max_length=50)
|
||||||
|
is_enabled: bool | None = None
|
||||||
|
is_exclusive: bool | None = None
|
||||||
|
use_starttls: bool | None = None
|
||||||
|
connect_timeout: int | None = Field(None, ge=1, le=60)
|
||||||
|
|
||||||
|
@field_validator("user_search_filter")
|
||||||
|
@classmethod
|
||||||
|
def validate_search_filter(cls, v: str | None) -> str | None:
|
||||||
|
if v is None:
|
||||||
|
return v
|
||||||
|
if "{username}" not in v:
|
||||||
|
raise ValueError("搜索过滤器必须包含 {username} 占位符")
|
||||||
|
# 验证括号匹配和嵌套正确性
|
||||||
|
depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
if depth != 0:
|
||||||
|
raise ValueError("搜索过滤器括号不匹配")
|
||||||
|
# 限制过滤器复杂度(检查嵌套层数而非括号总数)
|
||||||
|
depth = 0
|
||||||
|
max_depth = 0
|
||||||
|
for char in v:
|
||||||
|
if char == "(":
|
||||||
|
depth += 1
|
||||||
|
max_depth = max(max_depth, depth)
|
||||||
|
elif char == ")":
|
||||||
|
depth -= 1
|
||||||
|
if max_depth > 5:
|
||||||
|
raise ValueError("搜索过滤器嵌套层数过深(最多5层)")
|
||||||
|
if len(v) > 200:
|
||||||
|
raise ValueError("搜索过滤器过长(最多200字符)")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API Endpoints ==========
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config")
|
||||||
|
async def get_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
获取 LDAP 配置
|
||||||
|
|
||||||
|
获取系统当前的 LDAP 认证配置信息,用于管理界面显示和编辑。
|
||||||
|
密码字段不会返回原文,仅返回是否已设置的标志。
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `server_url`: LDAP 服务器地址(如:ldap://ldap.example.com:389)
|
||||||
|
- `bind_dn`: 绑定 DN(如:cn=admin,dc=example,dc=com)
|
||||||
|
- `base_dn`: 搜索基准 DN(如:ou=users,dc=example,dc=com)
|
||||||
|
- `has_bind_password`: 是否已设置绑定密码(布尔值)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(默认:(uid={username}))
|
||||||
|
- `username_attr`: 用户名属性(默认:uid)
|
||||||
|
- `email_attr`: 邮箱属性(默认:mail)
|
||||||
|
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||||
|
- `is_enabled`: 是否启用 LDAP 认证
|
||||||
|
- `is_exclusive`: 是否仅允许 LDAP 登录(独占模式)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||||
|
- `connect_timeout`: 连接超时时间(秒,1-60)
|
||||||
|
"""
|
||||||
|
adapter = AdminGetLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/config")
|
||||||
|
async def update_ldap_config(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
更新 LDAP 配置
|
||||||
|
|
||||||
|
更新系统的 LDAP 认证配置。支持完整配置更新,包括连接参数、
|
||||||
|
搜索过滤器、属性映射等。提供多重安全校验,防止误锁定管理员。
|
||||||
|
|
||||||
|
**请求体字段**:
|
||||||
|
- `server_url`: LDAP 服务器地址(必填,1-255字符)
|
||||||
|
- `bind_dn`: 绑定 DN(必填,1-255字符)
|
||||||
|
- `bind_password`: 绑定密码(可选,设为空字符串可清除密码)
|
||||||
|
- `base_dn`: 搜索基准 DN(必填,1-255字符)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(必须包含 {username} 占位符,默认:(uid={username}))
|
||||||
|
- `username_attr`: 用户名属性(默认:uid)
|
||||||
|
- `email_attr`: 邮箱属性(默认:mail)
|
||||||
|
- `display_name_attr`: 显示名称属性(默认:cn)
|
||||||
|
- `is_enabled`: 是否启用 LDAP 认证
|
||||||
|
- `is_exclusive`: 是否仅允许 LDAP 登录(需先启用 LDAP)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS 加密连接
|
||||||
|
- `connect_timeout`: 连接超时时间(秒,1-60,默认 10)
|
||||||
|
|
||||||
|
**安全校验**:
|
||||||
|
- 启用 LDAP 时必须设置有效的绑定密码
|
||||||
|
- 启用独占模式前会检查是否有至少 1 个有效的本地管理员账户
|
||||||
|
- 独占模式要求先启用 LDAP 认证
|
||||||
|
- 搜索过滤器必须包含 {username} 占位符且括号匹配
|
||||||
|
- 搜索过滤器嵌套层数不超过 5 层,长度不超过 200 字符
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `message`: 操作结果消息
|
||||||
|
"""
|
||||||
|
adapter = AdminUpdateLDAPConfigAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_ldap_connection(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||||
|
"""
|
||||||
|
测试 LDAP 连接
|
||||||
|
|
||||||
|
在保存配置前测试 LDAP 服务器连接是否正常。支持使用已保存的配置,
|
||||||
|
也支持通过请求体覆盖任意配置项进行临时测试,而不影响已保存的配置。
|
||||||
|
|
||||||
|
**请求体字段**(均为可选,用于临时覆盖):
|
||||||
|
- `server_url`: LDAP 服务器地址(覆盖已保存的配置)
|
||||||
|
- `bind_dn`: 绑定 DN(覆盖已保存的配置)
|
||||||
|
- `bind_password`: 绑定密码(覆盖已保存的密码)
|
||||||
|
- `base_dn`: 搜索基准 DN(覆盖已保存的配置)
|
||||||
|
- `user_search_filter`: 用户搜索过滤器(覆盖已保存的配置)
|
||||||
|
- `username_attr`: 用户名属性(覆盖已保存的配置)
|
||||||
|
- `email_attr`: 邮箱属性(覆盖已保存的配置)
|
||||||
|
- `display_name_attr`: 显示名称属性(覆盖已保存的配置)
|
||||||
|
- `use_starttls`: 是否使用 STARTTLS(覆盖已保存的配置)
|
||||||
|
- `connect_timeout`: 连接超时时间(覆盖已保存的配置)
|
||||||
|
|
||||||
|
**测试逻辑**:
|
||||||
|
- 未提供的字段使用已保存的配置值
|
||||||
|
- `bind_password` 优先使用请求体中的值,否则使用已保存的加密密码
|
||||||
|
- 测试时会尝试连接 LDAP 服务器并验证绑定 DN
|
||||||
|
|
||||||
|
**返回字段**:
|
||||||
|
- `success`: 测试是否成功(布尔值)
|
||||||
|
- `message`: 测试结果消息(成功或失败原因)
|
||||||
|
"""
|
||||||
|
adapter = AdminTestLDAPConnectionAdapter()
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== Adapters ==========
|
||||||
|
|
||||||
|
|
||||||
|
class AdminGetLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
if not config:
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=None,
|
||||||
|
bind_dn=None,
|
||||||
|
base_dn=None,
|
||||||
|
has_bind_password=False,
|
||||||
|
user_search_filter="(uid={username})",
|
||||||
|
username_attr="uid",
|
||||||
|
email_attr="mail",
|
||||||
|
display_name_attr="cn",
|
||||||
|
is_enabled=False,
|
||||||
|
is_exclusive=False,
|
||||||
|
use_starttls=False,
|
||||||
|
connect_timeout=10,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
return LDAPConfigResponse(
|
||||||
|
server_url=config.server_url,
|
||||||
|
bind_dn=config.bind_dn,
|
||||||
|
base_dn=config.base_dn,
|
||||||
|
has_bind_password=bool(config.bind_password_encrypted),
|
||||||
|
user_search_filter=config.user_search_filter,
|
||||||
|
username_attr=config.username_attr,
|
||||||
|
email_attr=config.email_attr,
|
||||||
|
display_name_attr=config.display_name_attr,
|
||||||
|
is_enabled=config.is_enabled,
|
||||||
|
is_exclusive=config.is_exclusive,
|
||||||
|
use_starttls=config.use_starttls,
|
||||||
|
connect_timeout=config.connect_timeout,
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUpdateLDAPConfigAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, str]: # type: ignore[override]
|
||||||
|
db = context.db
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
try:
|
||||||
|
config_update = LDAPConfigUpdate.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
# 使用行级锁防止并发修改导致的竞态条件
|
||||||
|
config = db.query(LDAPConfig).with_for_update().first()
|
||||||
|
is_new_config = config is None
|
||||||
|
|
||||||
|
if is_new_config:
|
||||||
|
# 首次创建配置时必须提供密码
|
||||||
|
if not config_update.bind_password:
|
||||||
|
raise InvalidRequestException("首次配置 LDAP 时必须设置绑定密码")
|
||||||
|
config = LDAPConfig()
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
# 需要启用 LDAP 且未提交新密码时,验证已保存密码可解密(避免开启后不可用)
|
||||||
|
if config_update.is_enabled and config_update.bind_password is None:
|
||||||
|
try:
|
||||||
|
if not config.get_bind_password():
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
except InvalidRequestException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
raise InvalidRequestException("绑定密码解密失败,请重新设置绑定密码")
|
||||||
|
|
||||||
|
# 计算更新后的密码状态(用于校验是否可启用/独占)
|
||||||
|
if config_update.bind_password is None:
|
||||||
|
will_have_password = bool(config.bind_password_encrypted)
|
||||||
|
elif config_update.bind_password == "":
|
||||||
|
will_have_password = False
|
||||||
|
else:
|
||||||
|
will_have_password = True
|
||||||
|
|
||||||
|
# 独占模式必须启用 LDAP 且必须有绑定密码(防止误锁定)
|
||||||
|
if config_update.is_exclusive and not config_update.is_enabled:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先启用 LDAP 认证")
|
||||||
|
if config_update.is_enabled and not will_have_password:
|
||||||
|
raise InvalidRequestException("启用 LDAP 认证 需要先设置绑定密码")
|
||||||
|
if config_update.is_exclusive and not will_have_password:
|
||||||
|
raise InvalidRequestException("仅允许 LDAP 登录 需要先设置绑定密码")
|
||||||
|
|
||||||
|
config.server_url = config_update.server_url
|
||||||
|
config.bind_dn = config_update.bind_dn
|
||||||
|
config.base_dn = config_update.base_dn
|
||||||
|
config.user_search_filter = config_update.user_search_filter
|
||||||
|
config.username_attr = config_update.username_attr
|
||||||
|
config.email_attr = config_update.email_attr
|
||||||
|
config.display_name_attr = config_update.display_name_attr
|
||||||
|
config.is_enabled = config_update.is_enabled
|
||||||
|
config.is_exclusive = config_update.is_exclusive
|
||||||
|
config.use_starttls = config_update.use_starttls
|
||||||
|
config.connect_timeout = config_update.connect_timeout
|
||||||
|
|
||||||
|
# 启用独占模式前检查是否有足够的本地管理员(防止锁定)
|
||||||
|
# 使用 with_for_update() 阻塞锁防止竞态条件(移除 nowait 确保并发安全)
|
||||||
|
if config_update.is_enabled and config_update.is_exclusive:
|
||||||
|
local_admins = (
|
||||||
|
db.query(User)
|
||||||
|
.filter(
|
||||||
|
User.role == UserRole.ADMIN,
|
||||||
|
User.auth_source == AuthSource.LOCAL,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
User.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
# 验证至少有一个管理员有有效的密码哈希(可以登录)
|
||||||
|
# 使用严格的 bcrypt 格式校验:$2a$/$2b$/$2y$ + 2位cost + $ + 53字符
|
||||||
|
valid_admin_count = sum(
|
||||||
|
1
|
||||||
|
for admin in local_admins
|
||||||
|
if admin.password_hash
|
||||||
|
and isinstance(admin.password_hash, str)
|
||||||
|
and BCRYPT_HASH_PATTERN.match(admin.password_hash)
|
||||||
|
)
|
||||||
|
if valid_admin_count < 1:
|
||||||
|
raise InvalidRequestException(
|
||||||
|
"启用 LDAP 独占模式前,必须至少保留 1 个有效的本地管理员账户(含有效密码)作为紧急恢复通道"
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_update.bind_password is not None:
|
||||||
|
if config_update.bind_password == "":
|
||||||
|
# 显式清除密码(设置为 NULL)
|
||||||
|
config.bind_password_encrypted = None
|
||||||
|
password_changed = "cleared"
|
||||||
|
else:
|
||||||
|
config.bind_password_encrypted = crypto_service.encrypt(config_update.bind_password)
|
||||||
|
password_changed = "updated"
|
||||||
|
else:
|
||||||
|
password_changed = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 记录审计日志
|
||||||
|
AuditService.log_event(
|
||||||
|
db=db,
|
||||||
|
event_type=AuditEventType.CONFIG_CHANGED,
|
||||||
|
description=f"LDAP 配置已更新 (enabled={config_update.is_enabled}, exclusive={config_update.is_exclusive})",
|
||||||
|
user_id=str(context.user.id) if context.user else None,
|
||||||
|
metadata={
|
||||||
|
"server_url": config_update.server_url,
|
||||||
|
"is_enabled": config_update.is_enabled,
|
||||||
|
"is_exclusive": config_update.is_exclusive,
|
||||||
|
"password_changed": password_changed,
|
||||||
|
"is_new_config": is_new_config,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "LDAP配置更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
class AdminTestLDAPConnectionAdapter(AdminApiAdapter):
|
||||||
|
async def handle(self, context: ApiRequestContext) -> dict[str, Any]: # type: ignore[override]
|
||||||
|
from src.services.auth.ldap import LDAPService
|
||||||
|
|
||||||
|
db = context.db
|
||||||
|
if context.json_body is not None:
|
||||||
|
payload = context.json_body
|
||||||
|
elif not context.raw_body:
|
||||||
|
payload = {}
|
||||||
|
else:
|
||||||
|
payload = context.ensure_json_body()
|
||||||
|
|
||||||
|
saved_config = db.query(LDAPConfig).first()
|
||||||
|
|
||||||
|
try:
|
||||||
|
overrides = LDAPConfigTest.model_validate(payload)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = e.errors()
|
||||||
|
if errors:
|
||||||
|
raise InvalidRequestException(translate_pydantic_error(errors[0]))
|
||||||
|
raise InvalidRequestException("请求数据验证失败")
|
||||||
|
|
||||||
|
config_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if saved_config:
|
||||||
|
config_data = {
|
||||||
|
"server_url": saved_config.server_url,
|
||||||
|
"bind_dn": saved_config.bind_dn,
|
||||||
|
"base_dn": saved_config.base_dn,
|
||||||
|
"user_search_filter": saved_config.user_search_filter,
|
||||||
|
"username_attr": saved_config.username_attr,
|
||||||
|
"email_attr": saved_config.email_attr,
|
||||||
|
"display_name_attr": saved_config.display_name_attr,
|
||||||
|
"use_starttls": saved_config.use_starttls,
|
||||||
|
"connect_timeout": saved_config.connect_timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 应用前端传入的覆盖值
|
||||||
|
for field in [
|
||||||
|
"server_url",
|
||||||
|
"bind_dn",
|
||||||
|
"base_dn",
|
||||||
|
"user_search_filter",
|
||||||
|
"username_attr",
|
||||||
|
"email_attr",
|
||||||
|
"display_name_attr",
|
||||||
|
"use_starttls",
|
||||||
|
"is_enabled",
|
||||||
|
"is_exclusive",
|
||||||
|
"connect_timeout",
|
||||||
|
]:
|
||||||
|
value = getattr(overrides, field)
|
||||||
|
if value is not None:
|
||||||
|
config_data[field] = value
|
||||||
|
|
||||||
|
# bind_password 优先使用 overrides;否则使用已保存的密码(允许保存密码无法解密时依然用 overrides 测试)
|
||||||
|
if overrides.bind_password is not None:
|
||||||
|
config_data["bind_password"] = overrides.bind_password
|
||||||
|
elif saved_config and saved_config.bind_password_encrypted:
|
||||||
|
try:
|
||||||
|
config_data["bind_password"] = crypto_service.decrypt(
|
||||||
|
saved_config.bind_password_encrypted
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"绑定密码解密失败: {type(e).__name__}: {e}")
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message="绑定密码解密失败,请检查配置或重新设置密码"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
# 必填字段检查
|
||||||
|
required_fields = ["server_url", "bind_dn", "base_dn", "bind_password"]
|
||||||
|
missing = [f for f in required_fields if not config_data.get(f)]
|
||||||
|
if missing:
|
||||||
|
return LDAPTestResponse(
|
||||||
|
success=False, message=f"缺少必要字段: {', '.join(missing)}"
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
success, message = LDAPService.test_connection_with_config(config_data)
|
||||||
|
return LDAPTestResponse(success=success, message=message).model_dump()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Management Token 管理员路由模块"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from .routes import router as management_tokens_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(management_tokens_router)
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user