mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
feat: 全栈功能增强 - 扩展 provider/pool 管理、完善调度与数据层、重构前端 Pool 页面
后端: - 扩展 pool_admin payloads 和 provider query models,增强 endpoint key 管理 - 完善 scheduler-core 候选排序与请求候选逻辑 - 增强 usage-runtime 写入、provider-transport 网络层与 OAuth 刷新 - 改进 AI pipeline 响应转换与流式处理 - 扩展 global_models/provider_catalog 数据层查询能力 - 增强 video-tasks-core 多 provider 支持 - 新增大量集成测试覆盖 pool/keys/provider_query/frontdoor 前端: - 重构 PoolManagement 页面,拆分状态管理/对话框逻辑到独立模块 - 新增 poolAdvancedDialog/poolSchedulingDialog/poolManagementState/poolMobilePresentation 工具函数及测试 - 改进 Dialog 组件与 provider tabs 显示 部署: - 更新 Rust CI workflow 和 Dockerfile 构建配置 Closes #275 Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
+123
-11
@@ -7,20 +7,25 @@ on:
|
||||
- aether-rust-pioneer
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "aether-hub/**"
|
||||
- "aether-proxy/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
- "aether-hub/**"
|
||||
- "aether-proxy/**"
|
||||
- "apps/**"
|
||||
- ".github/workflows/rust-ci.yml"
|
||||
|
||||
concurrency:
|
||||
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
@@ -28,21 +33,128 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: . -> target
|
||||
components: rustfmt
|
||||
|
||||
- name: Format
|
||||
run: cargo fmt --all --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
|
||||
- name: Clippy
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
|
||||
- name: Test
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo test --workspace
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
build_release:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: rust-ci-${{ runner.os }}
|
||||
workspaces: . -> target
|
||||
|
||||
- name: Setup sccache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: cargo build --workspace --release
|
||||
|
||||
- name: Show sccache stats
|
||||
if: always()
|
||||
env:
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
run: sccache --show-stats
|
||||
|
||||
check:
|
||||
name: check
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- fmt
|
||||
- clippy
|
||||
- test
|
||||
- build_release
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Verify required jobs
|
||||
run: |
|
||||
if [ "${{ needs.fmt.result }}" != "success" ] || \
|
||||
[ "${{ needs.clippy.result }}" != "success" ] || \
|
||||
[ "${{ needs.test.result }}" != "success" ] || \
|
||||
[ "${{ needs.build_release.result }}" != "success" ]; then
|
||||
echo "Rust CI failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
+89
-20
@@ -12,46 +12,115 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM rust:1.86-slim AS gateway-builder
|
||||
FROM rust:1.94.1-slim AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
# CI 镜像也采用同一套分层缓存策略,减少重复编译开销。
|
||||
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 \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
libjemalloc2 \
|
||||
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 --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/build/target \
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
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-ci,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --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-ci,target=/build/target,sharing=locked \
|
||||
cargo build --release --locked -p aether-gateway && \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
libjemalloc2 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/app/logs \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/local/bin \
|
||||
/runtime-root/usr/local/lib; \
|
||||
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
|
||||
[ -n "$jemalloc_path" ]; \
|
||||
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
|
||||
install -D "$jemalloc_path" /runtime-root/usr/local/lib/libjemalloc.so.2; \
|
||||
printf '%s\n' "$jemalloc_path" >> /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
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
# 复制 gateway 二进制
|
||||
COPY --from=gateway-builder /tmp/aether-gateway /usr/local/bin/aether-gateway
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
|
||||
RUN chmod -R 755 /srv/frontend
|
||||
|
||||
RUN mkdir -p /app/logs /app/data
|
||||
WORKDIR /app
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
@@ -65,6 +134,6 @@ ENV LANG=C.UTF-8 \
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
+89
-21
@@ -11,48 +11,116 @@ COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ==================== Rust gateway 构建 ====================
|
||||
FROM rust:1.86-slim AS gateway-builder
|
||||
FROM rust:1.94.1-slim AS gateway-base
|
||||
WORKDIR /build
|
||||
|
||||
# 本地镜像优先缩短构建时间,保留 release 语义,但改用更快的 thin LTO。
|
||||
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 \
|
||||
libjemalloc2 \
|
||||
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 --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/build/target \
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM gateway-base AS gateway-builder
|
||||
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-local,target=/build/target,sharing=locked \
|
||||
cargo chef cook --release --locked --package aether-gateway --bin aether-gateway --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-local,target=/build/target,sharing=locked \
|
||||
cargo build --release --locked -p aether-gateway && \
|
||||
cp target/release/aether-gateway /tmp/aether-gateway
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
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 \
|
||||
curl \
|
||||
libjemalloc2 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ==================== 最小运行时打包 ====================
|
||||
FROM gateway-builder AS runtime-prep
|
||||
RUN set -eux; \
|
||||
mkdir -p \
|
||||
/runtime-root/app/data \
|
||||
/runtime-root/app/logs \
|
||||
/runtime-root/etc \
|
||||
/runtime-root/etc/ssl \
|
||||
/runtime-root/lib \
|
||||
/runtime-root/lib64 \
|
||||
/runtime-root/usr/local/bin \
|
||||
/runtime-root/usr/local/lib; \
|
||||
cp /tmp/aether-gateway /runtime-root/usr/local/bin/aether-gateway; \
|
||||
: > /tmp/runtime-libs.txt; \
|
||||
: > /tmp/runtime-scan-queue.txt; \
|
||||
printf '%s\n' /tmp/aether-gateway >> /tmp/runtime-scan-queue.txt; \
|
||||
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
|
||||
[ -n "$jemalloc_path" ]; \
|
||||
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
|
||||
install -D "$jemalloc_path" /runtime-root/usr/local/lib/libjemalloc.so.2; \
|
||||
printf '%s\n' "$jemalloc_path" >> /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
|
||||
|
||||
# ==================== 运行时镜像 ====================
|
||||
FROM scratch
|
||||
|
||||
# 复制 gateway 二进制
|
||||
COPY --from=gateway-builder /tmp/aether-gateway /usr/local/bin/aether-gateway
|
||||
COPY --from=runtime-prep /runtime-root/ /
|
||||
|
||||
# 复制前端构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist /srv/frontend
|
||||
RUN chmod -R 755 /srv/frontend
|
||||
|
||||
RUN mkdir -p /app/logs /app/data
|
||||
WORKDIR /app
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
@@ -66,6 +134,6 @@ ENV LANG=C.UTF-8 \
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/health || exit 1
|
||||
CMD ["/usr/local/bin/aether-gateway", "--healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/aether-gateway"]
|
||||
|
||||
@@ -106,6 +106,7 @@ async fn maybe_build_local_video_task_content_stream_decision_payload(
|
||||
let crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) = action else {
|
||||
return Ok(None);
|
||||
};
|
||||
let plan = *plan;
|
||||
let provider_contract = plan.provider_api_format.clone();
|
||||
let client_contract = plan.client_api_format.clone();
|
||||
let execution_strategy = if plan.provider_api_format == plan.client_api_format {
|
||||
|
||||
+13
-11
@@ -9,7 +9,9 @@ use crate::ai_pipeline::transport::antigravity::{
|
||||
};
|
||||
use crate::ai_pipeline::transport::auth::build_openai_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::claude_code::build_claude_code_passthrough_headers;
|
||||
use crate::ai_pipeline::transport::kiro::{build_kiro_provider_headers, KIRO_ENVELOPE_NAME};
|
||||
use crate::ai_pipeline::transport::kiro::{
|
||||
build_kiro_provider_headers, KiroProviderHeadersInput, KIRO_ENVELOPE_NAME,
|
||||
};
|
||||
use crate::ai_pipeline::transport::{
|
||||
apply_local_header_rules, ensure_upstream_auth_header, resolve_transport_execution_timeouts,
|
||||
resolve_transport_proxy_snapshot_with_tunnel_affinity, resolve_transport_tls_profile,
|
||||
@@ -171,16 +173,16 @@ pub(crate) async fn maybe_build_local_same_format_provider_decision_payload_for_
|
||||
};
|
||||
|
||||
let Some(provider_request_headers) = (if let Some(kiro_auth) = kiro_auth.as_ref() {
|
||||
build_kiro_provider_headers(
|
||||
&parts.headers,
|
||||
&provider_request_body,
|
||||
body_json,
|
||||
transport.endpoint.header_rules.as_ref(),
|
||||
auth_header.as_deref().unwrap_or_default(),
|
||||
auth_value.as_deref().unwrap_or_default(),
|
||||
&kiro_auth.auth_config,
|
||||
kiro_auth.machine_id.as_str(),
|
||||
)
|
||||
build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &parts.headers,
|
||||
provider_request_body: &provider_request_body,
|
||||
original_request_body: body_json,
|
||||
header_rules: transport.endpoint.header_rules.as_ref(),
|
||||
auth_header: auth_header.as_deref().unwrap_or_default(),
|
||||
auth_value: auth_value.as_deref().unwrap_or_default(),
|
||||
auth_config: &kiro_auth.auth_config,
|
||||
machine_id: kiro_auth.machine_id.as_str(),
|
||||
})
|
||||
} else {
|
||||
let extra_headers = antigravity_auth
|
||||
.as_ref()
|
||||
|
||||
@@ -127,40 +127,6 @@ pub(crate) fn build_client_response_from_parts(
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_client_response_from_parts;
|
||||
use axum::body::Body;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn sse_responses_disable_proxy_buffering() {
|
||||
let response = build_client_response_from_parts(
|
||||
200,
|
||||
&BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
|
||||
Body::from("data: hello\n\n"),
|
||||
"trace-sse-buffering-1",
|
||||
None,
|
||||
)
|
||||
.expect("response should build");
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-cache, no-transform")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-accel-buffering")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_candidate_id_header_if_present(
|
||||
headers: &mut http::HeaderMap,
|
||||
candidate_id: Option<&str>,
|
||||
@@ -357,3 +323,37 @@ pub(crate) fn build_local_overloaded_response(
|
||||
control_decision,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::build_client_response_from_parts;
|
||||
use axum::body::Body;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn sse_responses_disable_proxy_buffering() {
|
||||
let response = build_client_response_from_parts(
|
||||
200,
|
||||
&BTreeMap::from([("content-type".to_string(), "text/event-stream".to_string())]),
|
||||
Body::from("data: hello\n\n"),
|
||||
"trace-sse-buffering-1",
|
||||
None,
|
||||
)
|
||||
.expect("response should build");
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(http::header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-cache, no-transform")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-accel-buffering")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,7 @@ pub(super) fn classify_admin_route(
|
||||
} else if let Some(route) = classify_admin_model_provider_family_route(method, normalized_path)
|
||||
{
|
||||
Some(route)
|
||||
} else if let Some(route) = classify_admin_endpoints_family_route(method, normalized_path) {
|
||||
Some(route)
|
||||
} else {
|
||||
None
|
||||
classify_admin_endpoints_family_route(method, normalized_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::io::Error as IoError;
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTelemetry, StreamFramePayload};
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||
use async_stream::stream;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::http::Response;
|
||||
@@ -169,16 +170,18 @@ pub(crate) async fn execute_execution_runtime_stream(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(response.status().as_u16()),
|
||||
Some("execution_runtime_http_error".to_string()),
|
||||
Some(format!(
|
||||
"execution runtime returned HTTP {}",
|
||||
response.status()
|
||||
)),
|
||||
None,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(response.status().as_u16()),
|
||||
error_type: Some("execution_runtime_http_error".to_string()),
|
||||
error_message: Some(format!(
|
||||
"execution runtime returned HTTP {}",
|
||||
response.status()
|
||||
)),
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(Some(attach_control_metadata_headers(
|
||||
@@ -241,15 +244,17 @@ async fn execute_stream_from_frame_stream(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(status_code),
|
||||
Some("retryable_upstream_status".to_string()),
|
||||
Some(format!(
|
||||
"execution runtime stream returned retryable status {status_code}"
|
||||
)),
|
||||
None,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(status_code),
|
||||
error_type: Some("retryable_upstream_status".to_string()),
|
||||
error_message: Some(format!(
|
||||
"execution runtime stream returned retryable status {status_code}"
|
||||
)),
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
warn!(
|
||||
@@ -276,15 +281,17 @@ async fn execute_stream_from_frame_stream(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(status_code),
|
||||
Some("control_fallback".to_string()),
|
||||
Some(format!(
|
||||
"stream decision fell back to control after status {status_code}"
|
||||
)),
|
||||
None,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(status_code),
|
||||
error_type: Some("control_fallback".to_string()),
|
||||
error_message: Some(format!(
|
||||
"stream decision fell back to control after status {status_code}"
|
||||
)),
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
@@ -322,15 +329,17 @@ async fn execute_stream_from_frame_stream(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(status_code),
|
||||
Some("execution_runtime_stream_error".to_string()),
|
||||
Some(format!(
|
||||
"execution runtime stream returned error status {status_code}"
|
||||
)),
|
||||
None,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(status_code),
|
||||
error_type: Some("execution_runtime_stream_error".to_string()),
|
||||
error_message: Some(format!(
|
||||
"execution runtime stream returned error status {status_code}"
|
||||
)),
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if let Some(report_kind) = stream_error_finalize_kind {
|
||||
@@ -632,15 +641,17 @@ async fn execute_stream_from_frame_stream(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Streaming,
|
||||
Some(status_code),
|
||||
None,
|
||||
None,
|
||||
prefetched_telemetry
|
||||
.as_ref()
|
||||
.and_then(|telemetry| telemetry.elapsed_ms),
|
||||
Some(candidate_started_unix_secs),
|
||||
None,
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Streaming,
|
||||
status_code: Some(status_code),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: prefetched_telemetry
|
||||
.as_ref()
|
||||
.and_then(|telemetry| telemetry.elapsed_ms),
|
||||
started_at_unix_secs: Some(candidate_started_unix_secs),
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -981,13 +992,15 @@ async fn execute_stream_from_frame_stream(
|
||||
&state_for_report,
|
||||
&plan_for_report,
|
||||
report_context_owned.as_ref(),
|
||||
RequestCandidateStatus::Cancelled,
|
||||
Some(499),
|
||||
Some("downstream_disconnect".to_string()),
|
||||
Some("client disconnected before stream completion".to_string()),
|
||||
telemetry.as_ref().and_then(|value| value.elapsed_ms),
|
||||
Some(candidate_started_unix_secs_for_report),
|
||||
Some(current_request_candidate_unix_secs()),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Cancelled,
|
||||
status_code: Some(499),
|
||||
error_type: Some("downstream_disconnect".to_string()),
|
||||
error_message: Some("client disconnected before stream completion".to_string()),
|
||||
latency_ms: telemetry.as_ref().and_then(|value| value.elapsed_ms),
|
||||
started_at_unix_secs: Some(candidate_started_unix_secs_for_report),
|
||||
finished_at_unix_secs: Some(current_request_candidate_unix_secs()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -1036,13 +1049,15 @@ async fn execute_stream_from_frame_stream(
|
||||
&state_for_report,
|
||||
&plan_for_report,
|
||||
report_context_owned.as_ref(),
|
||||
RequestCandidateStatus::Success,
|
||||
Some(status_code),
|
||||
None,
|
||||
None,
|
||||
telemetry.as_ref().and_then(|value| value.elapsed_ms),
|
||||
Some(candidate_started_unix_secs_for_report),
|
||||
Some(current_request_candidate_unix_secs()),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Success,
|
||||
status_code: Some(status_code),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: telemetry.as_ref().and_then(|value| value.elapsed_ms),
|
||||
started_at_unix_secs: Some(candidate_started_unix_secs_for_report),
|
||||
finished_at_unix_secs: Some(current_request_candidate_unix_secs()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use aether_contracts::{ExecutionError, ExecutionPlan, ExecutionTelemetry};
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use base64::Engine as _;
|
||||
@@ -126,16 +127,18 @@ async fn record_stream_sync_failure(
|
||||
record_report_request_candidate_status(
|
||||
state,
|
||||
report_context,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(failure.status_code),
|
||||
Some(failure.error_type.clone()),
|
||||
Some(failure.error_message.clone()),
|
||||
payload
|
||||
.telemetry
|
||||
.as_ref()
|
||||
.and_then(|telemetry| telemetry.elapsed_ms),
|
||||
started_at_unix_secs.or(Some(terminal_unix_secs)),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(failure.status_code),
|
||||
error_type: Some(failure.error_type.clone()),
|
||||
error_message: Some(failure.error_message.clone()),
|
||||
latency_ms: payload
|
||||
.telemetry
|
||||
.as_ref()
|
||||
.and_then(|telemetry| telemetry.elapsed_ms),
|
||||
started_at_unix_secs: started_at_unix_secs.or(Some(terminal_unix_secs)),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTelemetry};
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::execution_error_details;
|
||||
use aether_scheduler_core::{execution_error_details, SchedulerRequestCandidateStatusUpdate};
|
||||
use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
use base64::Engine as _;
|
||||
@@ -165,13 +165,15 @@ pub(crate) async fn execute_execution_runtime_sync(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(result.status_code),
|
||||
result_error_type.clone(),
|
||||
result_error_message.clone(),
|
||||
result_latency_ms,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(result.status_code),
|
||||
error_type: result_error_type.clone(),
|
||||
error_message: result_error_message.clone(),
|
||||
latency_ms: result_latency_ms,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
warn!(
|
||||
@@ -231,13 +233,15 @@ pub(crate) async fn execute_execution_runtime_sync(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(result.status_code),
|
||||
result_error_type.clone(),
|
||||
result_error_message.clone(),
|
||||
result_latency_ms,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(result.status_code),
|
||||
error_type: result_error_type.clone(),
|
||||
error_message: result_error_message.clone(),
|
||||
latency_ms: result_latency_ms,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(None);
|
||||
@@ -252,17 +256,19 @@ pub(crate) async fn execute_execution_runtime_sync(
|
||||
state,
|
||||
&plan,
|
||||
report_context.as_ref(),
|
||||
if result.status_code >= 400 {
|
||||
RequestCandidateStatus::Failed
|
||||
} else {
|
||||
RequestCandidateStatus::Success
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: if result.status_code >= 400 {
|
||||
RequestCandidateStatus::Failed
|
||||
} else {
|
||||
RequestCandidateStatus::Success
|
||||
},
|
||||
status_code: Some(result.status_code),
|
||||
error_type: result_error_type.clone(),
|
||||
error_message: result_error_message.clone(),
|
||||
latency_ms: result_latency_ms,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
Some(result.status_code),
|
||||
result_error_type.clone(),
|
||||
result_error_message.clone(),
|
||||
result_latency_ms,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -603,16 +609,18 @@ async fn execute_sync_via_remote_execution_runtime(
|
||||
state,
|
||||
plan,
|
||||
report_context,
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(response.status().as_u16()),
|
||||
Some("execution_runtime_http_error".to_string()),
|
||||
Some(format!(
|
||||
"execution runtime returned HTTP {}",
|
||||
response.status()
|
||||
)),
|
||||
None,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(response.status().as_u16()),
|
||||
error_type: Some("execution_runtime_http_error".to_string()),
|
||||
error_message: Some(format!(
|
||||
"execution runtime returned HTTP {}",
|
||||
response.status()
|
||||
)),
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return Ok(RemoteSyncFallbackOutcome::ClientResponse(
|
||||
|
||||
@@ -2,6 +2,7 @@ use axum::body::Body;
|
||||
use axum::http::Response;
|
||||
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::SchedulerRequestCandidateStatusUpdate;
|
||||
|
||||
use crate::ai_pipeline_api::{LocalStreamPlanAndReport, LocalSyncPlanAndReport};
|
||||
use crate::control::GatewayControlDecision;
|
||||
@@ -118,13 +119,15 @@ where
|
||||
state,
|
||||
plan_and_report.plan(),
|
||||
plan_and_report.report_context().as_ref(),
|
||||
RequestCandidateStatus::Unused,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Unused,
|
||||
status_code: None,
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -144,13 +147,15 @@ pub(crate) async fn mark_unused_local_candidate_items<T, FPlan, FContext>(
|
||||
state,
|
||||
plan(&item),
|
||||
report_context(&item),
|
||||
RequestCandidateStatus::Unused,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Unused,
|
||||
status_code: None,
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: None,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -169,8 +169,10 @@ async fn maybe_execute_local_video_task_content_stream(
|
||||
&body_json,
|
||||
)?)),
|
||||
crate::video_tasks::LocalVideoTaskContentAction::StreamPlan(plan) => {
|
||||
execute_execution_runtime_stream(state, plan, trace_id, decision, plan_kind, None, None)
|
||||
.await
|
||||
execute_execution_runtime_stream(
|
||||
state, *plan, trace_id, decision, plan_kind, None, None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +285,6 @@ pub(crate) async fn maybe_build_local_admin_billing_response(
|
||||
return Ok(Some(response));
|
||||
}
|
||||
|
||||
match decision.route_kind.as_deref() {
|
||||
_ => Ok(Some(build_admin_billing_data_unavailable_response())),
|
||||
}
|
||||
let _ = decision.route_kind.as_deref();
|
||||
Ok(Some(build_admin_billing_data_unavailable_response()))
|
||||
}
|
||||
|
||||
+5
-3
@@ -246,9 +246,11 @@ pub(super) async fn build_admin_monitoring_cache_affinity_response(
|
||||
"缺少 user_identifier",
|
||||
));
|
||||
};
|
||||
let direct_api_key_by_id =
|
||||
admin_monitoring_list_export_api_key_records_by_ids(state, &[user_identifier.clone()])
|
||||
.await?;
|
||||
let direct_api_key_by_id = admin_monitoring_list_export_api_key_records_by_ids(
|
||||
state,
|
||||
std::slice::from_ref(&user_identifier),
|
||||
)
|
||||
.await?;
|
||||
let direct_affinity_keys =
|
||||
std::iter::once(user_identifier.clone()).collect::<std::collections::BTreeSet<_>>();
|
||||
let direct_affinities =
|
||||
|
||||
@@ -70,7 +70,12 @@ pub(super) async fn maybe_handle(
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Some(
|
||||
Json(state.build_admin_provider_key_response(&created, now_unix_secs)).into_response(),
|
||||
Json(state.build_admin_provider_key_response(
|
||||
&created,
|
||||
&provider.provider_type,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,12 @@ pub(super) async fn maybe_handle(
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Some(
|
||||
Json(state.build_admin_provider_key_response(&updated, now_unix_secs)).into_response(),
|
||||
Json(state.build_admin_provider_key_response(
|
||||
&updated,
|
||||
&provider.provider_type,
|
||||
now_unix_secs,
|
||||
))
|
||||
.into_response(),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,820 @@
|
||||
use crate::handlers::admin::provider::shared::support::{
|
||||
AdminProviderPoolConfig, AdminProviderPoolRuntimeState,
|
||||
};
|
||||
use aether_admin::provider::pool as admin_provider_pool_pure;
|
||||
use crate::handlers::admin::shared::{
|
||||
provider_key_status_snapshot_payload, unix_secs_to_rfc3339,
|
||||
};
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn admin_pool_api_formats(key: &StoredProviderCatalogKey) -> Vec<String> {
|
||||
key.api_formats
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|values| {
|
||||
values
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn admin_pool_string_list(value: Option<&serde_json::Value>) -> Option<Vec<String>> {
|
||||
let values = value
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if values.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(values)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_json_object(
|
||||
value: Option<&serde_json::Value>,
|
||||
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn admin_pool_json_to_f64(value: Option<&serde_json::Value>) -> Option<f64> {
|
||||
let parsed = match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_f64(),
|
||||
Some(serde_json::Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}?;
|
||||
if parsed.is_finite() {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_json_to_u64(value: Option<&serde_json::Value>) -> Option<u64> {
|
||||
let mut parsed = match value {
|
||||
Some(serde_json::Value::Number(number)) => number.as_f64(),
|
||||
Some(serde_json::Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}?;
|
||||
if !parsed.is_finite() || parsed <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if parsed > 1_000_000_000_000.0 {
|
||||
parsed /= 1000.0;
|
||||
}
|
||||
Some(parsed.floor() as u64)
|
||||
}
|
||||
|
||||
fn admin_pool_trimmed_string(value: Option<&serde_json::Value>) -> Option<String> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn admin_pool_trimmed_string_from_map(
|
||||
value: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
admin_pool_trimmed_string(value.and_then(|object| object.get(field)))
|
||||
}
|
||||
|
||||
fn admin_pool_oauth_organizations(
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Vec<serde_json::Value> {
|
||||
auth_config
|
||||
.and_then(|config| config.get("organizations"))
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn admin_pool_normalize_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
|
||||
let mut normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
|
||||
normalized = normalized[provider_type.len()..]
|
||||
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let normalized = normalized.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_derive_oauth_expires_at(
|
||||
key: &StoredProviderCatalogKey,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<u64> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
for field in ["expires_at", "expiresAt", "expiry", "exp"] {
|
||||
let expires_at = admin_pool_json_to_u64(auth_config.and_then(|config| config.get(field)));
|
||||
if expires_at.is_some() {
|
||||
return expires_at;
|
||||
}
|
||||
}
|
||||
|
||||
key.expires_at_unix_secs
|
||||
}
|
||||
|
||||
fn admin_pool_derive_oauth_plan_type(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for field in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(field).and_then(serde_json::Value::as_str) {
|
||||
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
|
||||
if normalized.is_some() {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(config) = auth_config {
|
||||
for field in ["plan_type", "tier", "plan", "subscription_plan"] {
|
||||
if let Some(value) = config.get(field).and_then(serde_json::Value::as_str) {
|
||||
let normalized = admin_pool_normalize_oauth_plan_type(value, provider_type);
|
||||
if normalized.is_some() {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn admin_pool_format_percent(value: f64) -> String {
|
||||
format!("{:.1}%", value.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
fn admin_pool_format_quota_value(value: f64) -> String {
|
||||
let rounded = value.round();
|
||||
if (value - rounded).abs() < 1e-6 {
|
||||
rounded.to_string()
|
||||
} else {
|
||||
format!("{value:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_has_quota_consumption(used_percent: Option<f64>) -> bool {
|
||||
used_percent
|
||||
.map(|value| value.clamp(0.0, 100.0) > 1e-6)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn admin_pool_format_reset_after(seconds: f64) -> Option<String> {
|
||||
if !seconds.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let total_seconds = seconds.floor() as i64;
|
||||
if total_seconds <= 0 {
|
||||
return Some("已重置".to_string());
|
||||
}
|
||||
|
||||
let days = total_seconds / 86_400;
|
||||
let hours = (total_seconds % 86_400) / 3_600;
|
||||
let minutes = (total_seconds % 3_600) / 60;
|
||||
|
||||
if days > 0 {
|
||||
return Some(format!("{days}天{hours}小时后重置"));
|
||||
}
|
||||
if hours > 0 {
|
||||
return Some(format!("{hours}小时{minutes}分钟后重置"));
|
||||
}
|
||||
if minutes > 0 {
|
||||
return Some(format!("{minutes}分钟后重置"));
|
||||
}
|
||||
Some("即将重置".to_string())
|
||||
}
|
||||
|
||||
fn admin_pool_build_codex_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
fn codex_reset_seconds(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
reset_seconds_key: &str,
|
||||
reset_after_seconds_key: &str,
|
||||
reset_at_key: &str,
|
||||
) -> Option<f64> {
|
||||
admin_pool_json_to_f64(data.get(reset_seconds_key))
|
||||
.or_else(|| admin_pool_json_to_f64(data.get(reset_after_seconds_key)))
|
||||
.or_else(|| {
|
||||
let reset_at = admin_pool_json_to_u64(data.get(reset_at_key))?;
|
||||
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
|
||||
Some(reset_at.saturating_sub(now_unix_secs) as f64)
|
||||
})
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
let primary_used = admin_pool_json_to_f64(data.get("primary_used_percent"));
|
||||
if let Some(primary_used) = primary_used {
|
||||
let mut part = format!("周剩余 {}", admin_pool_format_percent(100.0 - primary_used));
|
||||
if admin_pool_has_quota_consumption(Some(primary_used)) {
|
||||
if let Some(reset_text) = codex_reset_seconds(
|
||||
data,
|
||||
"primary_reset_seconds",
|
||||
"primary_reset_after_seconds",
|
||||
"primary_reset_at",
|
||||
)
|
||||
.and_then(admin_pool_format_reset_after)
|
||||
{
|
||||
part.push_str(&format!(" ({reset_text})"));
|
||||
}
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
let secondary_used = admin_pool_json_to_f64(data.get("secondary_used_percent"));
|
||||
if let Some(secondary_used) = secondary_used {
|
||||
let mut part = format!(
|
||||
"5H剩余 {}",
|
||||
admin_pool_format_percent(100.0 - secondary_used)
|
||||
);
|
||||
if admin_pool_has_quota_consumption(Some(secondary_used)) {
|
||||
if let Some(reset_text) = codex_reset_seconds(
|
||||
data,
|
||||
"secondary_reset_seconds",
|
||||
"secondary_reset_after_seconds",
|
||||
"secondary_reset_at",
|
||||
)
|
||||
.and_then(admin_pool_format_reset_after)
|
||||
{
|
||||
part.push_str(&format!(" ({reset_text})"));
|
||||
}
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
return Some(parts.join(" | "));
|
||||
}
|
||||
|
||||
let has_credits = data
|
||||
.get("has_credits")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let credits_balance = admin_pool_json_to_f64(data.get("credits_balance"));
|
||||
if has_credits && credits_balance.is_some() {
|
||||
return credits_balance.map(|value| format!("积分 {value:.2}"));
|
||||
}
|
||||
if has_credits {
|
||||
return Some("有积分".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn admin_pool_build_kiro_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
if data
|
||||
.get("is_banned")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("账号已封禁".to_string());
|
||||
}
|
||||
|
||||
let usage_percentage = admin_pool_json_to_f64(data.get("usage_percentage"));
|
||||
if let Some(usage_percentage) = usage_percentage {
|
||||
let remaining = 100.0 - usage_percentage;
|
||||
let current_usage = admin_pool_json_to_f64(data.get("current_usage"));
|
||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
||||
if let (Some(current_usage), Some(usage_limit)) = (current_usage, usage_limit) {
|
||||
if usage_limit > 0.0 {
|
||||
return Some(format!(
|
||||
"剩余 {} ({}/{})",
|
||||
admin_pool_format_percent(remaining),
|
||||
admin_pool_format_quota_value(current_usage),
|
||||
admin_pool_format_quota_value(usage_limit),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Some(format!("剩余 {}", admin_pool_format_percent(remaining)));
|
||||
}
|
||||
|
||||
let remaining = admin_pool_json_to_f64(data.get("remaining"));
|
||||
let usage_limit = admin_pool_json_to_f64(data.get("usage_limit"));
|
||||
match (remaining, usage_limit) {
|
||||
(Some(remaining), Some(usage_limit)) if usage_limit > 0.0 => Some(format!(
|
||||
"剩余 {}/{}",
|
||||
admin_pool_format_quota_value(remaining),
|
||||
admin_pool_format_quota_value(usage_limit),
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_quota_by_model(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
data.get("quota_by_model")?.as_object()
|
||||
}
|
||||
|
||||
fn admin_pool_build_antigravity_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
if data
|
||||
.get("is_forbidden")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some("访问受限".to_string());
|
||||
}
|
||||
|
||||
let remaining_list = admin_pool_quota_by_model(data)?
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.filter_map(|item| {
|
||||
let used_percent = admin_pool_json_to_f64(item.get("used_percent")).or_else(|| {
|
||||
admin_pool_json_to_f64(item.get("remaining_fraction"))
|
||||
.map(|value| (1.0 - value) * 100.0)
|
||||
})?;
|
||||
Some((100.0 - used_percent).clamp(0.0, 100.0))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if remaining_list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let min_remaining = remaining_list.iter().copied().fold(100.0_f64, f64::min);
|
||||
if remaining_list.len() == 1 {
|
||||
return Some(format!("剩余 {}", admin_pool_format_percent(min_remaining)));
|
||||
}
|
||||
Some(format!(
|
||||
"最低剩余 {} ({} 模型)",
|
||||
admin_pool_format_percent(min_remaining),
|
||||
remaining_list.len()
|
||||
))
|
||||
}
|
||||
|
||||
fn admin_pool_gemini_reset_at(item: &serde_json::Map<String, serde_json::Value>) -> Option<i64> {
|
||||
let reset_at = admin_pool_json_to_u64(item.get("reset_at"))?;
|
||||
Some(reset_at as i64)
|
||||
}
|
||||
|
||||
fn admin_pool_gemini_model_exhausted(item: &serde_json::Map<String, serde_json::Value>) -> bool {
|
||||
if item
|
||||
.get("is_exhausted")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if admin_pool_json_to_f64(item.get("remaining_fraction")).is_some_and(|value| value <= 0.0) {
|
||||
return true;
|
||||
}
|
||||
admin_pool_json_to_f64(item.get("used_percent")).is_some_and(|value| value >= 100.0 - 1e-6)
|
||||
}
|
||||
|
||||
fn admin_pool_build_gemini_cli_account_quota(
|
||||
data: &serde_json::Map<String, serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let mut active = admin_pool_quota_by_model(data)?
|
||||
.iter()
|
||||
.filter_map(|(model_name, item)| {
|
||||
let item = item.as_object()?;
|
||||
if !admin_pool_gemini_model_exhausted(item) {
|
||||
return None;
|
||||
}
|
||||
let reset_at = admin_pool_gemini_reset_at(item);
|
||||
if reset_at.is_some_and(|value| value <= now) {
|
||||
return None;
|
||||
}
|
||||
Some((model_name.as_str(), reset_at))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if active.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
active.sort_by_key(|(_, reset_at)| reset_at.unwrap_or(i64::MAX));
|
||||
let (first_model, first_reset_at) = active[0];
|
||||
if active.len() == 1 {
|
||||
if let Some(reset_at) = first_reset_at {
|
||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
||||
return Some(format!("{first_model} 冷却中 ({reset_text})"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{first_model} 冷却中"));
|
||||
}
|
||||
|
||||
if let Some(reset_at) = first_reset_at {
|
||||
if let Some(reset_text) = admin_pool_format_reset_after((reset_at - now) as f64) {
|
||||
return Some(format!(
|
||||
"{} 个模型冷却中(最早 {reset_text})",
|
||||
active.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(format!("{} 个模型冷却中", active.len()))
|
||||
}
|
||||
|
||||
fn admin_pool_build_account_quota(
|
||||
provider_type: &str,
|
||||
upstream_metadata: Option<&serde_json::Value>,
|
||||
) -> Option<String> {
|
||||
let normalized_provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
let upstream_metadata = upstream_metadata?.as_object()?;
|
||||
let data = upstream_metadata
|
||||
.get(&normalized_provider_type)?
|
||||
.as_object()?;
|
||||
|
||||
match normalized_provider_type.as_str() {
|
||||
"codex" => admin_pool_build_codex_account_quota(data),
|
||||
"kiro" => admin_pool_build_kiro_account_quota(data),
|
||||
"antigravity" => admin_pool_build_antigravity_account_quota(data),
|
||||
"gemini_cli" => admin_pool_build_gemini_cli_account_quota(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_health_score(key: &StoredProviderCatalogKey) -> f64 {
|
||||
let scores = key
|
||||
.health_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.filter_map(|item| item.get("health_score"))
|
||||
.filter_map(serde_json::Value::as_f64)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if scores.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
scores.into_iter().fold(1.0, f64::min)
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_pool_circuit_breaker_open(key: &StoredProviderCatalogKey) -> bool {
|
||||
key.circuit_breaker_by_format
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|formats| {
|
||||
formats
|
||||
.values()
|
||||
.filter_map(serde_json::Value::as_object)
|
||||
.any(|item| {
|
||||
item.get("open")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn admin_pool_scheduling_payload(
|
||||
key: &StoredProviderCatalogKey,
|
||||
cooldown_reason: Option<&str>,
|
||||
cooldown_ttl_seconds: Option<u64>,
|
||||
health_score: f64,
|
||||
circuit_breaker_open: bool,
|
||||
) -> (String, String, String, Vec<serde_json::Value>) {
|
||||
if !key.is_active {
|
||||
return (
|
||||
"blocked".to_string(),
|
||||
"inactive".to_string(),
|
||||
"已禁用".to_string(),
|
||||
vec![json!({
|
||||
"code": "inactive",
|
||||
"label": "已禁用",
|
||||
"blocking": true,
|
||||
"source": "manual",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if let Some(reason) = cooldown_reason {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"cooldown".to_string(),
|
||||
"冷却中".to_string(),
|
||||
vec![json!({
|
||||
"code": "cooldown",
|
||||
"label": "冷却中",
|
||||
"blocking": true,
|
||||
"source": "pool",
|
||||
"ttl_seconds": cooldown_ttl_seconds,
|
||||
"detail": reason,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if circuit_breaker_open {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"circuit_breaker".to_string(),
|
||||
"熔断中".to_string(),
|
||||
vec![json!({
|
||||
"code": "circuit_breaker",
|
||||
"label": "熔断中",
|
||||
"blocking": true,
|
||||
"source": "health",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
if health_score < 0.5 {
|
||||
return (
|
||||
"degraded".to_string(),
|
||||
"health_low".to_string(),
|
||||
"健康度较低".to_string(),
|
||||
vec![json!({
|
||||
"code": "health_low",
|
||||
"label": "健康度较低",
|
||||
"blocking": false,
|
||||
"source": "health",
|
||||
"ttl_seconds": serde_json::Value::Null,
|
||||
"detail": serde_json::Value::Null,
|
||||
})],
|
||||
);
|
||||
}
|
||||
(
|
||||
"available".to_string(),
|
||||
"available".to_string(),
|
||||
"可用".to_string(),
|
||||
Vec::new(),
|
||||
)
|
||||
}
|
||||
pub(super) fn build_admin_pool_key_payload(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_type: &str,
|
||||
key: &StoredProviderCatalogKey,
|
||||
runtime: &AdminProviderPoolRuntimeState,
|
||||
pool_config: Option<AdminProviderPoolConfig>,
|
||||
) -> serde_json::Value {
|
||||
admin_provider_pool_pure::build_admin_pool_key_payload(
|
||||
key,
|
||||
&admin_provider_pool_pure::AdminPoolKeyPayloadContext {
|
||||
cooldown_reason: runtime.cooldown_reason_by_key.get(&key.id).cloned(),
|
||||
cooldown_ttl_seconds: runtime
|
||||
.cooldown_reason_by_key
|
||||
.get(&key.id)
|
||||
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied()),
|
||||
cost_window_usage: runtime
|
||||
.cost_window_usage_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
sticky_sessions: runtime
|
||||
.sticky_sessions_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
lru_score: runtime.lru_score_by_key.get(&key.id).copied(),
|
||||
cost_limit: pool_config.and_then(|config| config.cost_limit_per_key_tokens),
|
||||
},
|
||||
)
|
||||
let cooldown_reason = runtime.cooldown_reason_by_key.get(&key.id).cloned();
|
||||
let cooldown_ttl_seconds = cooldown_reason
|
||||
.as_ref()
|
||||
.and_then(|_| runtime.cooldown_ttl_by_key.get(&key.id).copied());
|
||||
let health_score = admin_pool_health_score(key);
|
||||
let circuit_breaker_open = admin_pool_circuit_breaker_open(key);
|
||||
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
|
||||
admin_pool_scheduling_payload(
|
||||
key,
|
||||
cooldown_reason.as_deref(),
|
||||
cooldown_ttl_seconds,
|
||||
health_score,
|
||||
circuit_breaker_open,
|
||||
);
|
||||
let auth_config = state.parse_catalog_auth_config_json(key);
|
||||
let oauth_expires_at = admin_pool_derive_oauth_expires_at(key, auth_config.as_ref());
|
||||
let oauth_plan_type =
|
||||
admin_pool_derive_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||
let status_snapshot = provider_key_status_snapshot_payload(key);
|
||||
let account_snapshot = status_snapshot
|
||||
.get("account")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let quota_snapshot = status_snapshot
|
||||
.get("quota")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let oauth_snapshot = status_snapshot
|
||||
.get("oauth")
|
||||
.and_then(serde_json::Value::as_object);
|
||||
let quota_updated_at =
|
||||
admin_pool_json_to_u64(quota_snapshot.and_then(|item| item.get("updated_at")));
|
||||
let oauth_invalid_at =
|
||||
admin_pool_json_to_u64(oauth_snapshot.and_then(|item| item.get("invalid_at")))
|
||||
.or(key.oauth_invalid_at_unix_secs);
|
||||
let oauth_account_id = admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_id");
|
||||
let oauth_account_name =
|
||||
admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_name");
|
||||
let oauth_account_user_id =
|
||||
admin_pool_trimmed_string_from_map(auth_config.as_ref(), "account_user_id");
|
||||
let oauth_organizations = admin_pool_oauth_organizations(auth_config.as_ref());
|
||||
let account_status_code = admin_pool_trimmed_string_from_map(account_snapshot, "code");
|
||||
let account_status_label =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("label")));
|
||||
let account_status_reason =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("reason")));
|
||||
let account_status_blocked = account_snapshot
|
||||
.and_then(|item| item.get("blocked"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let account_status_recoverable = account_snapshot
|
||||
.and_then(|item| item.get("recoverable"))
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let account_status_source =
|
||||
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("source")));
|
||||
|
||||
let mut payload = serde_json::Map::new();
|
||||
payload.insert("key_id".to_string(), json!(key.id));
|
||||
payload.insert("key_name".to_string(), json!(key.name));
|
||||
payload.insert("is_active".to_string(), json!(key.is_active));
|
||||
payload.insert("auth_type".to_string(), json!(key.auth_type));
|
||||
payload.insert("oauth_expires_at".to_string(), json!(oauth_expires_at));
|
||||
payload.insert("oauth_invalid_at".to_string(), json!(oauth_invalid_at));
|
||||
payload.insert(
|
||||
"oauth_invalid_reason".to_string(),
|
||||
json!(key.oauth_invalid_reason),
|
||||
);
|
||||
payload.insert("oauth_plan_type".to_string(), json!(oauth_plan_type));
|
||||
payload.insert("oauth_account_id".to_string(), json!(oauth_account_id));
|
||||
payload.insert("oauth_account_name".to_string(), json!(oauth_account_name));
|
||||
payload.insert(
|
||||
"oauth_account_user_id".to_string(),
|
||||
json!(oauth_account_user_id),
|
||||
);
|
||||
payload.insert(
|
||||
"oauth_organizations".to_string(),
|
||||
serde_json::Value::Array(oauth_organizations),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_code".to_string(),
|
||||
json!(account_status_code),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_label".to_string(),
|
||||
json!(account_status_label),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_reason".to_string(),
|
||||
json!(account_status_reason),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_blocked".to_string(),
|
||||
json!(account_status_blocked),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_recoverable".to_string(),
|
||||
json!(account_status_recoverable),
|
||||
);
|
||||
payload.insert(
|
||||
"account_status_source".to_string(),
|
||||
json!(account_status_source),
|
||||
);
|
||||
payload.insert("status_snapshot".to_string(), status_snapshot);
|
||||
payload.insert("quota_updated_at".to_string(), json!(quota_updated_at));
|
||||
payload.insert("health_score".to_string(), json!(health_score));
|
||||
payload.insert(
|
||||
"circuit_breaker_open".to_string(),
|
||||
json!(circuit_breaker_open),
|
||||
);
|
||||
payload.insert(
|
||||
"api_formats".to_string(),
|
||||
json!(admin_pool_api_formats(key)),
|
||||
);
|
||||
payload.insert(
|
||||
"rate_multipliers".to_string(),
|
||||
json!(admin_pool_json_object(key.rate_multipliers.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"internal_priority".to_string(),
|
||||
json!(key.internal_priority),
|
||||
);
|
||||
payload.insert("rpm_limit".to_string(), json!(key.rpm_limit));
|
||||
payload.insert(
|
||||
"cache_ttl_minutes".to_string(),
|
||||
json!(key.cache_ttl_minutes),
|
||||
);
|
||||
payload.insert(
|
||||
"max_probe_interval_minutes".to_string(),
|
||||
json!(key.max_probe_interval_minutes),
|
||||
);
|
||||
payload.insert("note".to_string(), json!(key.note));
|
||||
payload.insert(
|
||||
"allowed_models".to_string(),
|
||||
json!(admin_pool_string_list(key.allowed_models.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"capabilities".to_string(),
|
||||
json!(admin_pool_json_object(key.capabilities.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"auto_fetch_models".to_string(),
|
||||
json!(key.auto_fetch_models),
|
||||
);
|
||||
payload.insert(
|
||||
"locked_models".to_string(),
|
||||
json!(admin_pool_string_list(key.locked_models.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"model_include_patterns".to_string(),
|
||||
json!(admin_pool_string_list(key.model_include_patterns.as_ref())),
|
||||
);
|
||||
payload.insert(
|
||||
"model_exclude_patterns".to_string(),
|
||||
json!(admin_pool_string_list(key.model_exclude_patterns.as_ref())),
|
||||
);
|
||||
payload.insert("proxy".to_string(), json!(key.proxy.clone()));
|
||||
payload.insert("fingerprint".to_string(), json!(key.fingerprint.clone()));
|
||||
payload.insert(
|
||||
"account_quota".to_string(),
|
||||
json!(admin_pool_build_account_quota(
|
||||
provider_type,
|
||||
key.upstream_metadata.as_ref(),
|
||||
)),
|
||||
);
|
||||
payload.insert("cooldown_reason".to_string(), json!(cooldown_reason));
|
||||
payload.insert(
|
||||
"cooldown_ttl_seconds".to_string(),
|
||||
json!(cooldown_ttl_seconds),
|
||||
);
|
||||
payload.insert(
|
||||
"cost_window_usage".to_string(),
|
||||
json!(runtime
|
||||
.cost_window_usage_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0)),
|
||||
);
|
||||
payload.insert(
|
||||
"cost_limit".to_string(),
|
||||
json!(pool_config.map(|config| config.cost_limit_per_key_tokens)),
|
||||
);
|
||||
payload.insert(
|
||||
"request_count".to_string(),
|
||||
json!(key.request_count.unwrap_or(0)),
|
||||
);
|
||||
payload.insert("total_tokens".to_string(), json!(key.total_tokens));
|
||||
payload.insert(
|
||||
"total_cost_usd".to_string(),
|
||||
json!(format!("{:.8}", key.total_cost_usd)),
|
||||
);
|
||||
payload.insert(
|
||||
"sticky_sessions".to_string(),
|
||||
json!(runtime
|
||||
.sticky_sessions_by_key
|
||||
.get(&key.id)
|
||||
.copied()
|
||||
.unwrap_or(0)),
|
||||
);
|
||||
payload.insert(
|
||||
"lru_score".to_string(),
|
||||
json!(runtime.lru_score_by_key.get(&key.id).copied()),
|
||||
);
|
||||
payload.insert(
|
||||
"created_at".to_string(),
|
||||
json!(key.created_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert(
|
||||
"last_used_at".to_string(),
|
||||
json!(key.last_used_at_unix_secs.and_then(unix_secs_to_rfc3339)),
|
||||
);
|
||||
payload.insert("scheduling_status".to_string(), json!(scheduling_status));
|
||||
payload.insert("scheduling_reason".to_string(), json!(scheduling_reason));
|
||||
payload.insert("scheduling_label".to_string(), json!(scheduling_label));
|
||||
payload.insert("scheduling_reasons".to_string(), json!(scheduling_reasons));
|
||||
|
||||
serde_json::Value::Object(payload)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,15 @@ pub(super) async fn build_admin_pool_list_keys_response(
|
||||
|
||||
let items = keys
|
||||
.into_iter()
|
||||
.map(|key| pool_payloads::build_admin_pool_key_payload(&key, &runtime, pool_config))
|
||||
.map(|key| {
|
||||
pool_payloads::build_admin_pool_key_payload(
|
||||
state,
|
||||
&provider.provider_type,
|
||||
&key,
|
||||
&runtime,
|
||||
pool_config,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(Json(json!({
|
||||
|
||||
@@ -48,6 +48,33 @@ fn admin_pool_derive_oauth_plan_type(
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for plan_key in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(plan_key).and_then(serde_json::Value::as_str) {
|
||||
if let Some(normalized) = normalize(value) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(auth_config) = admin_pool_parse_auth_config_json(state, key) {
|
||||
for plan_key in ["plan_type", "tier", "plan", "subscription_plan"] {
|
||||
if let Some(value) = auth_config
|
||||
@@ -61,28 +88,6 @@ fn admin_pool_derive_oauth_plan_type(
|
||||
}
|
||||
}
|
||||
|
||||
let upstream_metadata = key.upstream_metadata.as_ref()?.as_object()?;
|
||||
let provider_bucket = upstream_metadata
|
||||
.get(&provider_type.trim().to_ascii_lowercase())
|
||||
.and_then(serde_json::Value::as_object);
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
for plan_key in [
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
] {
|
||||
if let Some(value) = source.get(plan_key).and_then(serde_json::Value::as_str) {
|
||||
if let Some(normalized) = normalize(value) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
|
||||
@@ -1,71 +1,223 @@
|
||||
use super::payload::{provider_query_extract_api_key_id, provider_query_extract_provider_id};
|
||||
use super::payload::{
|
||||
provider_query_extract_api_key_id, provider_query_extract_force_refresh,
|
||||
provider_query_extract_provider_id,
|
||||
};
|
||||
use super::response::{
|
||||
build_admin_provider_query_bad_request_response, build_admin_provider_query_not_found_response,
|
||||
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL, ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL, ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_PROVIDER_ID_REQUIRED_DETAIL,
|
||||
ADMIN_PROVIDER_QUERY_PROVIDER_NOT_FOUND_DETAIL,
|
||||
};
|
||||
use crate::execution_runtime;
|
||||
use crate::model_fetch::ModelFetchRuntimeState;
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::GatewayError;
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_model_fetch::{
|
||||
aggregate_models_for_cache, build_models_fetch_execution_plan, extract_error_message,
|
||||
parse_models_response,
|
||||
};
|
||||
use axum::{body::Body, http::Response, response::IntoResponse, Json};
|
||||
use serde_json::json;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_MESSAGE: &str =
|
||||
"Rust local provider-query model test is not configured";
|
||||
pub(crate) const ADMIN_PROVIDER_QUERY_LOCAL_TEST_MODEL_FAILOVER_MESSAGE: &str =
|
||||
"Rust local provider-query failover simulation is not configured";
|
||||
const ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL: &str =
|
||||
"No active endpoints found for this provider";
|
||||
const ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL: &str =
|
||||
"No models returned from any endpoint";
|
||||
const PROVIDER_QUERY_FETCH_FORMAT_PRIORITY: &[&[&str]] = &[
|
||||
&["openai:chat", "openai:cli", "openai:compact"],
|
||||
&["claude:chat", "claude:cli"],
|
||||
&["gemini:chat", "gemini:cli"],
|
||||
];
|
||||
|
||||
fn provider_query_string_list(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||
value
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
#[derive(Debug)]
|
||||
struct ProviderQueryKeyFetchResult {
|
||||
models: Vec<Value>,
|
||||
error: Option<String>,
|
||||
from_cache: bool,
|
||||
}
|
||||
|
||||
fn provider_query_resolved_api_formats(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
selected_key: Option<&StoredProviderCatalogKey>,
|
||||
) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
let key_formats = selected_key
|
||||
.map(|key| provider_query_string_list(key.api_formats.as_ref()))
|
||||
.unwrap_or_default();
|
||||
let mut formats = Vec::new();
|
||||
fn provider_query_provider_payload(provider: &StoredProviderCatalogProvider) -> Value {
|
||||
json!({
|
||||
"id": provider.id.clone(),
|
||||
"name": provider.name.clone(),
|
||||
"display_name": provider.name.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn provider_query_key_display_name(key: &StoredProviderCatalogKey) -> String {
|
||||
let trimmed = key.name.trim();
|
||||
if trimmed.is_empty() {
|
||||
key.id.clone()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_query_normalize_api_format(value: &str) -> String {
|
||||
value.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn provider_query_selected_fetch_endpoints(
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
) -> Vec<StoredProviderCatalogEndpoint> {
|
||||
let mut by_format = BTreeMap::<String, StoredProviderCatalogEndpoint>::new();
|
||||
for endpoint in endpoints.iter().filter(|endpoint| endpoint.is_active) {
|
||||
let api_format = endpoint.api_format.trim();
|
||||
let api_format = provider_query_normalize_api_format(&endpoint.api_format);
|
||||
if api_format.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !key_formats.is_empty() && !key_formats.iter().any(|value| value == api_format) {
|
||||
by_format.insert(api_format, endpoint.clone());
|
||||
}
|
||||
|
||||
// 与 Python 版本保持一致:同族优先使用 chat 端点,其次才回退到 cli/compact。
|
||||
PROVIDER_QUERY_FETCH_FORMAT_PRIORITY
|
||||
.iter()
|
||||
.filter_map(|candidates| {
|
||||
candidates
|
||||
.iter()
|
||||
.find_map(|api_format| by_format.get(*api_format))
|
||||
.cloned()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn provider_query_read_cached_models(
|
||||
state: &AppState,
|
||||
provider_id: &str,
|
||||
key_id: &str,
|
||||
) -> Option<Vec<Value>> {
|
||||
let runner = state.redis_kv_runner()?;
|
||||
let cache_key = runner
|
||||
.keyspace()
|
||||
.key(&format!("upstream_models:{provider_id}:{key_id}"));
|
||||
let mut connection = runner
|
||||
.client()
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.ok()?;
|
||||
let raw = redis::cmd("GET")
|
||||
.arg(&cache_key)
|
||||
.query_async::<Option<String>>(&mut connection)
|
||||
.await
|
||||
.ok()??;
|
||||
let parsed = serde_json::from_str::<Vec<Value>>(&raw).ok()?;
|
||||
Some(aggregate_models_for_cache(&parsed))
|
||||
}
|
||||
|
||||
async fn provider_query_fetch_models_from_transport(
|
||||
state: &AppState,
|
||||
transport: &crate::provider_transport::GatewayProviderTransportSnapshot,
|
||||
) -> Result<Vec<Value>, String> {
|
||||
let plan = build_models_fetch_execution_plan(state, transport).await?;
|
||||
let result = execution_runtime::execute_execution_runtime_sync_plan(state, None, &plan)
|
||||
.await
|
||||
.map_err(|err| format!("{err:?}"))?;
|
||||
|
||||
if result.status_code != 200 {
|
||||
let message = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
.and_then(extract_error_message)
|
||||
.or_else(|| {
|
||||
result.error.as_ref().and_then(|error| {
|
||||
let message = error.message.trim();
|
||||
(!message.is_empty()).then_some(message.to_string())
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| format!("upstream returned status {}", result.status_code));
|
||||
return Err(message);
|
||||
}
|
||||
|
||||
let body_json = result
|
||||
.body
|
||||
.as_ref()
|
||||
.and_then(|body| body.json_body.as_ref())
|
||||
.ok_or_else(|| "models fetch response body is missing JSON payload".to_string())?;
|
||||
let parsed = parse_models_response(&transport.endpoint.api_format, body_json)?;
|
||||
Ok(parsed.cached_models)
|
||||
}
|
||||
|
||||
async fn provider_query_fetch_models_for_key(
|
||||
state: &AppState,
|
||||
provider: &StoredProviderCatalogProvider,
|
||||
endpoints: &[StoredProviderCatalogEndpoint],
|
||||
key: &StoredProviderCatalogKey,
|
||||
force_refresh: bool,
|
||||
) -> Result<ProviderQueryKeyFetchResult, GatewayError> {
|
||||
if !force_refresh {
|
||||
if let Some(cached_models) =
|
||||
provider_query_read_cached_models(state, &provider.id, &key.id).await
|
||||
{
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models: cached_models,
|
||||
error: None,
|
||||
from_cache: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let selected_endpoints = provider_query_selected_fetch_endpoints(endpoints);
|
||||
if selected_endpoints.is_empty() {
|
||||
return Ok(ProviderQueryKeyFetchResult {
|
||||
models: Vec::new(),
|
||||
error: Some(ADMIN_PROVIDER_QUERY_NO_ACTIVE_ENDPOINT_DETAIL.to_string()),
|
||||
from_cache: false,
|
||||
});
|
||||
}
|
||||
|
||||
let mut all_models = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
for endpoint in selected_endpoints {
|
||||
let Some(transport) = state
|
||||
.read_provider_transport_snapshot(&provider.id, &endpoint.id, &key.id)
|
||||
.await?
|
||||
else {
|
||||
all_errors.push(format!(
|
||||
"{} transport snapshot unavailable",
|
||||
endpoint.api_format.trim()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
if seen.insert(api_format.to_string()) {
|
||||
formats.push(api_format.to_string());
|
||||
};
|
||||
match provider_query_fetch_models_from_transport(state, &transport).await {
|
||||
Ok(models) => all_models.extend(models),
|
||||
Err(err) => all_errors.push(err),
|
||||
}
|
||||
}
|
||||
|
||||
if formats.is_empty() {
|
||||
for api_format in key_formats {
|
||||
if seen.insert(api_format.clone()) {
|
||||
formats.push(api_format);
|
||||
}
|
||||
}
|
||||
let unique_models = aggregate_models_for_cache(&all_models);
|
||||
if !unique_models.is_empty() {
|
||||
<AppState as ModelFetchRuntimeState>::write_upstream_models_cache(
|
||||
state,
|
||||
&provider.id,
|
||||
&key.id,
|
||||
&unique_models,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
formats
|
||||
let mut error = if all_errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(all_errors.join("; "))
|
||||
};
|
||||
if unique_models.is_empty() && error.is_none() {
|
||||
error = Some(ADMIN_PROVIDER_QUERY_NO_MODELS_FROM_ENDPOINT_DETAIL.to_string());
|
||||
}
|
||||
|
||||
Ok(ProviderQueryKeyFetchResult {
|
||||
models: unique_models,
|
||||
error,
|
||||
from_cache: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn build_admin_provider_query_models_response(
|
||||
@@ -99,96 +251,89 @@ pub(crate) async fn build_admin_provider_query_models_response(
|
||||
.app()
|
||||
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
|
||||
.await?;
|
||||
let selected_key = if let Some(api_key_id) = provider_query_extract_api_key_id(payload) {
|
||||
let Some(key) = keys.iter().find(|key| key.id == api_key_id) else {
|
||||
let force_refresh = provider_query_extract_force_refresh(payload);
|
||||
|
||||
if let Some(api_key_id) = provider_query_extract_api_key_id(payload) {
|
||||
let Some(selected_key) = keys.iter().find(|key| key.id == api_key_id) else {
|
||||
return Ok(build_admin_provider_query_not_found_response(
|
||||
ADMIN_PROVIDER_QUERY_API_KEY_NOT_FOUND_DETAIL,
|
||||
));
|
||||
};
|
||||
Some(key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let active_keys = keys.iter().filter(|key| key.is_active).count();
|
||||
if selected_key.is_none() && active_keys == 0 {
|
||||
|
||||
let result = provider_query_fetch_models_for_key(
|
||||
state.app(),
|
||||
&provider,
|
||||
&endpoints,
|
||||
selected_key,
|
||||
force_refresh,
|
||||
)
|
||||
.await?;
|
||||
let success = !result.models.is_empty();
|
||||
return Ok(Json(json!({
|
||||
"success": success,
|
||||
"data": {
|
||||
"models": result.models,
|
||||
"error": result.error,
|
||||
"from_cache": result.from_cache,
|
||||
},
|
||||
"provider": provider_query_provider_payload(&provider),
|
||||
}))
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let active_keys = keys.iter().filter(|key| key.is_active).collect::<Vec<_>>();
|
||||
if active_keys.is_empty() {
|
||||
return Ok(build_admin_provider_query_bad_request_response(
|
||||
ADMIN_PROVIDER_QUERY_NO_ACTIVE_API_KEY_DETAIL,
|
||||
));
|
||||
}
|
||||
let active_key_count = active_keys.len();
|
||||
|
||||
let resolved_api_formats = provider_query_resolved_api_formats(&endpoints, selected_key);
|
||||
let provider_models = state
|
||||
.app()
|
||||
.list_admin_provider_available_source_models(&provider.id)
|
||||
.await?;
|
||||
|
||||
let mut grouped: BTreeMap<
|
||||
String,
|
||||
(
|
||||
aether_data_contracts::repository::global_models::StoredAdminProviderModel,
|
||||
BTreeSet<String>,
|
||||
),
|
||||
> = BTreeMap::new();
|
||||
for model in provider_models {
|
||||
let entry = grouped
|
||||
.entry(model.provider_model_name.clone())
|
||||
.or_insert_with(|| (model.clone(), BTreeSet::new()));
|
||||
for api_format in &resolved_api_formats {
|
||||
entry.1.insert(api_format.clone());
|
||||
let mut all_models = Vec::new();
|
||||
let mut all_errors = Vec::new();
|
||||
let mut cache_hit_count = 0usize;
|
||||
let mut fetch_count = 0usize;
|
||||
for key in active_keys {
|
||||
let result =
|
||||
provider_query_fetch_models_for_key(state.app(), &provider, &endpoints, key, force_refresh)
|
||||
.await?;
|
||||
all_models.extend(result.models);
|
||||
if let Some(error) = result.error {
|
||||
all_errors.push(format!(
|
||||
"Key {}: {}",
|
||||
provider_query_key_display_name(key),
|
||||
error
|
||||
));
|
||||
}
|
||||
if result.from_cache {
|
||||
cache_hit_count += 1;
|
||||
} else {
|
||||
fetch_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let models: Vec<_> = grouped
|
||||
.into_iter()
|
||||
.map(|(model_id, (model, api_formats))| {
|
||||
let display_name = model
|
||||
.global_model_display_name
|
||||
.clone()
|
||||
.or(model.global_model_name.clone())
|
||||
.unwrap_or_else(|| model_id.clone());
|
||||
let api_formats: Vec<_> = api_formats.into_iter().collect();
|
||||
json!({
|
||||
"id": model_id,
|
||||
"object": "model",
|
||||
"created": model.created_at_unix_secs,
|
||||
"owned_by": provider.name,
|
||||
"display_name": display_name,
|
||||
"api_format": api_formats.first().cloned(),
|
||||
"api_formats": api_formats,
|
||||
"provider_model_name": model.provider_model_name,
|
||||
"global_model_id": model.global_model_id,
|
||||
"global_model_name": model.global_model_name,
|
||||
"supports_streaming": model.supports_streaming,
|
||||
"supports_function_calling": model.supports_function_calling,
|
||||
"supports_vision": model.supports_vision,
|
||||
"supports_extended_thinking": model.supports_extended_thinking,
|
||||
"supports_image_generation": model.supports_image_generation,
|
||||
"is_available": model.is_available,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let models = aggregate_models_for_cache(&all_models);
|
||||
let success = !models.is_empty();
|
||||
let error = if success {
|
||||
let mut error = if all_errors.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ADMIN_PROVIDER_QUERY_NO_LOCAL_MODELS_DETAIL)
|
||||
Some(all_errors.join("; "))
|
||||
};
|
||||
if !success && error.is_none() {
|
||||
error = Some("No models returned from any key".to_string());
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"success": success,
|
||||
"data": {
|
||||
"models": models,
|
||||
"error": error,
|
||||
"from_cache": true,
|
||||
"keys_total": active_keys,
|
||||
"keys_cached": 0,
|
||||
"keys_fetched": 0,
|
||||
},
|
||||
"provider": {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.name,
|
||||
"from_cache": fetch_count == 0 && cache_hit_count > 0,
|
||||
"keys_total": active_key_count,
|
||||
"keys_cached": cache_hit_count,
|
||||
"keys_fetched": fetch_count,
|
||||
},
|
||||
"provider": provider_query_provider_payload(&provider),
|
||||
}))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,13 @@ pub(crate) fn provider_query_extract_api_key_id(payload: &serde_json::Value) ->
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_query_extract_force_refresh(payload: &serde_json::Value) -> bool {
|
||||
payload
|
||||
.get("force_refresh")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_query_extract_model(payload: &serde_json::Value) -> Option<String> {
|
||||
payload
|
||||
.get("model")
|
||||
|
||||
@@ -39,7 +39,9 @@ pub(crate) async fn build_admin_provider_keys_payload(
|
||||
keys.into_iter()
|
||||
.skip(skip)
|
||||
.take(limit)
|
||||
.map(|key| state.build_admin_provider_key_response(&key, now_unix_secs))
|
||||
.map(|key| {
|
||||
state.build_admin_provider_key_response(&key, &provider.provider_type, now_unix_secs)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ impl<'a> AdminAppState<'a> {
|
||||
pub(crate) fn build_admin_provider_key_response(
|
||||
&self,
|
||||
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
crate::handlers::admin::shared::build_admin_provider_key_response(
|
||||
self.app,
|
||||
key,
|
||||
provider_type,
|
||||
now_unix_secs,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -672,8 +672,7 @@ pub(crate) async fn maybe_build_local_public_support_response(
|
||||
providers.into_iter().next()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.or_else(|| None);
|
||||
};
|
||||
let provider = match provider {
|
||||
Some(provider) => provider,
|
||||
None => state
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use aether_data_contracts::repository::candidate_selection::StoredMinimalCandidateSelectionRow;
|
||||
use axum::{body::Body, response::Response};
|
||||
|
||||
use super::models_responses::{
|
||||
@@ -9,9 +10,74 @@ use super::models_responses::{
|
||||
build_models_not_found_response, build_openai_model_detail_response,
|
||||
build_openai_models_list_response,
|
||||
};
|
||||
use super::models_shared::{filter_rows_for_models, models_api_format, models_detail_id};
|
||||
use super::models_shared::{
|
||||
filter_rows_for_models, models_api_format, models_detail_id, models_query_api_formats,
|
||||
};
|
||||
use super::{query_param_value, AppState, GatewayPublicRequestContext};
|
||||
|
||||
fn sort_and_dedup_model_rows(
|
||||
mut rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
) -> Vec<StoredMinimalCandidateSelectionRow> {
|
||||
rows.sort_by(|left, right| {
|
||||
left.global_model_name
|
||||
.cmp(&right.global_model_name)
|
||||
.then(left.provider_priority.cmp(&right.provider_priority))
|
||||
.then(left.key_internal_priority.cmp(&right.key_internal_priority))
|
||||
.then(left.provider_id.cmp(&right.provider_id))
|
||||
.then(left.endpoint_id.cmp(&right.endpoint_id))
|
||||
.then(left.key_id.cmp(&right.key_id))
|
||||
.then(left.model_id.cmp(&right.model_id))
|
||||
});
|
||||
let mut deduped = Vec::with_capacity(rows.len());
|
||||
let mut last_model_name: Option<String> = None;
|
||||
for row in rows {
|
||||
if last_model_name.as_deref() == Some(row.global_model_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
last_model_name = Some(row.global_model_name.clone());
|
||||
deduped.push(row);
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
async fn list_model_rows_for_client_format(
|
||||
state: &AppState,
|
||||
api_format: &str,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
|
||||
let mut collected = Vec::new();
|
||||
for query_format in models_query_api_formats(api_format) {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format(query_format)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut filtered = filter_rows_for_models(rows, auth_snapshot, query_format);
|
||||
collected.append(&mut filtered);
|
||||
}
|
||||
Some(sort_and_dedup_model_rows(collected))
|
||||
}
|
||||
|
||||
async fn list_model_rows_for_client_format_and_global_model(
|
||||
state: &AppState,
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
|
||||
) -> Option<Vec<StoredMinimalCandidateSelectionRow>> {
|
||||
let mut collected = Vec::new();
|
||||
for query_format in models_query_api_formats(api_format) {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_global_model(
|
||||
query_format,
|
||||
global_model_name,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let mut filtered = filter_rows_for_models(rows, auth_snapshot, query_format);
|
||||
collected.append(&mut filtered);
|
||||
}
|
||||
Some(sort_and_dedup_model_rows(collected))
|
||||
}
|
||||
|
||||
pub(super) async fn maybe_build_local_models_route_response(
|
||||
state: &AppState,
|
||||
request_context: &GatewayPublicRequestContext,
|
||||
@@ -44,11 +110,7 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
|
||||
match decision.route_kind.as_deref() {
|
||||
Some("list") => {
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format(api_format)
|
||||
.await
|
||||
.ok()?;
|
||||
let rows = filter_rows_for_models(rows, auth_snapshot, api_format);
|
||||
let rows = list_model_rows_for_client_format(state, api_format, auth_snapshot).await?;
|
||||
if rows.is_empty() {
|
||||
return Some(build_empty_models_list_response(api_format));
|
||||
}
|
||||
@@ -94,13 +156,13 @@ pub(super) async fn maybe_build_local_models_route_response(
|
||||
}
|
||||
Some("detail") => {
|
||||
let model_id = models_detail_id(&request_context.request_path)?;
|
||||
let rows = state
|
||||
.list_minimal_candidate_selection_rows_for_api_format_and_global_model(
|
||||
api_format, &model_id,
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let rows = filter_rows_for_models(rows, auth_snapshot, api_format);
|
||||
let rows = list_model_rows_for_client_format_and_global_model(
|
||||
state,
|
||||
api_format,
|
||||
&model_id,
|
||||
auth_snapshot,
|
||||
)
|
||||
.await?;
|
||||
let Some(row) = rows.first() else {
|
||||
return Some(build_models_not_found_response(&model_id, api_format));
|
||||
};
|
||||
|
||||
@@ -13,6 +13,23 @@ pub(crate) fn models_api_format(request_context: &GatewayPublicRequestContext) -
|
||||
.filter(|signature| matches!(*signature, "openai:chat" | "claude:chat" | "gemini:chat"))
|
||||
}
|
||||
|
||||
const MODELS_CROSS_FORMAT_QUERY_API_FORMATS: &[&str] = &[
|
||||
"openai:chat",
|
||||
"openai:cli",
|
||||
"openai:compact",
|
||||
"claude:chat",
|
||||
"claude:cli",
|
||||
"gemini:chat",
|
||||
"gemini:cli",
|
||||
];
|
||||
|
||||
pub(super) fn models_query_api_formats(api_format: &str) -> &'static [&'static str] {
|
||||
match api_format.trim().to_ascii_lowercase().as_str() {
|
||||
"openai:chat" | "claude:chat" | "gemini:chat" => MODELS_CROSS_FORMAT_QUERY_API_FORMATS,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn models_detail_id(request_path: &str) -> Option<String> {
|
||||
let raw = if let Some(value) = request_path.strip_prefix("/v1/models/") {
|
||||
value
|
||||
|
||||
@@ -231,9 +231,98 @@ pub(crate) fn provider_key_health_summary(
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_catalog_oauth_plan_type(value: &str, provider_type: &str) -> Option<String> {
|
||||
let mut normalized = value.trim().to_string();
|
||||
if normalized.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type = provider_type.trim().to_ascii_lowercase();
|
||||
if !provider_type.is_empty() && normalized.to_ascii_lowercase().starts_with(&provider_type) {
|
||||
normalized = normalized[provider_type.len()..]
|
||||
.trim_matches(|ch: char| [' ', ':', '-', '_'].contains(&ch))
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let normalized = normalized.trim().to_ascii_lowercase();
|
||||
if normalized.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_oauth_plan_type_from_source(
|
||||
source: &serde_json::Map<String, serde_json::Value>,
|
||||
provider_type: &str,
|
||||
fields: &[&str],
|
||||
) -> Option<String> {
|
||||
for field in fields {
|
||||
let Some(value) = source.get(*field).and_then(serde_json::Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(normalized) = normalize_catalog_oauth_plan_type(value, provider_type) {
|
||||
return Some(normalized);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn derive_catalog_oauth_plan_type(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
auth_config: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let provider_type_key = provider_type.trim().to_ascii_lowercase();
|
||||
if let Some(upstream_metadata) = key
|
||||
.upstream_metadata
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_object)
|
||||
{
|
||||
let provider_bucket = if provider_type_key.is_empty() {
|
||||
None
|
||||
} else {
|
||||
upstream_metadata
|
||||
.get(&provider_type_key)
|
||||
.and_then(serde_json::Value::as_object)
|
||||
};
|
||||
for source in provider_bucket
|
||||
.into_iter()
|
||||
.chain(std::iter::once(upstream_metadata))
|
||||
{
|
||||
if let Some(plan_type) = catalog_oauth_plan_type_from_source(
|
||||
source,
|
||||
provider_type,
|
||||
&[
|
||||
"plan_type",
|
||||
"tier",
|
||||
"subscription_title",
|
||||
"subscription_plan",
|
||||
"plan",
|
||||
],
|
||||
) {
|
||||
return Some(plan_type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth_config.and_then(|source| {
|
||||
catalog_oauth_plan_type_from_source(
|
||||
source,
|
||||
provider_type,
|
||||
&["plan_type", "tier", "plan", "subscription_plan"],
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_admin_provider_key_response(
|
||||
state: &AppState,
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> serde_json::Value {
|
||||
let request_count = u64::from(key.request_count.unwrap_or(0));
|
||||
@@ -257,16 +346,7 @@ pub(crate) fn build_admin_provider_key_response(
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let oauth_plan_type = auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("plan_type").and_then(serde_json::Value::as_str))
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
auth_config
|
||||
.as_ref()
|
||||
.and_then(|config| config.get("tier").and_then(serde_json::Value::as_str))
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
});
|
||||
let oauth_plan_type = derive_catalog_oauth_plan_type(key, provider_type, auth_config.as_ref());
|
||||
let (
|
||||
health_score,
|
||||
consecutive_failures,
|
||||
|
||||
@@ -79,7 +79,7 @@ pub(crate) use self::fallback_metrics::{GatewayFallbackMetricKind, GatewayFallba
|
||||
pub use self::middleware::strip_cf_headers_middleware;
|
||||
pub use self::rate_limit::FrontdoorUserRpmConfig;
|
||||
pub(crate) use self::rate_limit::FrontdoorUserRpmOutcome;
|
||||
pub use self::router::{build_router, build_router_with_state, serve_tcp};
|
||||
pub use self::router::{attach_static_frontend, build_router, build_router_with_state, serve_tcp};
|
||||
pub(crate) use self::state::{
|
||||
AdminBillingCollectorRecord, AdminBillingCollectorWriteInput, AdminBillingRuleRecord,
|
||||
AdminBillingRuleWriteInput, AdminWalletMutationOutcome, AdminWalletPaymentOrderRecord,
|
||||
|
||||
+134
-12
@@ -5,8 +5,8 @@ use aether_crypto::warm_python_fernet_secret;
|
||||
use aether_data::postgres::PostgresPoolConfig;
|
||||
use aether_data::redis::RedisClientConfig;
|
||||
use aether_gateway::{
|
||||
build_router_with_state, AppState, FrontdoorCorsConfig, FrontdoorUserRpmConfig,
|
||||
GatewayDataConfig, UsageRuntimeConfig, VideoTaskTruthSourceMode,
|
||||
attach_static_frontend, build_router_with_state, AppState, FrontdoorCorsConfig,
|
||||
FrontdoorUserRpmConfig, GatewayDataConfig, UsageRuntimeConfig, VideoTaskTruthSourceMode,
|
||||
};
|
||||
use aether_runtime::{
|
||||
init_service_runtime, DistributedConcurrencyGate, FileLoggingConfig, LogDestination, LogFormat,
|
||||
@@ -494,6 +494,18 @@ struct Args {
|
||||
#[arg(long, env = "AETHER_GATEWAY_BIND", default_value = "0.0.0.0:80")]
|
||||
bind: String,
|
||||
|
||||
/// 容器内健康检查入口:根据当前 bind 端口探测本地 /health。
|
||||
#[arg(long, hide = true, default_value_t = false)]
|
||||
healthcheck: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
hide = true,
|
||||
env = "AETHER_GATEWAY_HEALTHCHECK_TIMEOUT_MS",
|
||||
default_value_t = 3_000
|
||||
)]
|
||||
healthcheck_timeout_ms: u64,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY",
|
||||
@@ -609,6 +621,70 @@ fn resolve_gateway_log_instance_id() -> String {
|
||||
.unwrap_or_else(|| "local".to_string())
|
||||
}
|
||||
|
||||
fn resolve_healthcheck_url(bind: &str) -> Result<String, std::io::Error> {
|
||||
let trimmed = bind.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"AETHER_GATEWAY_BIND cannot be empty when --healthcheck is enabled",
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(socket_addr) = trimmed.parse::<std::net::SocketAddr>() {
|
||||
let host = match socket_addr.ip() {
|
||||
std::net::IpAddr::V4(ip) if ip.is_unspecified() => "127.0.0.1".to_string(),
|
||||
std::net::IpAddr::V4(ip) => ip.to_string(),
|
||||
std::net::IpAddr::V6(ip) if ip.is_unspecified() => "[::1]".to_string(),
|
||||
std::net::IpAddr::V6(ip) => format!("[{ip}]"),
|
||||
};
|
||||
return Ok(format!("http://{host}:{}/health", socket_addr.port()));
|
||||
}
|
||||
|
||||
let (host, port) = trimmed.rsplit_once(':').ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"AETHER_GATEWAY_BIND must include a port when --healthcheck is enabled: {trimmed}"
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let port = port.parse::<u16>().map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid healthcheck port in AETHER_GATEWAY_BIND={trimmed}: {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let host = host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid host in AETHER_GATEWAY_BIND={trimmed}"),
|
||||
));
|
||||
}
|
||||
let host = if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
|
||||
Ok(format!("http://{host}:{port}/health"))
|
||||
}
|
||||
|
||||
async fn run_healthcheck(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let url = resolve_healthcheck_url(&args.bind)?;
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(
|
||||
args.healthcheck_timeout_ms.max(1),
|
||||
))
|
||||
.build()?
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_deployment_topology(
|
||||
args: &Args,
|
||||
data_postgres_url: Option<&str>,
|
||||
@@ -685,6 +761,9 @@ fn validate_deployment_topology(
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = Args::parse();
|
||||
if args.healthcheck {
|
||||
return run_healthcheck(&args).await;
|
||||
}
|
||||
init_service_runtime(args.runtime_config()?)?;
|
||||
let data_postgres_url = args.data.effective_postgres_url();
|
||||
let data_redis_url = args.data.effective_redis_url();
|
||||
@@ -776,7 +855,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(|| data_redis_url.as_deref())
|
||||
.or(data_redis_url.as_deref())
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
@@ -847,17 +926,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Compose the final router: API routes + optional static file serving + CF header stripping
|
||||
let router = if let Some(ref static_dir) = args.static_dir {
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
let static_path = std::path::PathBuf::from(static_dir);
|
||||
let index_html = static_path.join("index.html");
|
||||
info!(static_dir = %static_dir, "serving frontend static files");
|
||||
|
||||
// ServeDir with SPA fallback: if no static file matches, serve index.html
|
||||
let serve_dir = ServeDir::new(&static_path).not_found_service(ServeFile::new(&index_html));
|
||||
|
||||
api_router
|
||||
.fallback_service(serve_dir)
|
||||
attach_static_frontend(api_router, static_dir)
|
||||
.layer(CompressionLayer::new())
|
||||
.layer(axum::middleware::from_fn(
|
||||
aether_gateway::strip_cf_headers_middleware,
|
||||
@@ -878,3 +949,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::resolve_healthcheck_url;
|
||||
|
||||
#[test]
|
||||
fn resolves_ipv4_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("0.0.0.0:80").unwrap(),
|
||||
"http://127.0.0.1:80/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_ipv6_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("[::]:8080").unwrap(),
|
||||
"http://[::1]:8080/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_ipv4_bind_for_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("172.18.0.2:9000").unwrap(),
|
||||
"http://172.18.0.2:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_explicit_ipv6_bind_for_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("[2001:db8::2]:9000").unwrap(),
|
||||
"http://[2001:db8::2]:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_hostname_healthcheck_url() {
|
||||
assert_eq!(
|
||||
resolve_healthcheck_url("gateway.internal:9000").unwrap(),
|
||||
"http://gateway.internal:9000/health"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bind_without_port() {
|
||||
let error = resolve_healthcheck_url("not-a-socket").unwrap_err();
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +88,7 @@ pub(super) async fn pending_cleanup_batch_size(
|
||||
) -> Result<usize, DataLayerError> {
|
||||
Ok(system_config_usize(data, "cleanup_batch_size", 1_000)
|
||||
.await?
|
||||
.max(1)
|
||||
.min(200))
|
||||
.clamp(1, 200))
|
||||
}
|
||||
|
||||
pub(super) async fn usage_cleanup_settings(
|
||||
|
||||
@@ -205,7 +205,7 @@ async fn compress_usage_body_fields(
|
||||
|
||||
let mut total_compressed = 0usize;
|
||||
let mut no_progress_count = 0usize;
|
||||
let batch_size = batch_size.max(1).min(25);
|
||||
let batch_size = batch_size.clamp(1, 25);
|
||||
loop {
|
||||
let rows = sqlx::query(SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL)
|
||||
.bind(cutoff_time)
|
||||
|
||||
@@ -122,6 +122,9 @@ fn sample_global_model(id: &str, name: &str, mappings: &[&str]) -> StoredAdminGl
|
||||
None,
|
||||
None,
|
||||
Some(json!({ "model_mappings": mappings })),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(1_711_000_000),
|
||||
Some(1_711_000_000),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,9 @@ use aether_scheduler_core::{
|
||||
build_report_request_candidate_status_record,
|
||||
finalize_execution_request_candidate_report_context, parse_request_candidate_report_context,
|
||||
resolve_report_request_candidate_slot as resolve_report_request_candidate_slot_from_candidates,
|
||||
SchedulerMinimalCandidateSelectionCandidate, SchedulerResolvedReportRequestCandidateSlot,
|
||||
LocalRequestCandidateStatusRecordInput, ReportRequestCandidateStatusRecordInput,
|
||||
SchedulerMinimalCandidateSelectionCandidate, SchedulerRequestCandidateStatusUpdate,
|
||||
SchedulerResolvedReportRequestCandidateSlot,
|
||||
};
|
||||
use aether_usage_runtime::build_locally_actionable_report_context_from_request_candidate;
|
||||
use async_trait::async_trait;
|
||||
@@ -41,25 +43,15 @@ pub(crate) async fn record_local_request_candidate_status(
|
||||
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
status: RequestCandidateStatus,
|
||||
status_code: Option<u16>,
|
||||
error_type: Option<String>,
|
||||
error_message: Option<String>,
|
||||
latency_ms: Option<u64>,
|
||||
started_at_unix_secs: Option<u64>,
|
||||
finished_at_unix_secs: Option<u64>,
|
||||
status_update: SchedulerRequestCandidateStatusUpdate,
|
||||
) {
|
||||
let Some(record) = build_local_request_candidate_status_record(
|
||||
plan,
|
||||
report_context,
|
||||
status,
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
started_at_unix_secs,
|
||||
finished_at_unix_secs,
|
||||
) else {
|
||||
let Some(record) =
|
||||
build_local_request_candidate_status_record(LocalRequestCandidateStatusRecordInput {
|
||||
plan,
|
||||
report_context,
|
||||
status_update,
|
||||
})
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let candidate_id = record.id.clone();
|
||||
@@ -80,13 +72,7 @@ pub(crate) async fn record_local_request_candidate_status(
|
||||
pub(crate) async fn record_report_request_candidate_status(
|
||||
state: &(impl RequestCandidateRuntimeReader + RequestCandidateRuntimeWriter + ?Sized),
|
||||
report_context: Option<&Value>,
|
||||
status: RequestCandidateStatus,
|
||||
status_code: Option<u16>,
|
||||
error_type: Option<String>,
|
||||
error_message: Option<String>,
|
||||
latency_ms: Option<u64>,
|
||||
started_at_unix_secs: Option<u64>,
|
||||
finished_at_unix_secs: Option<u64>,
|
||||
status_update: SchedulerRequestCandidateStatusUpdate,
|
||||
) {
|
||||
let Some(slot) = resolve_report_request_candidate_slot(state, report_context).await else {
|
||||
return;
|
||||
@@ -95,17 +81,12 @@ pub(crate) async fn record_report_request_candidate_status(
|
||||
let request_id_for_log = short_request_id(request_id.as_str());
|
||||
let candidate_index = slot.candidate_index;
|
||||
let retry_index = slot.retry_index;
|
||||
let record = build_report_request_candidate_status_record(
|
||||
slot,
|
||||
status,
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
started_at_unix_secs,
|
||||
finished_at_unix_secs,
|
||||
current_unix_secs(),
|
||||
);
|
||||
let record =
|
||||
build_report_request_candidate_status_record(ReportRequestCandidateStatusRecordInput {
|
||||
slot,
|
||||
status_update,
|
||||
now_unix_secs: current_unix_secs(),
|
||||
});
|
||||
|
||||
if let Err(err) = state.upsert_request_candidate(record).await {
|
||||
warn!(
|
||||
@@ -326,7 +307,10 @@ mod tests {
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{ensure_execution_request_candidate_slot, record_report_request_candidate_status};
|
||||
use super::{
|
||||
ensure_execution_request_candidate_slot, record_report_request_candidate_status,
|
||||
SchedulerRequestCandidateStatusUpdate,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -494,13 +478,15 @@ mod tests {
|
||||
record_report_request_candidate_status(
|
||||
&state,
|
||||
Some(&report_context),
|
||||
RequestCandidateStatus::Success,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(25),
|
||||
Some(101),
|
||||
Some(102),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Success,
|
||||
status_code: Some(200),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: Some(25),
|
||||
started_at_unix_secs: Some(101),
|
||||
finished_at_unix_secs: Some(102),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::extract::Request;
|
||||
use axum::http::Method;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tracing::warn;
|
||||
|
||||
use aether_runtime::{prometheus_response, ConcurrencyError, DistributedConcurrencyError};
|
||||
|
||||
@@ -9,6 +17,12 @@ pub fn build_router() -> Result<Router, reqwest::Error> {
|
||||
Ok(build_router_with_state(AppState::new()?))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct FrontendStaticState {
|
||||
static_dir: PathBuf,
|
||||
index_html: PathBuf,
|
||||
}
|
||||
|
||||
pub fn build_router_with_state(state: AppState) -> Router {
|
||||
let cors_state = state.clone();
|
||||
let mut router = Router::<AppState>::new();
|
||||
@@ -32,6 +46,75 @@ pub fn build_router_with_state(state: AppState) -> Router {
|
||||
router
|
||||
}
|
||||
|
||||
pub fn attach_static_frontend(router: Router, static_dir: impl Into<PathBuf>) -> Router {
|
||||
let static_dir = static_dir.into();
|
||||
let index_html = static_dir.join("index.html");
|
||||
router.layer(axum::middleware::from_fn_with_state(
|
||||
FrontendStaticState {
|
||||
static_dir,
|
||||
index_html,
|
||||
},
|
||||
frontend_static_middleware,
|
||||
))
|
||||
}
|
||||
|
||||
async fn frontend_static_middleware(
|
||||
axum::extract::State(frontend): axum::extract::State<FrontendStaticState>,
|
||||
request: Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
let path = request.uri().path().to_string();
|
||||
if !matches!(request.method(), &Method::GET | &Method::HEAD)
|
||||
|| frontend_path_bypasses_static(&path)
|
||||
{
|
||||
return next.run(request).await;
|
||||
}
|
||||
|
||||
if frontend_path_targets_static_asset(&path) {
|
||||
return serve_static_asset(&frontend.static_dir, request).await;
|
||||
}
|
||||
|
||||
serve_frontend_index(&frontend.index_html, request).await
|
||||
}
|
||||
|
||||
fn frontend_path_bypasses_static(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/health" | "/test-connection" | crate::constants::READYZ_PATH
|
||||
) || path.starts_with("/api/")
|
||||
|| path.starts_with("/v1/")
|
||||
|| path.starts_with("/v1beta/")
|
||||
|| path.starts_with("/upload/")
|
||||
|| path.starts_with("/_gateway/")
|
||||
|| path.starts_with("/.well-known/")
|
||||
}
|
||||
|
||||
fn frontend_path_targets_static_asset(path: &str) -> bool {
|
||||
path.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|segment| !segment.is_empty() && segment.contains('.'))
|
||||
}
|
||||
|
||||
async fn serve_static_asset(static_dir: &PathBuf, request: Request) -> Response {
|
||||
match ServeDir::new(static_dir).oneshot(request).await {
|
||||
Ok(response) => response.into_response(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to serve frontend static asset");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_frontend_index(index_html: &PathBuf, request: Request) -> Response {
|
||||
match ServeFile::new(index_html).oneshot(request).await {
|
||||
Ok(response) => response.into_response(),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to serve frontend index");
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn metrics(
|
||||
axum::extract::State(state): axum::extract::State<AppState>,
|
||||
) -> impl axum::response::IntoResponse {
|
||||
|
||||
@@ -4,7 +4,8 @@ use aether_data_contracts::repository::candidates::StoredRequestCandidate;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use aether_scheduler_core::{
|
||||
auth_api_key_concurrency_limit_reached, build_provider_concurrent_limit_map,
|
||||
candidate_is_selectable_with_runtime_state, SchedulerAffinityTarget,
|
||||
candidate_is_selectable_with_runtime_state, CandidateRuntimeSelectabilityInput,
|
||||
SchedulerAffinityTarget,
|
||||
};
|
||||
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
@@ -87,16 +88,16 @@ pub(super) fn is_candidate_selectable(
|
||||
.copied()
|
||||
.flatten();
|
||||
|
||||
candidate_is_selectable_with_runtime_state(
|
||||
candidate_is_selectable_with_runtime_state(CandidateRuntimeSelectabilityInput {
|
||||
candidate,
|
||||
&snapshot.recent_candidates,
|
||||
&snapshot.provider_concurrent_limits,
|
||||
&snapshot.provider_key_rpm_states,
|
||||
recent_candidates: &snapshot.recent_candidates,
|
||||
provider_concurrent_limits: &snapshot.provider_concurrent_limits,
|
||||
provider_key_rpm_states: &snapshot.provider_key_rpm_states,
|
||||
now_unix_secs,
|
||||
cached_affinity_target,
|
||||
provider_quota_blocks_requests,
|
||||
rpm_reset_at,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn read_provider_concurrent_limits(
|
||||
|
||||
@@ -160,7 +160,6 @@ impl AppState {
|
||||
page.items
|
||||
.into_iter()
|
||||
.map(stored_admin_payment_callback_to_gateway)
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
page.total,
|
||||
)))
|
||||
|
||||
@@ -1091,7 +1091,7 @@ fn admin_provider_write_uses_specific_local_owners() {
|
||||
] {
|
||||
assert!(
|
||||
endpoint_keys_mutations.contains(pattern),
|
||||
"handlers/admin/provider/endpoint_keys/mutations/mod.rs should expose explicit mutation owner {pattern}"
|
||||
"handlers/admin/provider/endpoint_keys/mutations/mod.rs should expose explicit mutation owner {pattern}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ fn usage_runtime_paths_depend_on_shared_crates_not_app_runtime_shims() {
|
||||
);
|
||||
}
|
||||
|
||||
for path in ["apps/aether-gateway/src/async_task/runtime.rs"] {
|
||||
{
|
||||
let path = "apps/aether-gateway/src/async_task/runtime.rs";
|
||||
let source = read_workspace_file(path);
|
||||
assert!(
|
||||
source.contains("aether_billing"),
|
||||
|
||||
@@ -116,6 +116,90 @@ async fn gateway_handles_admin_provider_keys_locally_with_trusted_admin_principa
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_admin_provider_keys_prefers_upstream_plan_type_over_auth_config() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/endpoints/providers/provider-codex/keys",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let mut key = sample_key(
|
||||
"key-codex-oauth",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
"updated_at": 1_775_553_285u64
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/endpoints/providers/provider-codex/keys?skip=0&limit=50"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let items = payload.as_array().expect("payload should be an array");
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["oauth_plan_type"], "plus");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_creates_admin_provider_key_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -92,6 +92,9 @@ async fn gateway_handles_admin_global_models_locally_with_trusted_admin_principa
|
||||
payload["models"].as_array().expect("models array")[0]["name"],
|
||||
"gpt-4.1"
|
||||
);
|
||||
assert_eq!(payload["models"][0]["provider_count"], 1);
|
||||
assert_eq!(payload["models"][0]["active_provider_count"], 1);
|
||||
assert_eq!(payload["models"][0]["usage_count"], 0);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -311,6 +314,9 @@ async fn gateway_handles_admin_global_model_detail_locally_with_trusted_admin_pr
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["id"], "global-gpt-5");
|
||||
assert_eq!(payload["provider_count"], 1);
|
||||
assert_eq!(payload["active_provider_count"], 1);
|
||||
assert_eq!(payload["usage_count"], 0);
|
||||
assert_eq!(payload["total_models"], 1);
|
||||
assert_eq!(payload["total_providers"], 1);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_crypto::{encrypt_python_fernet_plaintext, DEVELOPMENT_ENCRYPTION_KEY};
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::body::{to_bytes, Body, Bytes};
|
||||
use axum::routing::{any, get, post};
|
||||
use axum::{extract::Request, Router};
|
||||
use http::{HeaderMap, HeaderValue, StatusCode};
|
||||
@@ -405,6 +405,68 @@ async fn gateway_handles_admin_pool_trailing_slash_routes_locally_with_trusted_a
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_pool_list_includes_usage_totals_and_nullable_lru_score() {
|
||||
let provider = sample_provider("provider-openai", "openai", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
let mut key = sample_key(
|
||||
"key-openai-usage",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-usage",
|
||||
);
|
||||
key.name = "usage key".to_string();
|
||||
key.request_count = Some(1566);
|
||||
key.total_tokens = 187_327_321;
|
||||
key.total_cost_usd = 93.1319297;
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
));
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-openai/keys?page=1&page_size=50&status=all",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0]["request_count"], json!(1566));
|
||||
assert_eq!(keys[0]["total_tokens"], json!(187_327_321u64));
|
||||
assert_eq!(keys[0]["total_cost_usd"], json!("93.13192970"));
|
||||
assert!(keys[0]["lru_score"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_pool_list_keys_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
@@ -563,6 +625,495 @@ async fn gateway_handles_admin_pool_list_keys_locally_with_trusted_admin_princip
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_pool_list_keys_with_quota_compatibility_fields() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/pool/provider-antigravity/keys",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let mut provider = sample_provider("provider-antigravity", "antigravity", 10)
|
||||
.with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "antigravity".to_string();
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-antigravity-a",
|
||||
"provider-antigravity",
|
||||
"gemini:chat",
|
||||
"sk-antigravity",
|
||||
);
|
||||
key.name = "quota-key".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.expires_at_unix_secs = Some(1_775_556_730);
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
r#"{"plan_type":"pro","account_id":"acct-antigravity-1","account_name":"quota-user","account_user_id":"quota-user-1","organizations":[{"id":"org-1","name":"Org One"}]}"#,
|
||||
)
|
||||
.expect("auth config ciphertext should build"),
|
||||
);
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {
|
||||
"code": "expired",
|
||||
"label": "已过期",
|
||||
"reason": "Token 已过期,请重新授权",
|
||||
"expires_at": 1775556730u64,
|
||||
"invalid_at": null,
|
||||
"source": "expires_at",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false
|
||||
},
|
||||
"account": {
|
||||
"code": "ok",
|
||||
"label": null,
|
||||
"reason": null,
|
||||
"blocked": false,
|
||||
"source": null,
|
||||
"recoverable": false
|
||||
},
|
||||
"quota": {
|
||||
"code": "ok",
|
||||
"label": null,
|
||||
"reason": null,
|
||||
"exhausted": false,
|
||||
"usage_ratio": 0.0,
|
||||
"updated_at": 1775553285u64,
|
||||
"reset_seconds": null,
|
||||
"plan_type": null
|
||||
}
|
||||
}));
|
||||
key.upstream_metadata = Some(json!({
|
||||
"antigravity": {
|
||||
"updated_at": 1775553285u64,
|
||||
"quota_by_model": {
|
||||
"gemini-2.5-flash": { "used_percent": 0.0 },
|
||||
"gemini-2.5-pro": { "used_percent": 0.0 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
|
||||
let (_upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(format!(
|
||||
"{gateway_url}/api/admin/pool/provider-antigravity/keys?page=1&page_size=10&status=all"
|
||||
))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0]["account_quota"], json!("最低剩余 100.0% (2 模型)"));
|
||||
assert_eq!(keys[0]["quota_updated_at"], json!(1775553285u64));
|
||||
assert_eq!(keys[0]["oauth_expires_at"], json!(1775556730u64));
|
||||
assert_eq!(keys[0]["oauth_plan_type"], json!("pro"));
|
||||
assert_eq!(keys[0]["oauth_account_id"], json!("acct-antigravity-1"));
|
||||
assert_eq!(keys[0]["oauth_account_name"], json!("quota-user"));
|
||||
assert_eq!(keys[0]["oauth_account_user_id"], json!("quota-user-1"));
|
||||
assert_eq!(keys[0]["oauth_organizations"][0]["id"], json!("org-1"));
|
||||
assert_eq!(keys[0]["account_status_code"], json!("ok"));
|
||||
assert_eq!(keys[0]["account_status_blocked"], json!(false));
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_includes_pool_quota_and_compat_fields_in_list_keys_response() {
|
||||
let mut provider = sample_provider("provider-antigravity", "antigravity", 10)
|
||||
.with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "antigravity".to_string();
|
||||
let mut key = sample_key(
|
||||
"key-antigravity-oauth",
|
||||
"provider-antigravity",
|
||||
"gemini:chat",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.name = "quota key".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.expires_at_unix_secs = Some(1_775_556_730);
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "pro",
|
||||
"account_id": "acct-demo-001",
|
||||
"account_name": "Demo Account",
|
||||
"account_user_id": "user-demo-001",
|
||||
"organizations": [],
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.upstream_metadata = Some(json!({
|
||||
"antigravity": {
|
||||
"updated_at": 1_775_553_285u64,
|
||||
"quota_by_model": {
|
||||
"gemini-2.5-pro": { "used_percent": 0 },
|
||||
"gemini-2.5-flash": { "used_percent": 0 }
|
||||
}
|
||||
}
|
||||
}));
|
||||
key.status_snapshot = Some(json!({
|
||||
"oauth": {
|
||||
"code": "expired",
|
||||
"label": "已过期",
|
||||
"reason": "Token 已过期,请重新授权",
|
||||
"expires_at": 1_775_556_730u64,
|
||||
"invalid_at": serde_json::Value::Null,
|
||||
"source": "expires_at",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false
|
||||
},
|
||||
"account": {
|
||||
"code": "ok",
|
||||
"label": serde_json::Value::Null,
|
||||
"reason": serde_json::Value::Null,
|
||||
"blocked": false,
|
||||
"source": serde_json::Value::Null,
|
||||
"recoverable": false
|
||||
},
|
||||
"quota": {
|
||||
"code": "ok",
|
||||
"label": serde_json::Value::Null,
|
||||
"reason": serde_json::Value::Null,
|
||||
"exhausted": false,
|
||||
"usage_ratio": 0.0,
|
||||
"updated_at": 1_775_553_285u64,
|
||||
"reset_seconds": serde_json::Value::Null,
|
||||
"plan_type": serde_json::Value::Null
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-antigravity/keys?page=1&page_size=50&status=all",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0]["account_quota"], "最低剩余 100.0% (2 模型)");
|
||||
assert_eq!(keys[0]["quota_updated_at"], json!(1_775_553_285u64));
|
||||
assert_eq!(keys[0]["oauth_expires_at"], json!(1_775_556_730u64));
|
||||
assert_eq!(keys[0]["oauth_plan_type"], "pro");
|
||||
assert_eq!(keys[0]["oauth_account_id"], "acct-demo-001");
|
||||
assert_eq!(keys[0]["oauth_account_name"], "Demo Account");
|
||||
assert_eq!(keys[0]["oauth_account_user_id"], "user-demo-001");
|
||||
assert_eq!(keys[0]["oauth_organizations"], json!([]));
|
||||
assert_eq!(keys[0]["account_status_code"], "ok");
|
||||
assert_eq!(keys[0]["account_status_blocked"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_formats_codex_quota_countdown_from_reset_after_seconds() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "codex".to_string();
|
||||
|
||||
let mut key = sample_key(
|
||||
"key-codex-oauth",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.name = "codex quota key".to_string();
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
"updated_at": 1_775_553_285u64,
|
||||
"primary_used_percent": 10.0,
|
||||
"primary_reset_after_seconds": 266_400,
|
||||
"secondary_used_percent": 33.0,
|
||||
"secondary_reset_after_seconds": 13_800
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
));
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(
|
||||
keys[0]["account_quota"],
|
||||
"周剩余 90.0% (3天2小时后重置) | 5H剩余 67.0% (3小时50分钟后重置)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_pool_prefers_upstream_plan_type_over_auth_config() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let mut key = sample_key(
|
||||
"key-codex-precedence",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
"updated_at": 1_775_553_285u64
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::GET,
|
||||
"/api/admin/pool/provider-codex/keys?page=1&page_size=50&status=all",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
let keys = payload["keys"].as_array().expect("keys should be array");
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(keys[0]["oauth_plan_type"], "plus");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_pool_plan_free_selector_prefers_upstream_plan_type() {
|
||||
let mut provider = sample_provider("provider-codex", "codex", 10).with_transport_fields(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(json!({
|
||||
"pool_advanced": {
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
);
|
||||
provider.provider_type = "codex".to_string();
|
||||
let mut key = sample_key(
|
||||
"key-codex-selector",
|
||||
"provider-codex",
|
||||
"openai:cli",
|
||||
"oauth-placeholder",
|
||||
);
|
||||
key.auth_type = "oauth".to_string();
|
||||
key.encrypted_auth_config = Some(
|
||||
encrypt_python_fernet_plaintext(
|
||||
DEVELOPMENT_ENCRYPTION_KEY,
|
||||
&json!({
|
||||
"plan_type": "free",
|
||||
"account_id": "acct-codex-legacy"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("auth config should encrypt"),
|
||||
);
|
||||
key.upstream_metadata = Some(json!({
|
||||
"codex": {
|
||||
"plan_type": "plus",
|
||||
"updated_at": 1_775_553_285u64
|
||||
}
|
||||
}));
|
||||
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
Vec::new(),
|
||||
vec![key],
|
||||
));
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(provider_catalog_repository)
|
||||
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
|
||||
);
|
||||
|
||||
let response = local_admin_pool_response(
|
||||
&state,
|
||||
http::Method::POST,
|
||||
"/api/admin/pool/provider-codex/keys/resolve-selection",
|
||||
Some(json!({
|
||||
"quick_selectors": ["plan_free"]
|
||||
})),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body should read"),
|
||||
)
|
||||
.expect("json body should parse");
|
||||
assert_eq!(payload["total"], json!(0));
|
||||
assert_eq!(
|
||||
payload["items"]
|
||||
.as_array()
|
||||
.expect("items should be array")
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_pool_resolve_selection_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
|
||||
use aether_contracts::ExecutionPlan;
|
||||
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint;
|
||||
use axum::body::Body;
|
||||
use axum::routing::any;
|
||||
use axum::{extract::Request, Router};
|
||||
use axum::{extract::Request, Json, Router};
|
||||
use http::StatusCode;
|
||||
use serde_json::json;
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, sample_admin_global_model, sample_admin_provider_model, sample_key,
|
||||
build_router_with_state, build_state_with_execution_runtime_override, sample_key,
|
||||
sample_provider, start_server, AppState,
|
||||
};
|
||||
use crate::constants::{
|
||||
@@ -63,22 +64,48 @@ async fn assert_admin_provider_query_route(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_query_models_locally_with_trusted_admin_principal() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/api/admin/provider-query/models",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async fn gateway_handles_admin_provider_query_models_fetches_upstream_for_selected_key() {
|
||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("unexpected upstream hit"))
|
||||
*execution_runtime_hits_inner
|
||||
.lock()
|
||||
.expect("mutex should lock") += 1;
|
||||
assert_eq!(plan.url, "https://api.openai.example/v1/models");
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer sk-test")
|
||||
);
|
||||
Json(json!({
|
||||
"request_id": "req-provider-query-selected",
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": {
|
||||
"data": [{
|
||||
"id": "LLM-Research/Llama-4-Maverick-17B-128E-Instruct",
|
||||
"object": "",
|
||||
"owned_by": "system",
|
||||
"created": 1732517497u64
|
||||
}]
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-openai", "OpenAI", 10);
|
||||
provider.provider_type = "openai".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider("provider-openai", "OpenAI", 10)],
|
||||
vec![provider],
|
||||
vec![StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-openai-chat".to_string(),
|
||||
"provider-openai".to_string(),
|
||||
@@ -89,7 +116,7 @@ async fn gateway_handles_admin_provider_query_models_locally_with_trusted_admin_
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.com/v1".to_string(),
|
||||
"https://api.openai.example".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -99,67 +126,20 @@ async fn gateway_handles_admin_provider_query_models_locally_with_trusted_admin_
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![
|
||||
{
|
||||
let mut key = sample_key(
|
||||
"key-openai-allowed",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test",
|
||||
);
|
||||
key.allowed_models = Some(json!(["gpt-5"]));
|
||||
key
|
||||
},
|
||||
sample_key(
|
||||
"key-openai-all",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-2",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
"key-openai-selected",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test",
|
||||
)],
|
||||
));
|
||||
let global_model_repository = Arc::new(
|
||||
InMemoryGlobalModelReadRepository::seed(Vec::new())
|
||||
.with_admin_global_models(vec![
|
||||
sample_admin_global_model("global-gpt-5", "gpt-5", "GPT 5"),
|
||||
sample_admin_global_model("global-gpt-4.1", "gpt-4.1", "GPT 4.1"),
|
||||
])
|
||||
.with_admin_provider_models(vec![
|
||||
{
|
||||
let mut model = sample_admin_provider_model(
|
||||
"provider-model-gpt-5",
|
||||
"provider-openai",
|
||||
"global-gpt-5",
|
||||
"gpt-5",
|
||||
);
|
||||
model.global_model_name = Some("gpt-5".to_string());
|
||||
model.global_model_display_name = Some("GPT 5".to_string());
|
||||
model
|
||||
},
|
||||
{
|
||||
let mut model = sample_admin_provider_model(
|
||||
"provider-model-gpt-4.1",
|
||||
"provider-openai",
|
||||
"global-gpt-4.1",
|
||||
"gpt-4.1",
|
||||
);
|
||||
model.global_model_name = Some("gpt-4.1".to_string());
|
||||
model.global_model_display_name = Some("GPT 4.1".to_string());
|
||||
model
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
GatewayDataState::with_provider_catalog_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
)
|
||||
.with_global_model_repository_for_tests(global_model_repository),
|
||||
),
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
@@ -171,7 +151,7 @@ async fn gateway_handles_admin_provider_query_models_locally_with_trusted_admin_
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"provider_id": "provider-openai",
|
||||
"api_key_id": "key-openai-allowed"
|
||||
"api_key_id": "key-openai-selected"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -184,33 +164,167 @@ async fn gateway_handles_admin_provider_query_models_locally_with_trusted_admin_
|
||||
assert_eq!(payload["provider"]["name"], "OpenAI");
|
||||
assert_eq!(payload["provider"]["display_name"], "OpenAI");
|
||||
assert_eq!(payload["data"]["error"], serde_json::Value::Null);
|
||||
assert_eq!(payload["data"]["from_cache"], json!(true));
|
||||
assert_eq!(payload["data"]["from_cache"], json!(false));
|
||||
assert_eq!(payload["data"]["keys_total"], serde_json::Value::Null);
|
||||
let models = payload["data"]["models"]
|
||||
.as_array()
|
||||
.expect("models should be an array");
|
||||
assert_eq!(models.len(), 1);
|
||||
assert_eq!(
|
||||
models[0]["id"],
|
||||
json!("LLM-Research/Llama-4-Maverick-17B-128E-Instruct")
|
||||
);
|
||||
assert_eq!(models[0]["owned_by"], json!("system"));
|
||||
assert_eq!(models[0]["api_formats"], json!(["openai:chat"]));
|
||||
assert_eq!(
|
||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||
1
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_admin_provider_query_models_aggregating_active_keys() {
|
||||
let execution_runtime_hits = Arc::new(Mutex::new(0usize));
|
||||
let execution_runtime_hits_clone = Arc::clone(&execution_runtime_hits);
|
||||
let execution_runtime = Router::new().route(
|
||||
"/v1/execute/sync",
|
||||
any(move |Json(plan): Json<ExecutionPlan>| {
|
||||
let execution_runtime_hits_inner = Arc::clone(&execution_runtime_hits_clone);
|
||||
async move {
|
||||
*execution_runtime_hits_inner
|
||||
.lock()
|
||||
.expect("mutex should lock") += 1;
|
||||
assert_eq!(plan.url, "https://api.openai.example/v1/models");
|
||||
let auth = plan
|
||||
.headers
|
||||
.get("authorization")
|
||||
.map(String::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let body = if auth == "Bearer sk-test-1" {
|
||||
json!({
|
||||
"data": [{
|
||||
"id": "gpt-5",
|
||||
"api_formats": ["openai:chat"],
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"created": 1732517497u64
|
||||
}]
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"data": [{
|
||||
"id": "gpt-4.1",
|
||||
"api_formats": ["openai:chat"],
|
||||
"object": "model",
|
||||
"owned_by": "system",
|
||||
"created": 1732517498u64
|
||||
}]
|
||||
})
|
||||
};
|
||||
Json(json!({
|
||||
"request_id": format!("req-provider-query-{auth}"),
|
||||
"status_code": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": {
|
||||
"json_body": body
|
||||
}
|
||||
}))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let mut provider = sample_provider("provider-openai", "OpenAI", 10);
|
||||
provider.provider_type = "openai".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![provider],
|
||||
vec![StoredProviderCatalogEndpoint::new(
|
||||
"endpoint-openai-chat".to_string(),
|
||||
"provider-openai".to_string(),
|
||||
"openai:chat".to_string(),
|
||||
Some("chat".to_string()),
|
||||
Some("primary".to_string()),
|
||||
true,
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("endpoint transport should build")],
|
||||
vec![
|
||||
sample_key(
|
||||
"key-openai-1",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-1",
|
||||
),
|
||||
sample_key(
|
||||
"key-openai-2",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"sk-test-2",
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
let gateway = build_router_with_state(
|
||||
build_state_with_execution_runtime_override(execution_runtime_url)
|
||||
.with_data_state_for_tests(GatewayDataState::with_provider_transport_reader_for_tests(
|
||||
provider_catalog_repository,
|
||||
DEVELOPMENT_ENCRYPTION_KEY.to_string(),
|
||||
)),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.post(format!("{gateway_url}/api/admin/provider-query/models"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
|
||||
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
|
||||
.json(&json!({
|
||||
"provider_id": "provider-openai"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["success"], json!(true));
|
||||
assert_eq!(payload["data"]["from_cache"], json!(false));
|
||||
assert_eq!(payload["data"]["keys_total"], json!(2));
|
||||
assert_eq!(payload["data"]["keys_cached"], json!(0));
|
||||
assert_eq!(payload["data"]["keys_fetched"], json!(2));
|
||||
let models = payload["data"]["models"]
|
||||
.as_array()
|
||||
.expect("models should be an array");
|
||||
assert_eq!(models.len(), 2);
|
||||
let model_ids: Vec<_> = models
|
||||
let model_ids = models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
(
|
||||
model["id"].as_str().expect("id should be present"),
|
||||
model["display_name"]
|
||||
.as_str()
|
||||
.expect("display_name should be present"),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(model_ids, vec![("gpt-4.1", "GPT 4.1"), ("gpt-5", "GPT 5")]);
|
||||
for model in models {
|
||||
assert_eq!(model["owned_by"], "OpenAI");
|
||||
assert_eq!(model["api_format"], "openai:chat");
|
||||
assert_eq!(model["api_formats"], json!(["openai:chat"]));
|
||||
}
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
.map(|model| model["id"].as_str().expect("id should exist"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(model_ids, vec!["gpt-5", "gpt-4.1"]);
|
||||
assert_eq!(
|
||||
*execution_runtime_hits.lock().expect("mutex should lock"),
|
||||
2
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -351,6 +351,9 @@ pub(super) fn sample_admin_global_model(
|
||||
})),
|
||||
Some(json!(["streaming", "vision"])),
|
||||
Some(json!({"streaming": true, "vision": false, "billing": {"currency": "USD"}})),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(1_711_000_000),
|
||||
Some(1_711_000_100),
|
||||
)
|
||||
|
||||
@@ -174,6 +174,85 @@ async fn gateway_handles_public_openai_models_without_hitting_fallback_probe() {
|
||||
fallback_probe_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_public_openai_models_with_cross_format_candidates_without_hitting_fallback_probe(
|
||||
) {
|
||||
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
||||
let fallback_probe_hits_clone = Arc::clone(&fallback_probe_hits);
|
||||
let fallback_probe = Router::new().route(
|
||||
"/{*path}",
|
||||
any(move |_request: Request| {
|
||||
let fallback_probe_hits_inner = Arc::clone(&fallback_probe_hits_clone);
|
||||
async move {
|
||||
*fallback_probe_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("proxied"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-openai-models-cross-format")),
|
||||
unrestricted_models_snapshot("key-1", "user-1"),
|
||||
)]));
|
||||
let candidate_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
sample_models_candidate_row(
|
||||
"provider-claude",
|
||||
"claude",
|
||||
"claude:chat",
|
||||
"claude-3-7-sonnet",
|
||||
10,
|
||||
),
|
||||
]));
|
||||
|
||||
let (_unused_fallback_probe_url, fallback_probe_handle) = start_server(fallback_probe).await;
|
||||
let gateway = build_router_with_state(
|
||||
AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(
|
||||
crate::data::GatewayDataState::with_minimal_candidate_selection_and_auth_for_tests(
|
||||
candidate_repository,
|
||||
auth_repository,
|
||||
),
|
||||
),
|
||||
);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let list_response = client
|
||||
.get(format!("{gateway_url}/v1/models"))
|
||||
.header("authorization", "Bearer sk-openai-models-cross-format")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(list_response.status(), StatusCode::OK);
|
||||
let list_payload: serde_json::Value =
|
||||
list_response.json().await.expect("json body should parse");
|
||||
assert_eq!(list_payload["object"], "list");
|
||||
assert_eq!(list_payload["data"][0]["id"], "claude-3-7-sonnet");
|
||||
assert_eq!(list_payload["data"][0]["owned_by"], "claude");
|
||||
|
||||
let detail_response = client
|
||||
.get(format!("{gateway_url}/v1/models/claude-3-7-sonnet"))
|
||||
.header("authorization", "Bearer sk-openai-models-cross-format")
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(detail_response.status(), StatusCode::OK);
|
||||
let detail_payload: serde_json::Value = detail_response
|
||||
.json()
|
||||
.await
|
||||
.expect("json body should parse");
|
||||
assert_eq!(detail_payload["id"], "claude-3-7-sonnet");
|
||||
assert_eq!(detail_payload["owned_by"], "claude");
|
||||
|
||||
assert_eq!(*fallback_probe_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
fallback_probe_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_public_claude_models_without_hitting_fallback_probe() {
|
||||
let fallback_probe_hits = Arc::new(Mutex::new(0usize));
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::tests::{
|
||||
any, build_router, start_server, Arc, Body, Mutex, Request, Router, StatusCode, READYZ_PATH,
|
||||
any, attach_static_frontend, build_router, start_server, Arc, Body, Mutex, Request, Router,
|
||||
StatusCode, READYZ_PATH,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -112,64 +116,69 @@ async fn gateway_handles_public_service_health_without_proxying_upstream() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_public_root_without_proxying_upstream() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/{*path}",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("proxied"))
|
||||
}
|
||||
}),
|
||||
);
|
||||
async fn gateway_serves_frontend_routes_and_assets_without_shadowing_public_api() {
|
||||
let unique_suffix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be monotonic enough for tests")
|
||||
.as_nanos();
|
||||
let static_dir =
|
||||
std::env::temp_dir().join(format!("aether-gateway-static-test-{unique_suffix}"));
|
||||
let assets_dir = static_dir.join("assets");
|
||||
fs::create_dir_all(&assets_dir).expect("static assets dir should be created");
|
||||
fs::write(
|
||||
static_dir.join("index.html"),
|
||||
"<!doctype html><html><body>Aether Frontend</body></html>",
|
||||
)
|
||||
.expect("index.html should be written");
|
||||
fs::write(assets_dir.join("app.js"), "console.log('frontend asset');")
|
||||
.expect("asset file should be written");
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router().expect("gateway should build");
|
||||
let gateway =
|
||||
attach_static_frontend(build_router().expect("gateway should build"), &static_dir);
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
let response = client
|
||||
.get(format!("{gateway_url}/"))
|
||||
.send()
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["status"], "running");
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let body = response.text().await.expect("html body should be readable");
|
||||
assert!(content_type.starts_with("text/html"));
|
||||
assert!(body.contains("Aether Frontend"));
|
||||
|
||||
let response = client
|
||||
.get(format!("{gateway_url}/guide"))
|
||||
.send()
|
||||
.await
|
||||
.expect("spa request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.text().await.expect("spa body should be readable");
|
||||
assert!(body.contains("Aether Frontend"));
|
||||
|
||||
let response = client
|
||||
.get(format!("{gateway_url}/assets/app.js"))
|
||||
.send()
|
||||
.await
|
||||
.expect("asset request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
payload["message"],
|
||||
"AI Proxy with Modular Architecture v4.0.0"
|
||||
);
|
||||
assert_eq!(payload["endpoints"]["health"], "/v1/health");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_public_site_info_without_proxying_upstream() {
|
||||
let upstream_hits = Arc::new(Mutex::new(0usize));
|
||||
let upstream_hits_clone = Arc::clone(&upstream_hits);
|
||||
let upstream = Router::new().route(
|
||||
"/{*path}",
|
||||
any(move |_request: Request| {
|
||||
let upstream_hits_inner = Arc::clone(&upstream_hits_clone);
|
||||
async move {
|
||||
*upstream_hits_inner.lock().expect("mutex should lock") += 1;
|
||||
(StatusCode::OK, Body::from("proxied"))
|
||||
}
|
||||
}),
|
||||
response
|
||||
.text()
|
||||
.await
|
||||
.expect("asset body should be readable"),
|
||||
"console.log('frontend asset');"
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let gateway = build_router().expect("gateway should build");
|
||||
let (gateway_url, gateway_handle) = start_server(gateway).await;
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
let response = client
|
||||
.get(format!("{gateway_url}/api/public/site-info"))
|
||||
.send()
|
||||
.await
|
||||
@@ -179,8 +188,7 @@ async fn gateway_handles_public_site_info_without_proxying_upstream() {
|
||||
let payload: serde_json::Value = response.json().await.expect("json body should parse");
|
||||
assert_eq!(payload["site_name"], "Aether");
|
||||
assert_eq!(payload["site_subtitle"], "AI Gateway");
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
upstream_handle.abort();
|
||||
let _ = fs::remove_dir_all(&static_dir);
|
||||
}
|
||||
|
||||
@@ -3871,15 +3871,15 @@ async fn gateway_handles_wallet_balance_locally_without_proxying_upstream() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
|
||||
let now = Utc::now();
|
||||
let auth_now = Utc::now();
|
||||
let usage_now = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(
|
||||
Utc::now()
|
||||
auth_now
|
||||
.date_naive()
|
||||
.and_hms_opt(12, 0, 0)
|
||||
.expect("midday should be valid"),
|
||||
chrono::Utc,
|
||||
);
|
||||
let user = sample_auth_user(now);
|
||||
let user = sample_auth_user(auth_now);
|
||||
let access_token = build_test_auth_token(
|
||||
"access",
|
||||
serde_json::Map::from_iter([
|
||||
@@ -3891,7 +3891,7 @@ async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
|
||||
),
|
||||
("session_id".to_string(), json!("session-wallet-today-1")),
|
||||
]),
|
||||
now + chrono::Duration::hours(1),
|
||||
auth_now + chrono::Duration::hours(1),
|
||||
);
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![
|
||||
sample_user_usage_audit(
|
||||
@@ -3916,13 +3916,13 @@ async fn gateway_handles_wallet_today_cost_locally_without_proxying_upstream() {
|
||||
let (gateway_url, upstream_hits, gateway_handle, upstream_handle) =
|
||||
start_auth_gateway_with_usage_state(
|
||||
user,
|
||||
sample_auth_wallet("user-auth-1", now),
|
||||
sample_auth_wallet("user-auth-1", auth_now),
|
||||
[sample_auth_session(
|
||||
"user-auth-1",
|
||||
"session-wallet-today-1",
|
||||
"device-wallet-today-1",
|
||||
"refresh-token-placeholder",
|
||||
now,
|
||||
auth_now,
|
||||
)],
|
||||
usage_repository,
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ pub(super) use super::async_task::VideoTaskTruthSourceMode;
|
||||
pub(super) use super::constants::*;
|
||||
pub(super) use super::fallback_metrics::{GatewayFallbackMetricKind, GatewayFallbackReason};
|
||||
pub(super) use super::rate_limit::FrontdoorUserRpmConfig;
|
||||
pub(super) use super::router::{build_router, build_router_with_state};
|
||||
pub(super) use super::router::{attach_static_frontend, build_router, build_router_with_state};
|
||||
pub(super) use super::state::{AppState, FrontdoorCorsConfig};
|
||||
pub(super) use super::usage::UsageRuntimeConfig;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::ExecutionError;
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateStatus;
|
||||
use aether_scheduler_core::execution_error_details;
|
||||
use aether_scheduler_core::{execution_error_details, SchedulerRequestCandidateStatusUpdate};
|
||||
use tracing::{debug, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -203,13 +203,15 @@ async fn handle_local_sync_report(state: &AppState, payload: &GatewaySyncReportR
|
||||
record_report_request_candidate_status(
|
||||
state,
|
||||
payload.report_context.as_ref(),
|
||||
status,
|
||||
Some(payload.status_code),
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status,
|
||||
status_code: Some(payload.status_code),
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -223,13 +225,15 @@ async fn handle_local_stream_report(state: &AppState, payload: &GatewayStreamRep
|
||||
record_report_request_candidate_status(
|
||||
state,
|
||||
payload.report_context.as_ref(),
|
||||
RequestCandidateStatus::Success,
|
||||
Some(payload.status_code),
|
||||
None,
|
||||
None,
|
||||
latency_ms,
|
||||
Some(terminal_unix_secs),
|
||||
Some(terminal_unix_secs),
|
||||
SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Success,
|
||||
status_code: Some(payload.status_code),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms,
|
||||
started_at_unix_secs: Some(terminal_unix_secs),
|
||||
finished_at_unix_secs: Some(terminal_unix_secs),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -119,8 +119,11 @@ fn rust_authoritative_service_builds_openai_content_stream_plan_from_direct_vide
|
||||
let LocalVideoTaskContentAction::StreamPlan(plan) = action else {
|
||||
panic!("content action should be stream plan");
|
||||
};
|
||||
assert_eq!(plan.method, "GET");
|
||||
assert_eq!(plan.url, "https://cdn.example.com/ext-video-task-123.mp4");
|
||||
assert_eq!(plan.method.as_str(), "GET");
|
||||
assert_eq!(
|
||||
plan.url.as_str(),
|
||||
"https://cdn.example.com/ext-video-task-123.mp4"
|
||||
);
|
||||
assert!(plan.headers.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ pub use gemini_chat::convert_openai_chat_response_to_gemini_chat;
|
||||
pub use openai_cli::convert_openai_chat_response_to_openai_cli;
|
||||
pub use shared::{
|
||||
build_openai_cli_response, build_openai_cli_response_with_content,
|
||||
build_openai_cli_response_with_reasoning,
|
||||
build_openai_cli_response_with_reasoning, OpenAiCliResponseUsage,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::shared::{build_openai_cli_response_with_content, canonicalize_tool_arguments};
|
||||
use super::shared::{
|
||||
build_openai_cli_response_with_content, canonicalize_tool_arguments, OpenAiCliResponseUsage,
|
||||
};
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_cli(
|
||||
body_json: &Value,
|
||||
@@ -143,8 +145,10 @@ pub fn convert_openai_chat_response_to_openai_cli(
|
||||
message_content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
OpenAiCliResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiCliResponseUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn build_openai_cli_response(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
@@ -24,9 +31,11 @@ pub fn build_openai_cli_response(
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
OpenAiCliResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,9 +45,7 @@ pub fn build_openai_cli_response_with_reasoning(
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
usage: OpenAiCliResponseUsage,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
@@ -55,9 +62,7 @@ pub fn build_openai_cli_response_with_reasoning(
|
||||
content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
usage,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -67,9 +72,7 @@ pub fn build_openai_cli_response_with_content(
|
||||
content: Vec<Value>,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
usage: OpenAiCliResponseUsage,
|
||||
) -> Value {
|
||||
let mut output = Vec::new();
|
||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||
@@ -104,9 +107,9 @@ pub fn build_openai_cli_response_with_content(
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": {
|
||||
"input_tokens": prompt_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
"total_tokens": total_tokens,
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::from_openai_chat::build_openai_cli_response_with_reasoning;
|
||||
use super::super::from_openai_chat::{
|
||||
build_openai_cli_response_with_reasoning, OpenAiCliResponseUsage,
|
||||
};
|
||||
use super::shared::{build_generated_tool_call_id, canonicalize_tool_arguments};
|
||||
|
||||
pub fn convert_claude_cli_response_to_openai_cli(
|
||||
@@ -76,8 +78,10 @@ pub fn convert_claude_cli_response_to_openai_cli(
|
||||
&text,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
OpenAiCliResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::from_openai_chat::build_openai_cli_response_with_content;
|
||||
use super::super::from_openai_chat::{
|
||||
build_openai_cli_response_with_content, OpenAiCliResponseUsage,
|
||||
};
|
||||
use super::shared::{
|
||||
build_generated_tool_call_id, canonicalize_tool_arguments, extract_gemini_image_url,
|
||||
};
|
||||
@@ -99,8 +101,10 @@ pub fn convert_gemini_cli_response_to_openai_cli(
|
||||
message_content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
OpenAiCliResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -187,12 +187,12 @@ impl ClaudeProviderState {
|
||||
tool_state.call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.unwrap_or(tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = block
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.unwrap_or(tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
|
||||
@@ -125,12 +125,12 @@ impl GeminiProviderState {
|
||||
tool_state.call_id = function_call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.call_id.as_str())
|
||||
.unwrap_or(tool_state.call_id.as_str())
|
||||
.to_string();
|
||||
tool_state.name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| tool_state.name.as_str())
|
||||
.unwrap_or(tool_state.name.as_str())
|
||||
.to_string();
|
||||
if !tool_state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
|
||||
@@ -367,12 +367,12 @@ impl OpenAICliProviderState {
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.unwrap_or(state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.unwrap_or(state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
@@ -525,12 +525,12 @@ impl OpenAICliProviderState {
|
||||
.get("call_id")
|
||||
.or_else(|| item.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.call_id.as_str())
|
||||
.unwrap_or(state.call_id.as_str())
|
||||
.to_string();
|
||||
state.name = item
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| state.name.as_str())
|
||||
.unwrap_or(state.name.as_str())
|
||||
.to_string();
|
||||
if !state.started_emitted {
|
||||
out.push(CanonicalStreamFrame {
|
||||
@@ -662,7 +662,7 @@ impl OpenAIChatClientEmitter {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
self.started = true;
|
||||
Ok(encode_json_sse(
|
||||
encode_json_sse(
|
||||
None,
|
||||
&build_openai_chat_role_chunk(
|
||||
self.response_id
|
||||
@@ -670,7 +670,7 @@ impl OpenAIChatClientEmitter {
|
||||
.unwrap_or("chatcmpl-local-stream"),
|
||||
self.model.as_deref().unwrap_or("unknown"),
|
||||
),
|
||||
)?)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, PipelineFinalizeError> {
|
||||
|
||||
@@ -184,6 +184,9 @@ pub struct StoredAdminGlobalModel {
|
||||
pub default_tiered_pricing: Option<Value>,
|
||||
pub supported_capabilities: Option<Value>,
|
||||
pub config: Option<Value>,
|
||||
pub provider_count: u64,
|
||||
pub active_provider_count: u64,
|
||||
pub usage_count: u64,
|
||||
pub created_at_unix_secs: Option<u64>,
|
||||
pub updated_at_unix_secs: Option<u64>,
|
||||
}
|
||||
@@ -199,6 +202,9 @@ impl StoredAdminGlobalModel {
|
||||
default_tiered_pricing: Option<Value>,
|
||||
supported_capabilities: Option<Value>,
|
||||
config: Option<Value>,
|
||||
provider_count: u64,
|
||||
active_provider_count: u64,
|
||||
usage_count: u64,
|
||||
created_at_unix_secs: Option<u64>,
|
||||
updated_at_unix_secs: Option<u64>,
|
||||
) -> Result<Self, crate::DataLayerError> {
|
||||
@@ -227,6 +233,9 @@ impl StoredAdminGlobalModel {
|
||||
default_tiered_pricing,
|
||||
supported_capabilities,
|
||||
config,
|
||||
provider_count,
|
||||
active_provider_count,
|
||||
usage_count,
|
||||
created_at_unix_secs,
|
||||
updated_at_unix_secs,
|
||||
})
|
||||
|
||||
@@ -270,6 +270,8 @@ pub struct StoredProviderCatalogKey {
|
||||
pub utilization_samples: Option<serde_json::Value>,
|
||||
pub last_probe_increase_at_unix_secs: Option<u64>,
|
||||
pub request_count: Option<u32>,
|
||||
pub total_tokens: u64,
|
||||
pub total_cost_usd: f64,
|
||||
pub success_count: Option<u32>,
|
||||
pub error_count: Option<u32>,
|
||||
pub total_response_time_ms: Option<u32>,
|
||||
@@ -340,6 +342,8 @@ impl StoredProviderCatalogKey {
|
||||
utilization_samples: None,
|
||||
last_probe_increase_at_unix_secs: None,
|
||||
request_count: None,
|
||||
total_tokens: 0,
|
||||
total_cost_usd: 0.0,
|
||||
success_count: None,
|
||||
error_count: None,
|
||||
total_response_time_ms: None,
|
||||
@@ -425,6 +429,16 @@ impl StoredProviderCatalogKey {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_usage_totals(mut self, total_tokens: u64, total_cost_usd: f64) -> Self {
|
||||
self.total_tokens = total_tokens;
|
||||
self.total_cost_usd = if total_cost_usd.is_finite() {
|
||||
total_cost_usd
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_health_fields(
|
||||
mut self,
|
||||
health_by_format: Option<serde_json::Value>,
|
||||
|
||||
@@ -85,13 +85,13 @@ impl RedisKvRunner {
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
Ok(redis::cmd("SETEX")
|
||||
redis::cmd("SETEX")
|
||||
.arg(&namespaced_key)
|
||||
.arg(resolved_ttl)
|
||||
.arg(value)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?)
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -104,11 +104,11 @@ impl RedisKvRunner {
|
||||
.get_multiplexed_async_connection()
|
||||
.await
|
||||
.map_redis_err()?;
|
||||
Ok(redis::cmd("DEL")
|
||||
redis::cmd("DEL")
|
||||
.arg(&namespaced_key)
|
||||
.query_async(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?)
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -214,10 +214,10 @@ impl RedisStreamRunner {
|
||||
for (key, value) in fields {
|
||||
command.arg(key).arg(value);
|
||||
}
|
||||
Ok(command
|
||||
command
|
||||
.query_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?)
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -323,10 +323,10 @@ impl RedisStreamRunner {
|
||||
for id in ids {
|
||||
command.arg(id);
|
||||
}
|
||||
Ok(command
|
||||
command
|
||||
.query_async::<usize>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?)
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -352,10 +352,10 @@ impl RedisStreamRunner {
|
||||
for id in ids {
|
||||
command.arg(id);
|
||||
}
|
||||
Ok(command
|
||||
command
|
||||
.query_async::<usize>(&mut connection)
|
||||
.await
|
||||
.map_redis_err()?)
|
||||
.map_redis_err()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ impl RequestCandidateWriteRepository for InMemoryRequestCandidateRepository {
|
||||
.filter(|row| row.created_at_unix_secs < created_before_unix_secs)
|
||||
.map(|row| (row.created_at_unix_secs, row.id.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
ids.sort_by(|left, right| left.cmp(right));
|
||||
ids.sort();
|
||||
|
||||
let mut deleted = 0usize;
|
||||
for (_, id) in ids.into_iter().take(limit) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -91,6 +92,35 @@ impl InMemoryGlobalModelReadRepository {
|
||||
.expect("admin global model repository lock") = items.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
fn admin_global_model_provider_counts(&self, global_model_id: &str) -> (u64, u64) {
|
||||
let items = self
|
||||
.admin_provider_model_items
|
||||
.read()
|
||||
.expect("admin provider model repository lock");
|
||||
let provider_count = items
|
||||
.iter()
|
||||
.filter(|item| item.global_model_id == global_model_id)
|
||||
.map(|item| item.provider_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len() as u64;
|
||||
let active_provider_count = items
|
||||
.iter()
|
||||
.filter(|item| item.global_model_id == global_model_id && item.is_active)
|
||||
.map(|item| item.provider_id.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len() as u64;
|
||||
(provider_count, active_provider_count)
|
||||
}
|
||||
|
||||
fn enrich_admin_global_model(&self, item: &StoredAdminGlobalModel) -> StoredAdminGlobalModel {
|
||||
let mut enriched = item.clone();
|
||||
let (provider_count, active_provider_count) =
|
||||
self.admin_global_model_provider_counts(&item.id);
|
||||
enriched.provider_count = provider_count;
|
||||
enriched.active_provider_count = active_provider_count;
|
||||
enriched
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -260,6 +290,7 @@ impl GlobalModelReadRepository for InMemoryGlobalModelReadRepository {
|
||||
.into_iter()
|
||||
.skip(query.offset)
|
||||
.take(query.limit)
|
||||
.map(|item| self.enrich_admin_global_model(&item))
|
||||
.collect();
|
||||
Ok(StoredAdminGlobalModelPage { items, total })
|
||||
}
|
||||
@@ -354,7 +385,7 @@ impl GlobalModelReadRepository for InMemoryGlobalModelReadRepository {
|
||||
Ok(items
|
||||
.iter()
|
||||
.find(|item| item.id == global_model_id)
|
||||
.cloned())
|
||||
.map(|item| self.enrich_admin_global_model(item)))
|
||||
}
|
||||
|
||||
async fn get_admin_global_model_by_name(
|
||||
@@ -365,7 +396,10 @@ impl GlobalModelReadRepository for InMemoryGlobalModelReadRepository {
|
||||
.admin_global_model_items
|
||||
.read()
|
||||
.expect("admin global model repository lock");
|
||||
Ok(items.iter().find(|item| item.name == model_name).cloned())
|
||||
Ok(items
|
||||
.iter()
|
||||
.find(|item| item.name == model_name)
|
||||
.map(|item| self.enrich_admin_global_model(item)))
|
||||
}
|
||||
|
||||
async fn list_admin_provider_models_by_global_model_id(
|
||||
@@ -537,35 +571,40 @@ impl GlobalModelWriteRepository for InMemoryGlobalModelReadRepository {
|
||||
record.default_tiered_pricing.clone(),
|
||||
record.supported_capabilities.clone(),
|
||||
record.config.clone(),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some(1_711_000_000),
|
||||
Some(1_711_000_000),
|
||||
)?;
|
||||
self.admin_global_model_items
|
||||
.write()
|
||||
.expect("admin global model repository lock")
|
||||
.push(stored.clone());
|
||||
Ok(Some(stored))
|
||||
.push(stored);
|
||||
self.get_admin_global_model_by_id(&record.id).await
|
||||
}
|
||||
|
||||
async fn update_admin_global_model(
|
||||
&self,
|
||||
record: &UpdateAdminGlobalModelRecord,
|
||||
) -> Result<Option<StoredAdminGlobalModel>, DataLayerError> {
|
||||
let mut items = self
|
||||
.admin_global_model_items
|
||||
.write()
|
||||
.expect("admin global model repository lock");
|
||||
let Some(existing) = items.iter_mut().find(|item| item.id == record.id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
existing.display_name = record.display_name.clone();
|
||||
existing.is_active = record.is_active;
|
||||
existing.default_price_per_request = record.default_price_per_request;
|
||||
existing.default_tiered_pricing = record.default_tiered_pricing.clone();
|
||||
existing.supported_capabilities = record.supported_capabilities.clone();
|
||||
existing.config = record.config.clone();
|
||||
existing.updated_at_unix_secs = Some(1_711_000_100);
|
||||
Ok(Some(existing.clone()))
|
||||
{
|
||||
let mut items = self
|
||||
.admin_global_model_items
|
||||
.write()
|
||||
.expect("admin global model repository lock");
|
||||
let Some(existing) = items.iter_mut().find(|item| item.id == record.id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
existing.display_name = record.display_name.clone();
|
||||
existing.is_active = record.is_active;
|
||||
existing.default_price_per_request = record.default_price_per_request;
|
||||
existing.default_tiered_pricing = record.default_tiered_pricing.clone();
|
||||
existing.supported_capabilities = record.supported_capabilities.clone();
|
||||
existing.config = record.config.clone();
|
||||
existing.updated_at_unix_secs = Some(1_711_000_100);
|
||||
}
|
||||
self.get_admin_global_model_by_id(&record.id).await
|
||||
}
|
||||
|
||||
async fn delete_admin_global_model(
|
||||
|
||||
@@ -104,22 +104,39 @@ LEFT JOIN global_models gm ON gm.id = m.global_model_id
|
||||
|
||||
const LIST_ADMIN_GLOBAL_MODELS_PREFIX: &str = r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
COALESCE(NULLIF(display_name, ''), name) AS display_name,
|
||||
is_active,
|
||||
CAST(default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
default_tiered_pricing,
|
||||
supported_capabilities,
|
||||
config,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models
|
||||
gm.id,
|
||||
gm.name,
|
||||
COALESCE(NULLIF(gm.display_name, ''), gm.name) AS display_name,
|
||||
gm.is_active,
|
||||
CAST(gm.default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
gm.default_tiered_pricing,
|
||||
gm.supported_capabilities,
|
||||
gm.config,
|
||||
COALESCE(gm_stats.provider_count, 0) AS provider_count,
|
||||
COALESCE(gm_stats.active_provider_count, 0) AS active_provider_count,
|
||||
COALESCE(gm.usage_count, 0)::bigint AS usage_count,
|
||||
EXTRACT(EPOCH FROM gm.created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM gm.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models gm
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
m.global_model_id,
|
||||
COUNT(DISTINCT m.provider_id)::bigint AS provider_count,
|
||||
COUNT(
|
||||
DISTINCT CASE
|
||||
WHEN m.is_active = TRUE AND p.is_active = TRUE THEN m.provider_id
|
||||
ELSE NULL
|
||||
END
|
||||
)::bigint AS active_provider_count
|
||||
FROM models m
|
||||
JOIN providers p ON p.id = m.provider_id
|
||||
GROUP BY m.global_model_id
|
||||
) gm_stats ON gm_stats.global_model_id = gm.id
|
||||
"#;
|
||||
|
||||
const COUNT_ADMIN_GLOBAL_MODELS_PREFIX: &str = r#"
|
||||
SELECT COUNT(id) AS total
|
||||
FROM global_models
|
||||
FROM global_models gm
|
||||
"#;
|
||||
|
||||
const LIST_ACTIVE_GLOBAL_MODEL_IDS_BY_PROVIDER_IDS_PREFIX: &str = r#"
|
||||
@@ -374,19 +391,35 @@ ORDER BY gm.name ASC, m.created_at DESC, m.id ASC
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
COALESCE(NULLIF(display_name, ''), name) AS display_name,
|
||||
is_active,
|
||||
CAST(default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
default_tiered_pricing,
|
||||
supported_capabilities,
|
||||
config
|
||||
,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models
|
||||
WHERE id = $1
|
||||
gm.id,
|
||||
gm.name,
|
||||
COALESCE(NULLIF(gm.display_name, ''), gm.name) AS display_name,
|
||||
gm.is_active,
|
||||
CAST(gm.default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
gm.default_tiered_pricing,
|
||||
gm.supported_capabilities,
|
||||
gm.config,
|
||||
COALESCE(gm_stats.provider_count, 0) AS provider_count,
|
||||
COALESCE(gm_stats.active_provider_count, 0) AS active_provider_count,
|
||||
COALESCE(gm.usage_count, 0)::bigint AS usage_count,
|
||||
EXTRACT(EPOCH FROM gm.created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM gm.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models gm
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
m.global_model_id,
|
||||
COUNT(DISTINCT m.provider_id)::bigint AS provider_count,
|
||||
COUNT(
|
||||
DISTINCT CASE
|
||||
WHEN m.is_active = TRUE AND p.is_active = TRUE THEN m.provider_id
|
||||
ELSE NULL
|
||||
END
|
||||
)::bigint AS active_provider_count
|
||||
FROM models m
|
||||
JOIN providers p ON p.id = m.provider_id
|
||||
GROUP BY m.global_model_id
|
||||
) gm_stats ON gm_stats.global_model_id = gm.id
|
||||
WHERE gm.id = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
@@ -405,19 +438,35 @@ LIMIT 1
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
COALESCE(NULLIF(display_name, ''), name) AS display_name,
|
||||
is_active,
|
||||
CAST(default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
default_tiered_pricing,
|
||||
supported_capabilities,
|
||||
config
|
||||
,
|
||||
EXTRACT(EPOCH FROM created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models
|
||||
WHERE name = $1
|
||||
gm.id,
|
||||
gm.name,
|
||||
COALESCE(NULLIF(gm.display_name, ''), gm.name) AS display_name,
|
||||
gm.is_active,
|
||||
CAST(gm.default_price_per_request AS DOUBLE PRECISION) AS default_price_per_request,
|
||||
gm.default_tiered_pricing,
|
||||
gm.supported_capabilities,
|
||||
gm.config,
|
||||
COALESCE(gm_stats.provider_count, 0) AS provider_count,
|
||||
COALESCE(gm_stats.active_provider_count, 0) AS active_provider_count,
|
||||
COALESCE(gm.usage_count, 0)::bigint AS usage_count,
|
||||
EXTRACT(EPOCH FROM gm.created_at)::bigint AS created_at_unix_secs,
|
||||
EXTRACT(EPOCH FROM gm.updated_at)::bigint AS updated_at_unix_secs
|
||||
FROM global_models gm
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
m.global_model_id,
|
||||
COUNT(DISTINCT m.provider_id)::bigint AS provider_count,
|
||||
COUNT(
|
||||
DISTINCT CASE
|
||||
WHEN m.is_active = TRUE AND p.is_active = TRUE THEN m.provider_id
|
||||
ELSE NULL
|
||||
END
|
||||
)::bigint AS active_provider_count
|
||||
FROM models m
|
||||
JOIN providers p ON p.id = m.provider_id
|
||||
GROUP BY m.global_model_id
|
||||
) gm_stats ON gm_stats.global_model_id = gm.id
|
||||
WHERE gm.name = $1
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
@@ -928,7 +977,7 @@ fn apply_admin_global_model_filters(
|
||||
) {
|
||||
builder.push(" WHERE 1=1");
|
||||
if let Some(is_active) = query.is_active {
|
||||
builder.push(" AND is_active = ").push_bind(is_active);
|
||||
builder.push(" AND gm.is_active = ").push_bind(is_active);
|
||||
}
|
||||
if let Some(search) = query
|
||||
.search
|
||||
@@ -938,9 +987,9 @@ fn apply_admin_global_model_filters(
|
||||
{
|
||||
let pattern = format!("%{search}%");
|
||||
builder
|
||||
.push(" AND (name ILIKE ")
|
||||
.push(" AND (gm.name ILIKE ")
|
||||
.push_bind(pattern.clone())
|
||||
.push(" OR display_name ILIKE ")
|
||||
.push(" OR gm.display_name ILIKE ")
|
||||
.push_bind(pattern)
|
||||
.push(")");
|
||||
}
|
||||
@@ -1062,6 +1111,18 @@ fn map_admin_global_model_row(row: &PgRow) -> Result<StoredAdminGlobalModel, Dat
|
||||
.try_get::<Option<i64>, _>("updated_at_unix_secs")
|
||||
.map_postgres_err()?
|
||||
.map(|value| value.max(0) as u64);
|
||||
let provider_count = row
|
||||
.try_get::<i64, _>("provider_count")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64;
|
||||
let active_provider_count = row
|
||||
.try_get::<i64, _>("active_provider_count")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64;
|
||||
let usage_count = row
|
||||
.try_get::<i64, _>("usage_count")
|
||||
.map_postgres_err()?
|
||||
.max(0) as u64;
|
||||
StoredAdminGlobalModel::new(
|
||||
row.try_get("id").map_postgres_err()?,
|
||||
row.try_get("name").map_postgres_err()?,
|
||||
@@ -1072,6 +1133,9 @@ fn map_admin_global_model_row(row: &PgRow) -> Result<StoredAdminGlobalModel, Dat
|
||||
row.try_get("default_tiered_pricing").map_postgres_err()?,
|
||||
row.try_get("supported_capabilities").map_postgres_err()?,
|
||||
row.try_get("config").map_postgres_err()?,
|
||||
provider_count,
|
||||
active_provider_count,
|
||||
usage_count,
|
||||
created_at_unix_secs,
|
||||
updated_at_unix_secs,
|
||||
)
|
||||
|
||||
@@ -161,6 +161,8 @@ SELECT
|
||||
utilization_samples,
|
||||
EXTRACT(EPOCH FROM last_probe_increase_at)::bigint AS last_probe_increase_at_unix_secs,
|
||||
request_count,
|
||||
total_tokens,
|
||||
CAST(total_cost_usd AS DOUBLE PRECISION) AS total_cost_usd,
|
||||
success_count,
|
||||
error_count,
|
||||
total_response_time_ms,
|
||||
@@ -214,6 +216,8 @@ SELECT
|
||||
utilization_samples,
|
||||
EXTRACT(EPOCH FROM last_probe_increase_at)::bigint AS last_probe_increase_at_unix_secs,
|
||||
request_count,
|
||||
total_tokens,
|
||||
CAST(total_cost_usd AS DOUBLE PRECISION) AS total_cost_usd,
|
||||
success_count,
|
||||
error_count,
|
||||
total_response_time_ms,
|
||||
@@ -503,6 +507,8 @@ SELECT
|
||||
utilization_samples,
|
||||
EXTRACT(EPOCH FROM last_probe_increase_at)::bigint AS last_probe_increase_at_unix_secs,
|
||||
request_count,
|
||||
total_tokens,
|
||||
CAST(total_cost_usd AS DOUBLE PRECISION) AS total_cost_usd,
|
||||
success_count,
|
||||
error_count,
|
||||
total_response_time_ms,
|
||||
@@ -1164,30 +1170,30 @@ INSERT INTO provider_api_keys (
|
||||
ELSE TO_TIMESTAMP($35::double precision)
|
||||
END,
|
||||
COALESCE($36, 0),
|
||||
0,
|
||||
0,
|
||||
COALESCE($37, 0),
|
||||
COALESCE($38, 0),
|
||||
COALESCE($39, 0),
|
||||
COALESCE($40, 0),
|
||||
COALESCE($41, 0),
|
||||
CASE
|
||||
WHEN $40::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($40::double precision)
|
||||
WHEN $42::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($42::double precision)
|
||||
END,
|
||||
CASE
|
||||
WHEN $41::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($41::double precision)
|
||||
WHEN $43::double precision IS NULL THEN NULL
|
||||
ELSE TO_TIMESTAMP($43::double precision)
|
||||
END,
|
||||
$42,
|
||||
$43,
|
||||
$44,
|
||||
$45,
|
||||
$46,
|
||||
$47,
|
||||
CASE
|
||||
WHEN $46::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($46::double precision)
|
||||
WHEN $48::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($48::double precision)
|
||||
END,
|
||||
CASE
|
||||
WHEN $47::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($47::double precision)
|
||||
WHEN $49::double precision IS NULL THEN NOW()
|
||||
ELSE TO_TIMESTAMP($49::double precision)
|
||||
END
|
||||
)
|
||||
"#,
|
||||
@@ -1231,6 +1237,13 @@ INSERT INTO provider_api_keys (
|
||||
.map(|value| value as f64),
|
||||
)
|
||||
.bind(key.request_count.map(|value| value as i32))
|
||||
.bind(Some(i64::try_from(key.total_tokens).map_err(|_| {
|
||||
DataLayerError::InvalidInput(format!(
|
||||
"provider catalog key.total_tokens exceeds i64: {}",
|
||||
key.total_tokens
|
||||
))
|
||||
})?))
|
||||
.bind(key.total_cost_usd)
|
||||
.bind(key.success_count.map(|value| value as i32))
|
||||
.bind(key.error_count.map(|value| value as i32))
|
||||
.bind(key.total_response_time_ms.map(|value| value as i32))
|
||||
@@ -2092,6 +2105,18 @@ fn map_key_row(row: &PgRow) -> Result<StoredProviderCatalogKey, DataLayerError>
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
let total_tokens = row_get::<Option<i64>>(row, "total_tokens")?
|
||||
.unwrap_or(0)
|
||||
.try_into()
|
||||
.map_err(|_| {
|
||||
DataLayerError::UnexpectedValue("invalid provider_api_keys.total_tokens".to_string())
|
||||
})?;
|
||||
let total_cost_usd = row_get::<Option<f64>>(row, "total_cost_usd")?.unwrap_or(0.0);
|
||||
if !total_cost_usd.is_finite() {
|
||||
return Err(DataLayerError::UnexpectedValue(
|
||||
"invalid provider_api_keys.total_cost_usd".to_string(),
|
||||
));
|
||||
}
|
||||
let success_count = row_get::<Option<i32>>(row, "success_count")?
|
||||
.map(|value| {
|
||||
u32::try_from(value).map_err(|_| {
|
||||
@@ -2212,6 +2237,7 @@ fn map_key_row(row: &PgRow) -> Result<StoredProviderCatalogKey, DataLayerError>
|
||||
success_count,
|
||||
)
|
||||
.with_usage_fields(error_count, total_response_time_ms)
|
||||
.with_usage_totals(total_tokens, total_cost_usd)
|
||||
.with_health_fields(
|
||||
row.try_get("health_by_format").ok(),
|
||||
row.try_get("circuit_breaker_by_format").ok(),
|
||||
@@ -2263,4 +2289,15 @@ mod tests {
|
||||
let repository = SqlxProviderCatalogReadRepository::new(pool);
|
||||
let _ = repository.pool();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_queries_include_usage_totals() {
|
||||
for sql in [
|
||||
super::LIST_KEYS_BY_IDS_PREFIX,
|
||||
super::LIST_KEYS_BY_PROVIDER_IDS_PREFIX,
|
||||
] {
|
||||
assert!(sql.contains("total_tokens"));
|
||||
assert!(sql.contains("total_cost_usd"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,6 +459,7 @@ pub struct CreateWalletRechargeOrderInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum CreateWalletRechargeOrderOutcome {
|
||||
Created(StoredAdminPaymentOrder),
|
||||
WalletInactive,
|
||||
@@ -506,6 +507,7 @@ pub struct ProcessPaymentCallbackInput {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum ProcessPaymentCallbackOutcome {
|
||||
DuplicateProcessed {
|
||||
order_id: Option<String>,
|
||||
|
||||
@@ -246,6 +246,12 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
.collect::<BTreeSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let legacy_api_format = object
|
||||
.get("api_format")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let existing_formats = entry
|
||||
.get("api_formats")
|
||||
.and_then(Value::as_array)
|
||||
@@ -259,9 +265,15 @@ pub fn aggregate_models_for_cache(models: &[Value]) -> Vec<Value> {
|
||||
.collect::<BTreeSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let merged_formats = existing_formats
|
||||
let mut merged_formats = existing_formats
|
||||
.union(&api_formats)
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
if let Some(api_format) = legacy_api_format {
|
||||
merged_formats.insert(api_format);
|
||||
}
|
||||
let merged_formats = merged_formats
|
||||
.into_iter()
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>();
|
||||
entry.insert("api_formats".to_string(), Value::Array(merged_formats));
|
||||
@@ -453,6 +465,17 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_models_for_cache_preserves_legacy_api_format_field() {
|
||||
let aggregated = aggregate_models_for_cache(&[json!({
|
||||
"id":"gpt-5",
|
||||
"api_format":"openai:chat"
|
||||
})]);
|
||||
assert_eq!(aggregated.len(), 1);
|
||||
assert_eq!(aggregated[0]["api_formats"], json!(["openai:chat"]));
|
||||
assert!(aggregated[0].get("api_format").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_gemini_models_url_preserves_base_query() {
|
||||
let url =
|
||||
|
||||
@@ -24,6 +24,7 @@ pub use request::{
|
||||
apply_local_body_rules, apply_local_header_rules, body_rules_are_locally_supported,
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
header_rules_are_locally_supported, supports_local_kiro_request_shape,
|
||||
KiroProviderHeadersInput,
|
||||
};
|
||||
pub use url::{
|
||||
build_kiro_generate_assistant_response_url, resolve_kiro_base_url,
|
||||
|
||||
@@ -77,16 +77,32 @@ pub fn build_kiro_provider_request_body(
|
||||
Some(provider_request_body)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct KiroProviderHeadersInput<'a> {
|
||||
pub headers: &'a http::HeaderMap,
|
||||
pub provider_request_body: &'a Value,
|
||||
pub original_request_body: &'a Value,
|
||||
pub header_rules: Option<&'a Value>,
|
||||
pub auth_header: &'a str,
|
||||
pub auth_value: &'a str,
|
||||
pub auth_config: &'a KiroAuthConfig,
|
||||
pub machine_id: &'a str,
|
||||
}
|
||||
|
||||
pub fn build_kiro_provider_headers(
|
||||
headers: &http::HeaderMap,
|
||||
provider_request_body: &Value,
|
||||
original_request_body: &Value,
|
||||
header_rules: Option<&Value>,
|
||||
auth_header: &str,
|
||||
auth_value: &str,
|
||||
auth_config: &KiroAuthConfig,
|
||||
machine_id: &str,
|
||||
input: KiroProviderHeadersInput<'_>,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
let KiroProviderHeadersInput {
|
||||
headers,
|
||||
provider_request_body,
|
||||
original_request_body,
|
||||
header_rules,
|
||||
auth_header,
|
||||
auth_value,
|
||||
auth_config,
|
||||
machine_id,
|
||||
} = input;
|
||||
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, value) in headers {
|
||||
let Ok(value) = value.to_str() else {
|
||||
@@ -133,7 +149,7 @@ mod tests {
|
||||
use super::super::credentials::KiroAuthConfig;
|
||||
use super::{
|
||||
build_kiro_provider_headers, build_kiro_provider_request_body,
|
||||
supports_local_kiro_request_shape,
|
||||
supports_local_kiro_request_shape, KiroProviderHeadersInput,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -221,19 +237,19 @@ mod tests {
|
||||
node_version: None,
|
||||
access_token: Some("cached-token".to_string()),
|
||||
};
|
||||
let headers = build_kiro_provider_headers(
|
||||
&http::HeaderMap::new(),
|
||||
&json!({"conversationState": {}}),
|
||||
&json!({"messages": []}),
|
||||
Some(&json!([
|
||||
let headers = build_kiro_provider_headers(KiroProviderHeadersInput {
|
||||
headers: &http::HeaderMap::new(),
|
||||
provider_request_body: &json!({"conversationState": {}}),
|
||||
original_request_body: &json!({"messages": []}),
|
||||
header_rules: Some(&json!([
|
||||
{"action":"set","key":"accept","value":"text/plain"},
|
||||
{"action":"set","key":"x-endpoint-tag","value":"kiro-local"}
|
||||
])),
|
||||
"authorization",
|
||||
"Bearer cached-token",
|
||||
&auth_config,
|
||||
"machine-123",
|
||||
)
|
||||
auth_header: "authorization",
|
||||
auth_value: "Bearer cached-token",
|
||||
auth_config: &auth_config,
|
||||
machine_id: "machine-123",
|
||||
})
|
||||
.expect("headers should build");
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -153,19 +153,14 @@ pub fn resolve_transport_tls_profile(
|
||||
}
|
||||
|
||||
fn effective_proxy_config(transport: &GatewayProviderTransportSnapshot) -> Option<&Value> {
|
||||
for candidate in [
|
||||
[
|
||||
transport.key.proxy.as_ref(),
|
||||
transport.endpoint.proxy.as_ref(),
|
||||
transport.provider.proxy.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if proxy_enabled(candidate) {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
None
|
||||
.find(|candidate| proxy_enabled(candidate))
|
||||
}
|
||||
|
||||
fn proxy_enabled(value: &Value) -> bool {
|
||||
|
||||
@@ -16,6 +16,7 @@ use super::kiro::{
|
||||
use super::snapshot::GatewayProviderTransportSnapshot;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum LocalResolvedOAuthRequestAuth {
|
||||
#[allow(dead_code)]
|
||||
Header {
|
||||
|
||||
@@ -258,16 +258,32 @@ pub fn reorder_candidates_by_scheduler_health(
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CandidateRuntimeSelectabilityInput<'a> {
|
||||
pub candidate: &'a SchedulerMinimalCandidateSelectionCandidate,
|
||||
pub recent_candidates: &'a [StoredRequestCandidate],
|
||||
pub provider_concurrent_limits: &'a BTreeMap<String, usize>,
|
||||
pub provider_key_rpm_states: &'a BTreeMap<String, StoredProviderCatalogKey>,
|
||||
pub now_unix_secs: u64,
|
||||
pub cached_affinity_target: Option<&'a crate::SchedulerAffinityTarget>,
|
||||
pub provider_quota_blocks_requests: bool,
|
||||
pub rpm_reset_at: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn candidate_is_selectable_with_runtime_state(
|
||||
candidate: &SchedulerMinimalCandidateSelectionCandidate,
|
||||
recent_candidates: &[StoredRequestCandidate],
|
||||
provider_concurrent_limits: &BTreeMap<String, usize>,
|
||||
provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>,
|
||||
now_unix_secs: u64,
|
||||
cached_affinity_target: Option<&crate::SchedulerAffinityTarget>,
|
||||
provider_quota_blocks_requests: bool,
|
||||
rpm_reset_at: Option<u64>,
|
||||
input: CandidateRuntimeSelectabilityInput<'_>,
|
||||
) -> bool {
|
||||
let CandidateRuntimeSelectabilityInput {
|
||||
candidate,
|
||||
recent_candidates,
|
||||
provider_concurrent_limits,
|
||||
provider_key_rpm_states,
|
||||
now_unix_secs,
|
||||
cached_affinity_target,
|
||||
provider_quota_blocks_requests,
|
||||
rpm_reset_at,
|
||||
} = input;
|
||||
|
||||
if provider_quota_blocks_requests {
|
||||
return false;
|
||||
}
|
||||
@@ -375,7 +391,7 @@ mod tests {
|
||||
candidate_is_selectable_with_runtime_state, candidate_supports_required_capability,
|
||||
collect_global_model_names_for_required_capability,
|
||||
collect_selectable_candidates_from_keys, reorder_candidates_by_scheduler_health,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
CandidateRuntimeSelectabilityInput, SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
use crate::SchedulerAuthConstraints;
|
||||
|
||||
@@ -627,14 +643,16 @@ mod tests {
|
||||
let provider_concurrent_limits = BTreeMap::from([("provider-1".to_string(), 1)]);
|
||||
|
||||
assert!(!candidate_is_selectable_with_runtime_state(
|
||||
&sample_candidate("1", None),
|
||||
&recent_candidates,
|
||||
&provider_concurrent_limits,
|
||||
&BTreeMap::new(),
|
||||
100,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
CandidateRuntimeSelectabilityInput {
|
||||
candidate: &sample_candidate("1", None),
|
||||
recent_candidates: &recent_candidates,
|
||||
provider_concurrent_limits: &provider_concurrent_limits,
|
||||
provider_key_rpm_states: &BTreeMap::new(),
|
||||
now_unix_secs: 100,
|
||||
cached_affinity_target: None,
|
||||
provider_quota_blocks_requests: false,
|
||||
rpm_reset_at: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -643,24 +661,28 @@ mod tests {
|
||||
let provider_key_rpm_states = BTreeMap::from([("key-1".to_string(), sample_key("1", 0.0))]);
|
||||
|
||||
assert!(!candidate_is_selectable_with_runtime_state(
|
||||
&sample_candidate("1", None),
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&provider_key_rpm_states,
|
||||
100,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
CandidateRuntimeSelectabilityInput {
|
||||
candidate: &sample_candidate("1", None),
|
||||
recent_candidates: &[],
|
||||
provider_concurrent_limits: &BTreeMap::new(),
|
||||
provider_key_rpm_states: &provider_key_rpm_states,
|
||||
now_unix_secs: 100,
|
||||
cached_affinity_target: None,
|
||||
provider_quota_blocks_requests: false,
|
||||
rpm_reset_at: None,
|
||||
},
|
||||
));
|
||||
assert!(!candidate_is_selectable_with_runtime_state(
|
||||
&sample_candidate("1", None),
|
||||
&[],
|
||||
&BTreeMap::new(),
|
||||
&BTreeMap::new(),
|
||||
100,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
CandidateRuntimeSelectabilityInput {
|
||||
candidate: &sample_candidate("1", None),
|
||||
recent_candidates: &[],
|
||||
provider_concurrent_limits: &BTreeMap::new(),
|
||||
provider_key_rpm_states: &BTreeMap::new(),
|
||||
now_unix_secs: 100,
|
||||
cached_affinity_target: None,
|
||||
provider_quota_blocks_requests: true,
|
||||
rpm_reset_at: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ pub use candidate::{
|
||||
auth_api_key_concurrency_limit_reached, build_minimal_candidate_selection,
|
||||
candidate_is_selectable_with_runtime_state, candidate_supports_required_capability,
|
||||
collect_global_model_names_for_required_capability, collect_selectable_candidates_from_keys,
|
||||
reorder_candidates_by_scheduler_health, SchedulerMinimalCandidateSelectionCandidate,
|
||||
reorder_candidates_by_scheduler_health, CandidateRuntimeSelectabilityInput,
|
||||
SchedulerMinimalCandidateSelectionCandidate,
|
||||
};
|
||||
pub use health::{
|
||||
aggregate_provider_key_health_score, count_recent_active_requests_for_api_key,
|
||||
@@ -40,6 +41,7 @@ pub use request_candidate::{
|
||||
build_report_request_candidate_status_record, execution_error_details,
|
||||
finalize_execution_request_candidate_report_context, is_terminal_candidate_status,
|
||||
parse_request_candidate_report_context, resolve_report_request_candidate_slot,
|
||||
LocalRequestCandidateStatusRecordInput, ReportRequestCandidateStatusRecordInput,
|
||||
SchedulerExecutionRequestCandidateSeed, SchedulerRequestCandidateReportContext,
|
||||
SchedulerResolvedReportRequestCandidateSlot,
|
||||
SchedulerRequestCandidateStatusUpdate, SchedulerResolvedReportRequestCandidateSlot,
|
||||
};
|
||||
|
||||
@@ -90,9 +90,7 @@ pub fn resolve_provider_model_name(
|
||||
}
|
||||
}
|
||||
|
||||
let Some(global_model_mappings) = row.global_model_mappings.as_ref() else {
|
||||
return None;
|
||||
};
|
||||
let global_model_mappings = row.global_model_mappings.as_ref()?;
|
||||
for allowed_model in sorted_allowed_models {
|
||||
for pattern in global_model_mappings {
|
||||
if matches_model_mapping(pattern, &allowed_model) {
|
||||
|
||||
@@ -41,6 +41,31 @@ pub struct SchedulerExecutionRequestCandidateSeed {
|
||||
pub report_context: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SchedulerRequestCandidateStatusUpdate {
|
||||
pub status: RequestCandidateStatus,
|
||||
pub status_code: Option<u16>,
|
||||
pub error_type: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub started_at_unix_secs: Option<u64>,
|
||||
pub finished_at_unix_secs: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRequestCandidateStatusRecordInput<'a> {
|
||||
pub plan: &'a ExecutionPlan,
|
||||
pub report_context: Option<&'a Value>,
|
||||
pub status_update: SchedulerRequestCandidateStatusUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportRequestCandidateStatusRecordInput {
|
||||
pub slot: SchedulerResolvedReportRequestCandidateSlot,
|
||||
pub status_update: SchedulerRequestCandidateStatusUpdate,
|
||||
pub now_unix_secs: u64,
|
||||
}
|
||||
|
||||
pub fn execution_error_details(
|
||||
error: Option<&ExecutionError>,
|
||||
body_json: Option<&Value>,
|
||||
@@ -231,16 +256,23 @@ pub fn build_execution_request_candidate_seed(
|
||||
}
|
||||
|
||||
pub fn build_local_request_candidate_status_record(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
status: RequestCandidateStatus,
|
||||
status_code: Option<u16>,
|
||||
error_type: Option<String>,
|
||||
error_message: Option<String>,
|
||||
latency_ms: Option<u64>,
|
||||
started_at_unix_secs: Option<u64>,
|
||||
finished_at_unix_secs: Option<u64>,
|
||||
input: LocalRequestCandidateStatusRecordInput<'_>,
|
||||
) -> Option<UpsertRequestCandidateRecord> {
|
||||
let LocalRequestCandidateStatusRecordInput {
|
||||
plan,
|
||||
report_context,
|
||||
status_update,
|
||||
} = input;
|
||||
let SchedulerRequestCandidateStatusUpdate {
|
||||
status,
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
started_at_unix_secs,
|
||||
finished_at_unix_secs,
|
||||
} = status_update;
|
||||
|
||||
let candidate_id = plan
|
||||
.candidate_id
|
||||
.as_deref()
|
||||
@@ -278,16 +310,23 @@ pub fn build_local_request_candidate_status_record(
|
||||
}
|
||||
|
||||
pub fn build_report_request_candidate_status_record(
|
||||
slot: SchedulerResolvedReportRequestCandidateSlot,
|
||||
status: RequestCandidateStatus,
|
||||
status_code: Option<u16>,
|
||||
error_type: Option<String>,
|
||||
error_message: Option<String>,
|
||||
latency_ms: Option<u64>,
|
||||
started_at_unix_secs: Option<u64>,
|
||||
finished_at_unix_secs: Option<u64>,
|
||||
now_unix_secs: u64,
|
||||
input: ReportRequestCandidateStatusRecordInput,
|
||||
) -> UpsertRequestCandidateRecord {
|
||||
let ReportRequestCandidateStatusRecordInput {
|
||||
slot,
|
||||
status_update,
|
||||
now_unix_secs,
|
||||
} = input;
|
||||
let SchedulerRequestCandidateStatusUpdate {
|
||||
status,
|
||||
status_code,
|
||||
error_type,
|
||||
error_message,
|
||||
latency_ms,
|
||||
started_at_unix_secs,
|
||||
finished_at_unix_secs,
|
||||
} = status_update;
|
||||
|
||||
let terminal_unix_secs = finished_at_unix_secs.unwrap_or(now_unix_secs);
|
||||
let started_at_unix_secs = started_at_unix_secs
|
||||
.or(slot.started_at_unix_secs)
|
||||
@@ -454,7 +493,8 @@ mod tests {
|
||||
build_report_request_candidate_status_record, execution_error_details,
|
||||
finalize_execution_request_candidate_report_context,
|
||||
parse_request_candidate_report_context, resolve_report_request_candidate_slot,
|
||||
SchedulerResolvedReportRequestCandidateSlot,
|
||||
LocalRequestCandidateStatusRecordInput, ReportRequestCandidateStatusRecordInput,
|
||||
SchedulerRequestCandidateStatusUpdate, SchedulerResolvedReportRequestCandidateSlot,
|
||||
};
|
||||
|
||||
fn sample_candidate(
|
||||
@@ -605,23 +645,26 @@ mod tests {
|
||||
let mut plan = sample_plan();
|
||||
plan.candidate_id = Some("cand-1".to_string());
|
||||
|
||||
let record = build_local_request_candidate_status_record(
|
||||
&plan,
|
||||
Some(&json!({
|
||||
"candidate_index": 1,
|
||||
"retry_index": 2,
|
||||
"user_id": "user-1",
|
||||
"api_key_id": "api-key-1"
|
||||
})),
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(500),
|
||||
Some("Upstream5xx".to_string()),
|
||||
Some("boom".to_string()),
|
||||
Some(42),
|
||||
Some(100),
|
||||
Some(101),
|
||||
)
|
||||
.expect("record should build");
|
||||
let record =
|
||||
build_local_request_candidate_status_record(LocalRequestCandidateStatusRecordInput {
|
||||
plan: &plan,
|
||||
report_context: Some(&json!({
|
||||
"candidate_index": 1,
|
||||
"retry_index": 2,
|
||||
"user_id": "user-1",
|
||||
"api_key_id": "api-key-1"
|
||||
})),
|
||||
status_update: SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Failed,
|
||||
status_code: Some(500),
|
||||
error_type: Some("Upstream5xx".to_string()),
|
||||
error_message: Some("boom".to_string()),
|
||||
latency_ms: Some(42),
|
||||
started_at_unix_secs: Some(100),
|
||||
finished_at_unix_secs: Some(101),
|
||||
},
|
||||
})
|
||||
.expect("record should build");
|
||||
|
||||
assert_eq!(record.id, "cand-1");
|
||||
assert_eq!(record.candidate_index, 1);
|
||||
@@ -632,31 +675,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn builds_report_request_candidate_status_record_with_terminal_timestamps() {
|
||||
let record = build_report_request_candidate_status_record(
|
||||
SchedulerResolvedReportRequestCandidateSlot {
|
||||
id: "cand-1".to_string(),
|
||||
request_id: "req-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("api-key-1".to_string()),
|
||||
candidate_index: 1,
|
||||
retry_index: 0,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
endpoint_id: Some("endpoint-1".to_string()),
|
||||
key_id: Some("key-1".to_string()),
|
||||
extra_data: None,
|
||||
created_at_unix_secs: 10,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
RequestCandidateStatus::Success,
|
||||
Some(200),
|
||||
None,
|
||||
None,
|
||||
Some(12),
|
||||
None,
|
||||
None,
|
||||
123,
|
||||
);
|
||||
let record =
|
||||
build_report_request_candidate_status_record(ReportRequestCandidateStatusRecordInput {
|
||||
slot: SchedulerResolvedReportRequestCandidateSlot {
|
||||
id: "cand-1".to_string(),
|
||||
request_id: "req-1".to_string(),
|
||||
user_id: Some("user-1".to_string()),
|
||||
api_key_id: Some("api-key-1".to_string()),
|
||||
candidate_index: 1,
|
||||
retry_index: 0,
|
||||
provider_id: Some("provider-1".to_string()),
|
||||
endpoint_id: Some("endpoint-1".to_string()),
|
||||
key_id: Some("key-1".to_string()),
|
||||
extra_data: None,
|
||||
created_at_unix_secs: 10,
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
status_update: SchedulerRequestCandidateStatusUpdate {
|
||||
status: RequestCandidateStatus::Success,
|
||||
status_code: Some(200),
|
||||
error_type: None,
|
||||
error_message: None,
|
||||
latency_ms: Some(12),
|
||||
started_at_unix_secs: None,
|
||||
finished_at_unix_secs: None,
|
||||
},
|
||||
now_unix_secs: 123,
|
||||
});
|
||||
|
||||
assert_eq!(record.started_at_unix_secs, Some(123));
|
||||
assert_eq!(record.finished_at_unix_secs, Some(123));
|
||||
|
||||
@@ -58,6 +58,18 @@ pub struct TerminalUsageOutcome {
|
||||
pub audit_payload: Option<Value>,
|
||||
}
|
||||
|
||||
struct TerminalUsageOutcomeBaseInput<'a> {
|
||||
plan: &'a ExecutionPlan,
|
||||
report_context: Option<&'a Value>,
|
||||
terminal_state: UsageTerminalState,
|
||||
status_code: u16,
|
||||
telemetry: Option<&'a ExecutionTelemetry>,
|
||||
provider_response: Option<Value>,
|
||||
client_response: Option<Value>,
|
||||
provider_response_headers: Option<Value>,
|
||||
client_response_headers: Option<Value>,
|
||||
}
|
||||
|
||||
pub fn build_pending_usage_record(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
@@ -132,17 +144,17 @@ pub fn build_sync_terminal_usage_outcome(
|
||||
.clone()
|
||||
.or_else(|| decode_body_for_storage(payload.body_base64.as_deref()));
|
||||
let client_response = payload.client_body_json.clone();
|
||||
build_terminal_usage_outcome_base(
|
||||
build_terminal_usage_outcome_base(TerminalUsageOutcomeBaseInput {
|
||||
plan,
|
||||
report_context,
|
||||
infer_sync_terminal_state(payload, provider_response.as_ref()),
|
||||
payload.status_code,
|
||||
payload.telemetry.as_ref(),
|
||||
terminal_state: infer_sync_terminal_state(payload, provider_response.as_ref()),
|
||||
status_code: payload.status_code,
|
||||
telemetry: payload.telemetry.as_ref(),
|
||||
provider_response,
|
||||
client_response,
|
||||
Some(headers_to_json(&payload.headers)),
|
||||
Some(headers_to_json(&payload.headers)),
|
||||
)
|
||||
provider_response_headers: Some(headers_to_json(&payload.headers)),
|
||||
client_response_headers: Some(headers_to_json(&payload.headers)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_stream_terminal_usage_outcome(
|
||||
@@ -152,17 +164,17 @@ pub fn build_stream_terminal_usage_outcome(
|
||||
) -> TerminalUsageOutcome {
|
||||
let provider_response = decode_body_for_storage(payload.provider_body_base64.as_deref());
|
||||
let client_response = decode_body_for_storage(payload.client_body_base64.as_deref());
|
||||
build_terminal_usage_outcome_base(
|
||||
build_terminal_usage_outcome_base(TerminalUsageOutcomeBaseInput {
|
||||
plan,
|
||||
report_context,
|
||||
infer_stream_terminal_state(payload),
|
||||
payload.status_code,
|
||||
payload.telemetry.as_ref(),
|
||||
terminal_state: infer_stream_terminal_state(payload),
|
||||
status_code: payload.status_code,
|
||||
telemetry: payload.telemetry.as_ref(),
|
||||
provider_response,
|
||||
client_response,
|
||||
Some(headers_to_json(&payload.headers)),
|
||||
Some(headers_to_json(&payload.headers)),
|
||||
)
|
||||
provider_response_headers: Some(headers_to_json(&payload.headers)),
|
||||
client_response_headers: Some(headers_to_json(&payload.headers)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_terminal_usage_event_from_outcome(
|
||||
@@ -239,16 +251,19 @@ pub fn build_terminal_usage_event_from_outcome(
|
||||
}
|
||||
|
||||
fn build_terminal_usage_outcome_base(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
terminal_state: UsageTerminalState,
|
||||
status_code: u16,
|
||||
telemetry: Option<&ExecutionTelemetry>,
|
||||
provider_response: Option<Value>,
|
||||
client_response: Option<Value>,
|
||||
provider_response_headers: Option<Value>,
|
||||
client_response_headers: Option<Value>,
|
||||
input: TerminalUsageOutcomeBaseInput<'_>,
|
||||
) -> TerminalUsageOutcome {
|
||||
let TerminalUsageOutcomeBaseInput {
|
||||
plan,
|
||||
report_context,
|
||||
terminal_state,
|
||||
status_code,
|
||||
telemetry,
|
||||
provider_response,
|
||||
client_response,
|
||||
provider_response_headers,
|
||||
client_response_headers,
|
||||
} = input;
|
||||
let context = report_context.and_then(Value::as_object);
|
||||
let client_contract = context_string(context, "client_contract")
|
||||
.or_else(|| context_string(context, "client_api_format"))
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub fn build_video_follow_up_report_context(
|
||||
request_id: &str,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
task_id: &str,
|
||||
provider_id: &str,
|
||||
endpoint_id: &str,
|
||||
key_id: &str,
|
||||
provider_name: Option<&str>,
|
||||
model_name: Option<&str>,
|
||||
client_api_format: &str,
|
||||
provider_api_format: &str,
|
||||
) -> Value {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct VideoFollowUpReportContextInput<'a> {
|
||||
pub request_id: &'a str,
|
||||
pub user_id: &'a str,
|
||||
pub api_key_id: &'a str,
|
||||
pub task_id: &'a str,
|
||||
pub provider_id: &'a str,
|
||||
pub endpoint_id: &'a str,
|
||||
pub key_id: &'a str,
|
||||
pub provider_name: Option<&'a str>,
|
||||
pub model_name: Option<&'a str>,
|
||||
pub client_api_format: &'a str,
|
||||
pub provider_api_format: &'a str,
|
||||
}
|
||||
|
||||
pub fn build_video_follow_up_report_context(input: VideoFollowUpReportContextInput<'_>) -> Value {
|
||||
let VideoFollowUpReportContextInput {
|
||||
request_id,
|
||||
user_id,
|
||||
api_key_id,
|
||||
task_id,
|
||||
provider_id,
|
||||
endpoint_id,
|
||||
key_id,
|
||||
provider_name,
|
||||
model_name,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
} = input;
|
||||
|
||||
let mut context = Map::new();
|
||||
context.insert(
|
||||
"request_id".to_string(),
|
||||
@@ -84,23 +101,26 @@ pub fn resolve_follow_up_auth(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{build_video_follow_up_report_context, resolve_follow_up_auth};
|
||||
use super::{
|
||||
build_video_follow_up_report_context, resolve_follow_up_auth,
|
||||
VideoFollowUpReportContextInput,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn builds_follow_up_report_context_with_transport_metadata() {
|
||||
let context = build_video_follow_up_report_context(
|
||||
"req_123",
|
||||
"user_123",
|
||||
"key_123",
|
||||
"task_123",
|
||||
"provider_123",
|
||||
"endpoint_123",
|
||||
"transport_key_123",
|
||||
Some("provider-name"),
|
||||
Some("model-name"),
|
||||
"openai:video",
|
||||
"openai:video",
|
||||
);
|
||||
let context = build_video_follow_up_report_context(VideoFollowUpReportContextInput {
|
||||
request_id: "req_123",
|
||||
user_id: "user_123",
|
||||
api_key_id: "key_123",
|
||||
task_id: "task_123",
|
||||
provider_id: "provider_123",
|
||||
endpoint_id: "endpoint_123",
|
||||
key_id: "transport_key_123",
|
||||
provider_name: Some("provider-name"),
|
||||
model_name: Some("model-name"),
|
||||
client_api_format: "openai:video",
|
||||
provider_api_format: "openai:video",
|
||||
});
|
||||
|
||||
assert_eq!(context["request_id"].as_str(), Some("req_123"));
|
||||
assert_eq!(context["provider_id"].as_str(), Some("provider_123"));
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::{
|
||||
build_video_follow_up_report_context, current_unix_timestamp_secs, gemini_metadata_video_url,
|
||||
request_body_string, request_body_u32, resolve_follow_up_auth, GeminiVideoTaskSeed,
|
||||
LocalVideoTaskFollowUpPlan, LocalVideoTaskReadResponse, LocalVideoTaskSnapshot,
|
||||
LocalVideoTaskStatus, DEFAULT_VIDEO_TASK_MAX_POLL_COUNT,
|
||||
LocalVideoTaskStatus, VideoFollowUpReportContextInput, DEFAULT_VIDEO_TASK_MAX_POLL_COUNT,
|
||||
DEFAULT_VIDEO_TASK_POLL_INTERVAL_SECONDS,
|
||||
};
|
||||
|
||||
@@ -233,17 +233,19 @@ impl GeminiVideoTaskSeed {
|
||||
},
|
||||
report_kind: Some("gemini_video_cancel_sync_finalize".to_string()),
|
||||
report_context: Some(build_video_follow_up_report_context(
|
||||
&self.persistence.request_id,
|
||||
&user_id,
|
||||
&api_key_id,
|
||||
&self.local_short_id,
|
||||
&self.transport.provider_id,
|
||||
&self.transport.endpoint_id,
|
||||
&self.transport.key_id,
|
||||
self.transport.provider_name.as_deref(),
|
||||
Some(self.model.as_str()),
|
||||
"gemini:video",
|
||||
"gemini:video",
|
||||
VideoFollowUpReportContextInput {
|
||||
request_id: &self.persistence.request_id,
|
||||
user_id: &user_id,
|
||||
api_key_id: &api_key_id,
|
||||
task_id: &self.local_short_id,
|
||||
provider_id: &self.transport.provider_id,
|
||||
endpoint_id: &self.transport.endpoint_id,
|
||||
key_id: &self.transport.key_id,
|
||||
provider_name: self.transport.provider_name.as_deref(),
|
||||
model_name: Some(self.model.as_str()),
|
||||
client_api_format: "gemini:video",
|
||||
provider_api_format: "gemini:video",
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ mod util;
|
||||
pub use body::{
|
||||
context_text, context_u64, request_body_string, request_body_text, request_body_u32,
|
||||
};
|
||||
pub use follow_up::{build_video_follow_up_report_context, resolve_follow_up_auth};
|
||||
pub use follow_up::{
|
||||
build_video_follow_up_report_context, resolve_follow_up_auth, VideoFollowUpReportContextInput,
|
||||
};
|
||||
pub use gemini::map_gemini_stored_task_to_read_response;
|
||||
pub use openai::map_openai_stored_task_to_read_response;
|
||||
pub use path::{
|
||||
|
||||
@@ -11,7 +11,8 @@ use crate::{
|
||||
parse_video_content_variant, request_body_string, request_body_u32, resolve_follow_up_auth,
|
||||
LocalVideoTaskContentAction, LocalVideoTaskFollowUpPlan, LocalVideoTaskReadResponse,
|
||||
LocalVideoTaskSnapshot, LocalVideoTaskStatus, OpenAiVideoTaskSeed,
|
||||
DEFAULT_VIDEO_TASK_MAX_POLL_COUNT, DEFAULT_VIDEO_TASK_POLL_INTERVAL_SECONDS,
|
||||
VideoFollowUpReportContextInput, DEFAULT_VIDEO_TASK_MAX_POLL_COUNT,
|
||||
DEFAULT_VIDEO_TASK_POLL_INTERVAL_SECONDS,
|
||||
};
|
||||
|
||||
pub fn map_openai_stored_task_to_read_response(
|
||||
@@ -208,34 +209,36 @@ impl OpenAiVideoTaskSeed {
|
||||
)
|
||||
};
|
||||
|
||||
Some(LocalVideoTaskContentAction::StreamPlan(ExecutionPlan {
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: None,
|
||||
provider_name: self.transport.provider_name.clone(),
|
||||
provider_id: self.transport.provider_id.clone(),
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "GET".to_string(),
|
||||
url,
|
||||
headers,
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: None,
|
||||
body_ref: None,
|
||||
Some(LocalVideoTaskContentAction::StreamPlan(Box::new(
|
||||
ExecutionPlan {
|
||||
request_id: trace_id.to_string(),
|
||||
candidate_id: None,
|
||||
provider_name: self.transport.provider_name.clone(),
|
||||
provider_id: self.transport.provider_id.clone(),
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "GET".to_string(),
|
||||
url,
|
||||
headers,
|
||||
content_type: None,
|
||||
content_encoding: None,
|
||||
body: RequestBody {
|
||||
json_body: None,
|
||||
body_bytes_b64: None,
|
||||
body_ref: None,
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:video".to_string(),
|
||||
provider_api_format: "openai:video".to_string(),
|
||||
model_name: self
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| self.transport.model_name.clone()),
|
||||
proxy: self.transport.proxy.clone(),
|
||||
tls_profile: self.transport.tls_profile.clone(),
|
||||
timeouts: self.transport.timeouts.clone(),
|
||||
},
|
||||
stream: true,
|
||||
client_api_format: "openai:video".to_string(),
|
||||
provider_api_format: "openai:video".to_string(),
|
||||
model_name: self
|
||||
.model
|
||||
.clone()
|
||||
.or_else(|| self.transport.model_name.clone()),
|
||||
proxy: self.transport.proxy.clone(),
|
||||
tls_profile: self.transport.tls_profile.clone(),
|
||||
timeouts: self.transport.timeouts.clone(),
|
||||
}))
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn client_body_json(&self) -> Value {
|
||||
@@ -342,17 +345,19 @@ impl OpenAiVideoTaskSeed {
|
||||
},
|
||||
report_kind: Some("openai_video_delete_sync_finalize".to_string()),
|
||||
report_context: Some(build_video_follow_up_report_context(
|
||||
&self.persistence.request_id,
|
||||
&user_id,
|
||||
&api_key_id,
|
||||
&self.local_task_id,
|
||||
&self.transport.provider_id,
|
||||
&self.transport.endpoint_id,
|
||||
&self.transport.key_id,
|
||||
self.transport.provider_name.as_deref(),
|
||||
model_name.as_deref(),
|
||||
"openai:video",
|
||||
"openai:video",
|
||||
VideoFollowUpReportContextInput {
|
||||
request_id: &self.persistence.request_id,
|
||||
user_id: &user_id,
|
||||
api_key_id: &api_key_id,
|
||||
task_id: &self.local_task_id,
|
||||
provider_id: &self.transport.provider_id,
|
||||
endpoint_id: &self.transport.endpoint_id,
|
||||
key_id: &self.transport.key_id,
|
||||
provider_name: self.transport.provider_name.as_deref(),
|
||||
model_name: model_name.as_deref(),
|
||||
client_api_format: "openai:video",
|
||||
provider_api_format: "openai:video",
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -466,17 +471,19 @@ impl OpenAiVideoTaskSeed {
|
||||
},
|
||||
report_kind: Some("openai_video_cancel_sync_finalize".to_string()),
|
||||
report_context: Some(build_video_follow_up_report_context(
|
||||
&self.persistence.request_id,
|
||||
&user_id,
|
||||
&api_key_id,
|
||||
&self.local_task_id,
|
||||
&self.transport.provider_id,
|
||||
&self.transport.endpoint_id,
|
||||
&self.transport.key_id,
|
||||
self.transport.provider_name.as_deref(),
|
||||
model_name.as_deref(),
|
||||
"openai:video",
|
||||
"openai:video",
|
||||
VideoFollowUpReportContextInput {
|
||||
request_id: &self.persistence.request_id,
|
||||
user_id: &user_id,
|
||||
api_key_id: &api_key_id,
|
||||
task_id: &self.local_task_id,
|
||||
provider_id: &self.transport.provider_id,
|
||||
endpoint_id: &self.transport.endpoint_id,
|
||||
key_id: &self.transport.key_id,
|
||||
provider_name: self.transport.provider_name.as_deref(),
|
||||
model_name: model_name.as_deref(),
|
||||
client_api_format: "openai:video",
|
||||
provider_api_format: "openai:video",
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
@@ -513,19 +520,20 @@ impl OpenAiVideoTaskSeed {
|
||||
.entry("content-type".to_string())
|
||||
.or_insert_with(|| content_type.clone());
|
||||
|
||||
let mut report_context = build_video_follow_up_report_context(
|
||||
&self.persistence.request_id,
|
||||
&user_id,
|
||||
&api_key_id,
|
||||
&self.local_task_id,
|
||||
&self.transport.provider_id,
|
||||
&self.transport.endpoint_id,
|
||||
&self.transport.key_id,
|
||||
self.transport.provider_name.as_deref(),
|
||||
model_name.as_deref(),
|
||||
"openai:video",
|
||||
"openai:video",
|
||||
);
|
||||
let mut report_context =
|
||||
build_video_follow_up_report_context(VideoFollowUpReportContextInput {
|
||||
request_id: &self.persistence.request_id,
|
||||
user_id: &user_id,
|
||||
api_key_id: &api_key_id,
|
||||
task_id: &self.local_task_id,
|
||||
provider_id: &self.transport.provider_id,
|
||||
endpoint_id: &self.transport.endpoint_id,
|
||||
key_id: &self.transport.key_id,
|
||||
provider_name: self.transport.provider_name.as_deref(),
|
||||
model_name: model_name.as_deref(),
|
||||
client_api_format: "openai:video",
|
||||
provider_api_format: "openai:video",
|
||||
});
|
||||
if let Some(report_context_object) = report_context.as_object_mut() {
|
||||
report_context_object.insert("original_request_body".to_string(), body_json.clone());
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub struct LocalVideoTaskReadRefreshPlan {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LocalVideoTaskContentAction {
|
||||
Immediate { status_code: u16, body_json: Value },
|
||||
StreamPlan(ExecutionPlan),
|
||||
StreamPlan(Box<ExecutionPlan>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<slot name="header">
|
||||
<div
|
||||
v-if="title"
|
||||
class="border-b border-border px-6 py-4"
|
||||
class="border-b border-border px-4 py-4 sm:px-6"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
@@ -73,14 +73,14 @@
|
||||
</slot>
|
||||
|
||||
<!-- 内容区域:可选添加 padding -->
|
||||
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||
<div :class="noPadding ? '' : 'px-4 py-3 sm:px-6'">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer 区域:如果有 footer 插槽,自动添加样式 -->
|
||||
<div
|
||||
v-if="slots.footer"
|
||||
class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3"
|
||||
class="border-t border-border bg-muted/10 px-4 py-4 sm:px-6 flex flex-row-reverse gap-3"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useRoute, useRouter, type LocationQuery, type LocationQueryValue } from 'vue-router'
|
||||
|
||||
type QueryValue = LocationQueryValue | LocationQueryValue[]
|
||||
|
||||
function normalizeQueryValue(value: QueryValue): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
|
||||
}
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
||||
for (const key of keys) {
|
||||
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function useRouteQuery() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function getQueryValue(key: string): string | undefined {
|
||||
return normalizeQueryValue(route.query[key])
|
||||
}
|
||||
|
||||
function patchQuery(patch: Record<string, string | undefined | null>) {
|
||||
const next: LocationQuery = { ...route.query }
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value == null || value.trim() === '') {
|
||||
delete next[key]
|
||||
} else {
|
||||
next[key] = value
|
||||
}
|
||||
}
|
||||
if (queriesEqual(route.query, next)) return
|
||||
void router.replace({ query: next }).catch(() => {})
|
||||
}
|
||||
|
||||
return { route, router, getQueryValue, patchQuery }
|
||||
}
|
||||
@@ -3,224 +3,235 @@
|
||||
:model-value="modelValue"
|
||||
title="账号批量操作"
|
||||
:description="dialogDescription"
|
||||
size="xl"
|
||||
size="3xl"
|
||||
persistent
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<MultiSelect
|
||||
:model-value="activeQuickSelectors"
|
||||
:options="QUICK_SELECT_OPTIONS"
|
||||
placeholder="快捷多选"
|
||||
trigger-class="h-8 w-40"
|
||||
dropdown-min-width="10rem"
|
||||
:disabled="loading || executing"
|
||||
@update:model-value="onQuickSelectChange"
|
||||
/>
|
||||
<Input
|
||||
:model-value="searchText"
|
||||
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
|
||||
class="h-8 flex-1"
|
||||
@update:model-value="(v) => searchText = String(v || '')"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:disabled="loading || executing"
|
||||
@click="loadKeysPage()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-lg border bg-muted/20 px-3 py-2.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium text-foreground">快捷多选</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="loading || executing || !hasActiveFilters"
|
||||
@click="clearFilters"
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeQuickSelectors.length > 0"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<Badge
|
||||
v-for="sel in activeQuickSelectors"
|
||||
:key="sel"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 h-5 cursor-pointer hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="removeQuickSelector(sel)"
|
||||
>
|
||||
{{ QUICK_SELECT_OPTIONS.find(s => s.value === sel)?.label }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="ml-0.5"
|
||||
><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="option in QUICK_SELECT_OPTIONS"
|
||||
:key="option.value"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-2.5 text-[11px]"
|
||||
:class="activeQuickSelectorSet.has(option.value) ? 'border-primary/70 bg-primary/10 text-primary' : ''"
|
||||
:disabled="loading || executing"
|
||||
@click="toggleQuickSelector(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
共 {{ filteredTotal }} 个匹配账号,当前页 {{ pageKeys.length }} 个,已选 {{ selectedCount }} 个
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredTotal === 0 || loading || executing"
|
||||
@update:checked="toggleSelectFiltered"
|
||||
/>
|
||||
<span class="text-muted-foreground">全选筛选结果</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3 rounded-md border bg-background/80 px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
:model-value="searchText"
|
||||
placeholder="搜索账号名 / 套餐 / 额度 / 代理状态"
|
||||
class="h-8 flex-1"
|
||||
@update:model-value="(v) => searchText = String(v || '')"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:disabled="loading || executing"
|
||||
@click="loadKeysPage()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="h-3.5 w-3.5"
|
||||
:class="loading ? 'animate-spin' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[380px] overflow-y-auto rounded-lg border">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="pageKeys.length === 0"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
无匹配账号
|
||||
</div>
|
||||
<label
|
||||
v-for="key in pageKeys"
|
||||
:key="key.key_id"
|
||||
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
|
||||
:disabled="executing || selectAllFiltered"
|
||||
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
|
||||
<Badge
|
||||
v-if="getStatusBadgeLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getStatusBadgeTitle(key)"
|
||||
>{{ getStatusBadgeLabel(key) }}</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ key.oauth_plan_type }}</Badge>
|
||||
<Badge
|
||||
v-if="getOAuthOrgBadge(key)"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getOAuthOrgBadge(key)?.title"
|
||||
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
|
||||
<div class="flex flex-col gap-2 text-xs lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="text-muted-foreground">
|
||||
共 {{ filteredTotal }} 个匹配账号,当前页 {{ pageKeys.length }} 个,已选 {{ selectedCount }} 个
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||
<span
|
||||
v-if="key.last_used_at"
|
||||
class="ml-auto shrink-0"
|
||||
>{{ formatRelativeTime(key.last_used_at) }}</span>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<div class="mr-1 flex items-center gap-2">
|
||||
<Checkbox
|
||||
:checked="isAllFilteredSelected"
|
||||
:indeterminate="isPartiallyFilteredSelected"
|
||||
:disabled="filteredTotal === 0 || loading || executing"
|
||||
@update:checked="toggleSelectFiltered"
|
||||
/>
|
||||
<span class="text-muted-foreground">全选筛选结果</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="pageKeys.length === 0 || loading || executing || selectAllFiltered"
|
||||
@click="toggleSelectCurrentPage"
|
||||
>
|
||||
{{ isCurrentPageFullySelected ? '取消本页全选' : '本页全选' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-7 px-2 text-[11px]"
|
||||
:disabled="!canClearSelection || loading || executing"
|
||||
@click="clearSelection"
|
||||
>
|
||||
清空选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||
>
|
||||
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<ChevronsRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Select v-model="selectedAction">
|
||||
<SelectTrigger class="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="选择动作" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_19rem]">
|
||||
<div class="min-w-0 space-y-3">
|
||||
<div class="max-h-[420px] overflow-y-auto rounded-lg border">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
正在加载账号列表...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="pageKeys.length === 0"
|
||||
class="py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
无匹配账号
|
||||
</div>
|
||||
<label
|
||||
v-for="key in pageKeys"
|
||||
:key="key.key_id"
|
||||
class="flex items-center gap-2.5 px-3 py-2 border-b last:border-b-0 cursor-pointer hover:bg-muted/30"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="selectAllFiltered || selectedIdSet.has(key.key_id)"
|
||||
:disabled="executing || selectAllFiltered"
|
||||
@update:checked="(checked) => toggleOne(key.key_id, checked === true)"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
|
||||
<Badge
|
||||
v-if="getStatusBadgeLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getStatusBadgeTitle(key)"
|
||||
>{{ getStatusBadgeLabel(key) }}</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ key.oauth_plan_type }}</Badge>
|
||||
<Badge
|
||||
v-if="getOAuthOrgBadge(key)"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getOAuthOrgBadge(key)?.title"
|
||||
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
<span v-if="key.account_quota">{{ shortenQuota(key.account_quota) }}</span>
|
||||
<span v-if="key.proxy?.node_id">独立代理</span>
|
||||
<span
|
||||
v-if="key.last_used_at"
|
||||
class="ml-auto shrink-0"
|
||||
>{{ formatRelativeTime(key.last_used_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="flex items-center justify-between text-xs text-muted-foreground"
|
||||
>
|
||||
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<ChevronsLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<ChevronsRight class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 lg:sticky lg:top-1 lg:self-start">
|
||||
<div class="space-y-2 rounded-lg border bg-background px-3 py-3">
|
||||
<div class="text-xs font-medium text-foreground">
|
||||
执行动作
|
||||
</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
代理节点(仅“配置代理”动作生效)
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
:model-value="proxyNodeIdForAction"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="(v: string) => proxyNodeIdForAction = v"
|
||||
/>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<Button
|
||||
v-for="item in ACTION_OPTIONS"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
class="h-8 w-full px-3 text-xs"
|
||||
:variant="getActionButtonVariant(item)"
|
||||
:disabled="!canExecuteSpecifiedAction(item.value)"
|
||||
@click="confirmAndExecuteAction(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:disabled="executing || selectedCount === 0 || loading"
|
||||
@click="executeAction"
|
||||
>
|
||||
<Play
|
||||
class="h-3.5 w-3.5"
|
||||
:class="executing ? 'animate-pulse' : ''"
|
||||
/>
|
||||
</Button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProxyNodeSelect
|
||||
v-if="selectedAction === 'set_proxy'"
|
||||
:model-value="proxyNodeIdForAction"
|
||||
trigger-class="h-8"
|
||||
@update:model-value="(v: string) => proxyNodeIdForAction = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -260,10 +271,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Checkbox, Badge } from '@/components/ui'
|
||||
import { MultiSelect } from '@/components/common'
|
||||
import { Dialog, Button, Input, Checkbox, Badge } from '@/components/ui'
|
||||
import ProxyNodeSelect from '@/features/providers/components/ProxyNodeSelect.vue'
|
||||
import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
|
||||
import { RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
@@ -308,6 +318,13 @@ type BatchActionValue =
|
||||
| 'enable'
|
||||
| 'disable'
|
||||
|
||||
type BatchActionOption = {
|
||||
value: BatchActionValue
|
||||
label: string
|
||||
hint: string
|
||||
destructive?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
providerId: string
|
||||
@@ -323,26 +340,26 @@ const emit = defineEmits<{
|
||||
|
||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||
{ value: 'banned', label: '账号异常' },
|
||||
{ value: 'oauth_invalid', label: 'Token 异常' },
|
||||
{ value: 'no_5h_limit', label: '无5H限额' },
|
||||
{ value: 'no_weekly_limit', label: '无周限额' },
|
||||
{ value: 'plan_free', label: '全部 Free' },
|
||||
{ value: 'plan_team', label: '全部 Team' },
|
||||
{ value: 'oauth_invalid', label: 'Token 异常' },
|
||||
{ value: 'proxy_unset', label: '未配置代理' },
|
||||
{ value: 'proxy_set', label: '已配置独立代理' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
{ value: 'enabled', label: '已启用' },
|
||||
]
|
||||
|
||||
const ACTION_OPTIONS: Array<{ value: BatchActionValue; label: string }> = [
|
||||
{ value: 'export', label: '导出凭据' },
|
||||
{ value: 'delete', label: '删除账号' },
|
||||
{ value: 'refresh_oauth', label: '刷新 OAuth' },
|
||||
{ value: 'refresh_quota', label: '刷新额度' },
|
||||
{ value: 'clear_proxy', label: '清除代理' },
|
||||
{ value: 'set_proxy', label: '配置代理' },
|
||||
{ value: 'enable', label: '启用' },
|
||||
{ value: 'disable', label: '禁用' },
|
||||
const ACTION_OPTIONS: BatchActionOption[] = [
|
||||
{ value: 'refresh_quota', label: '刷新额度', hint: '调用额度刷新接口,适合核对最新配额状态。' },
|
||||
{ value: 'refresh_oauth', label: '刷新 OAuth', hint: '仅对 OAuth 账号有效,非 OAuth 账号会自动跳过。' },
|
||||
{ value: 'set_proxy', label: '配置代理', hint: '为选中账号绑定独立代理节点。' },
|
||||
{ value: 'clear_proxy', label: '清除代理', hint: '移除账号独立代理,回退到提供商默认代理。' },
|
||||
{ value: 'enable', label: '启用', hint: '批量启用账号,恢复可调度状态。' },
|
||||
{ value: 'disable', label: '禁用', hint: '批量禁用账号,保留数据但停止调度。' },
|
||||
{ value: 'export', label: '导出凭据', hint: '仅导出 OAuth 凭据,其他类型账号将被跳过。' },
|
||||
{ value: 'delete', label: '删除账号', hint: '永久删除账号数据,执行后不可恢复。', destructive: true },
|
||||
]
|
||||
|
||||
const { success, warning, error: showError } = useToast()
|
||||
@@ -357,7 +374,7 @@ const selectedKeyIds = ref<string[]>([])
|
||||
const knownKeysById = ref<Record<string, PoolKeyDetail>>({})
|
||||
const selectAllFiltered = ref(false)
|
||||
const searchText = ref('')
|
||||
const selectedAction = ref<BatchActionValue>('delete')
|
||||
const selectedAction = ref<BatchActionValue>('refresh_quota')
|
||||
const proxyNodeIdForAction = ref('')
|
||||
const lastResultMessage = ref('')
|
||||
const progressTotal = ref(0)
|
||||
@@ -383,6 +400,21 @@ const selectedCount = computed(() => (selectAllFiltered.value ? filteredTotal.va
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(filteredTotal.value / PAGE_SIZE)))
|
||||
const isAllFilteredSelected = computed(() => selectAllFiltered.value && filteredTotal.value > 0)
|
||||
const isPartiallyFilteredSelected = computed(() => !selectAllFiltered.value && selectedKeyIds.value.length > 0)
|
||||
const hasActiveFilters = computed(() => searchText.value.trim().length > 0 || activeQuickSelectors.value.length > 0)
|
||||
const selectedOnCurrentPageCount = computed(() => {
|
||||
if (selectAllFiltered.value) return pageKeys.value.length
|
||||
let count = 0
|
||||
for (const key of pageKeys.value) {
|
||||
if (selectedIdSet.value.has(key.key_id)) count += 1
|
||||
}
|
||||
return count
|
||||
})
|
||||
const isCurrentPageFullySelected = computed(() => {
|
||||
if (selectAllFiltered.value || pageKeys.value.length === 0) return false
|
||||
return selectedOnCurrentPageCount.value === pageKeys.value.length
|
||||
})
|
||||
const canClearSelection = computed(() => selectAllFiltered.value || selectedKeyIds.value.length > 0)
|
||||
const activeQuickSelectorSet = computed(() => new Set(activeQuickSelectors.value))
|
||||
|
||||
function normalizeText(value: unknown): string {
|
||||
return String(value || '').trim().toLowerCase()
|
||||
@@ -592,17 +624,76 @@ function toggleSelectFiltered(checked: boolean | 'indeterminate'): void {
|
||||
}
|
||||
}
|
||||
|
||||
function onQuickSelectChange(values: string[]): void {
|
||||
activeQuickSelectors.value = values as QuickSelectorValue[]
|
||||
function toggleSelectCurrentPage(): void {
|
||||
if (selectAllFiltered.value || pageKeys.value.length === 0) return
|
||||
const set = new Set(selectedKeyIds.value)
|
||||
const pageIds = pageKeys.value.map((key) => key.key_id)
|
||||
const shouldUnselect = pageIds.every((id) => set.has(id))
|
||||
for (const id of pageIds) {
|
||||
if (shouldUnselect) set.delete(id)
|
||||
else set.add(id)
|
||||
}
|
||||
selectedKeyIds.value = [...set]
|
||||
}
|
||||
|
||||
function clearSelection(): void {
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
function clearFilters(): void {
|
||||
if (!hasActiveFilters.value) return
|
||||
clearSearchDebounce()
|
||||
suppressFilterWatch = true
|
||||
searchText.value = ''
|
||||
activeQuickSelectors.value = []
|
||||
suppressFilterWatch = false
|
||||
requestFilteredReload()
|
||||
}
|
||||
|
||||
function removeQuickSelector(selector: QuickSelectorValue): void {
|
||||
function toggleQuickSelector(selector: QuickSelectorValue): void {
|
||||
const idx = activeQuickSelectors.value.indexOf(selector)
|
||||
if (idx >= 0) {
|
||||
activeQuickSelectors.value.splice(idx, 1)
|
||||
requestFilteredReload()
|
||||
} else {
|
||||
activeQuickSelectors.value.push(selector)
|
||||
}
|
||||
requestFilteredReload()
|
||||
}
|
||||
|
||||
function canExecuteSpecifiedAction(action: BatchActionValue): boolean {
|
||||
if (executing.value || loading.value || selectedCount.value === 0) return false
|
||||
if (action === 'set_proxy') return Boolean(proxyNodeIdForAction.value)
|
||||
return true
|
||||
}
|
||||
|
||||
function getActionButtonVariant(option: BatchActionOption): 'default' | 'destructive' | 'outline' {
|
||||
if (option.destructive) return 'destructive'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
async function confirmAndExecuteAction(action: BatchActionValue): Promise<void> {
|
||||
selectedAction.value = action
|
||||
if (selectedCount.value === 0) {
|
||||
warning('请先选择账号')
|
||||
return
|
||||
}
|
||||
if (action === 'set_proxy' && !proxyNodeIdForAction.value) {
|
||||
warning('请先选择代理节点')
|
||||
return
|
||||
}
|
||||
if (!canExecuteSpecifiedAction(action)) return
|
||||
|
||||
const actionOption = ACTION_OPTIONS.find((item) => item.value === action)
|
||||
const actionLabel = actionOption?.label || '执行动作'
|
||||
const scopeLabel = selectAllFiltered.value ? '筛选结果' : '已选账号'
|
||||
const confirmed = await confirm({
|
||||
title: actionLabel,
|
||||
message: `将对${scopeLabel}(${selectedCount.value} 个)执行:${actionLabel},是否继续?`,
|
||||
confirmText: actionOption?.destructive ? '确认删除' : '确认执行',
|
||||
...(actionOption?.destructive ? { variant: 'destructive' as const } : {}),
|
||||
})
|
||||
if (!confirmed) return
|
||||
await executeAction(action)
|
||||
}
|
||||
|
||||
const DELETE_POLL_INTERVAL_MS = 2000
|
||||
@@ -654,24 +745,17 @@ async function resolveSelectedItems(): Promise<PoolKeySelectionItem[]> {
|
||||
})
|
||||
}
|
||||
|
||||
async function executeAction(): Promise<void> {
|
||||
async function executeAction(actionOverride?: BatchActionValue): Promise<void> {
|
||||
if (executing.value) return
|
||||
if (actionOverride) {
|
||||
selectedAction.value = actionOverride
|
||||
}
|
||||
if (selectedCount.value === 0) {
|
||||
warning('请先选择账号')
|
||||
return
|
||||
}
|
||||
|
||||
const requestedCount = selectedCount.value
|
||||
if (selectedAction.value === 'delete') {
|
||||
const confirmed = await confirm({
|
||||
title: '删除账号',
|
||||
message: `将删除 ${requestedCount} 个账号,操作不可恢复,是否继续?`,
|
||||
confirmText: '确认删除',
|
||||
variant: 'destructive',
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
if (selectedAction.value === 'set_proxy' && !proxyNodeIdForAction.value) {
|
||||
warning('请先选择代理节点')
|
||||
return
|
||||
@@ -918,6 +1002,8 @@ watch(
|
||||
searchText.value = ''
|
||||
lastResultMessage.value = ''
|
||||
activeQuickSelectors.value = []
|
||||
selectedAction.value = 'refresh_quota'
|
||||
proxyNodeIdForAction.value = ''
|
||||
resetSelection(true)
|
||||
filteredTotal.value = 0
|
||||
pageKeys.value = []
|
||||
|
||||
@@ -3,71 +3,96 @@
|
||||
:model-value="modelValue"
|
||||
title="高级设置"
|
||||
description="冷却、健康、成本控制与其他高级参数"
|
||||
size="lg"
|
||||
size="3xl"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Cooldown & Health -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
冷却与健康
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">健康策略</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按上游错误自动冷却并跳过账号
|
||||
</p>
|
||||
<div class="max-h-[calc(100dvh-13rem)] space-y-5 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:space-y-6 sm:pr-2">
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
冷却与健康
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
核心策略
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.health_policy_enabled"
|
||||
@update:model-value="(v: boolean) => form.health_policy_enabled = v"
|
||||
/>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制自动冷却、主动探测、异常清理和全局调度优先级。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">主动探测</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
按固定间隔主动刷新 Key 的账号状态与额度
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.probing_enabled"
|
||||
@update:model-value="(v: boolean) => form.probing_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.probing_enabled"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测间隔
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probing_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="10"
|
||||
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="item in healthToggleCards"
|
||||
:key="item.key"
|
||||
class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between lg:items-center"
|
||||
>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium">{{ item.label }}</span>
|
||||
<TooltipProvider
|
||||
:delay-duration="100"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
:title="item.description"
|
||||
:aria-label="`${item.label} 说明`"
|
||||
class="hidden lg:inline-flex items-center justify-center rounded-sm p-0.5 text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<CircleHelp class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
:side-offset="8"
|
||||
class="max-w-xs px-3 py-2 text-xs leading-5"
|
||||
>
|
||||
{{ item.description }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground lg:hidden">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="getHealthToggleValue(item.key)"
|
||||
class="shrink-0"
|
||||
@update:model-value="(v: boolean) => updateHealthToggleValue(item.key, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">异常自动清除</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅在检测到不可恢复的账号异常时自动从号池中移除,不处理纯 Token 失效
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="form.probing_enabled"
|
||||
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
探测间隔
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.probing_interval_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="10"
|
||||
@update:model-value="(v) => form.probing_interval_minutes = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.auto_remove_banned_keys"
|
||||
@update:model-value="(v: boolean) => form.auto_remove_banned_keys = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
|
||||
<div
|
||||
class="grid gap-3 sm:grid-cols-2"
|
||||
:class="cooldownFieldLayout.desktopColumnsClass"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
429 冷却
|
||||
@@ -96,8 +121,6 @@
|
||||
@update:model-value="(v) => form.overload_cooldown_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
粘性会话 TTL
|
||||
@@ -115,7 +138,6 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
全局优先级
|
||||
<span class="text-xs text-muted-foreground">(global_key)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.global_priority ?? ''"
|
||||
@@ -127,93 +149,200 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Batch Operations -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
批量操作
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
并发数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.batch_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
placeholder="8"
|
||||
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
|
||||
/>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
批量刷新 OAuth / 额度等操作的并行请求数
|
||||
<div :class="secondarySectionLayout.wrapperClass">
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
成本控制
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
额度保护
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制窗口期、Key 限额与软阈值,防止个别账号短时间内过度消耗。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-3 sm:grid-cols-2"
|
||||
:class="costFieldLayout.desktopColumnsClass"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
成本窗口
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_window_seconds ?? ''"
|
||||
type="number"
|
||||
min="3600"
|
||||
max="86400"
|
||||
placeholder="18000 (5 小时)"
|
||||
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
Key 窗口限额
|
||||
<span class="text-xs text-muted-foreground">(tokens)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_limit_per_key_tokens ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
软阈值
|
||||
<span class="text-xs text-muted-foreground">(%)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_soft_threshold_percent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5">
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
批量操作
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
任务效率
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
控制刷新 OAuth、主动探测和批量额度处理时的并行请求数。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-muted/30 p-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
并发数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.batch_concurrency ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="32"
|
||||
placeholder="8"
|
||||
@update:model-value="(v) => form.batch_concurrency = parseNum(v)"
|
||||
/>
|
||||
<p class="text-[11px] leading-5 text-muted-foreground">
|
||||
为空时沿用默认值;数值越大,批量操作越快,但会增加瞬时请求压力。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Claude Code -->
|
||||
<div
|
||||
<section
|
||||
v-if="isClaudeCode"
|
||||
class="space-y-3"
|
||||
class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4 sm:p-5"
|
||||
>
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
Claude Code
|
||||
</h3>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Session ID 伪装</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
固定 metadata.user_id 中 session 片段
|
||||
</p>
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Claude Code
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
请求约束
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_id_masking_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
|
||||
/>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
管理 CLI 请求限制、会话控制和 metadata / cache 相关的兼容行为。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">仅限 CLI 客户端</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
仅允许 Claude Code CLI 格式请求
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 lg:grid-cols-2">
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium">Session ID 伪装</span>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
固定 metadata.user_id 中 session 片段。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_id_masking_enabled"
|
||||
class="shrink-0"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_id_masking_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cli_only_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">Cache TTL 统一</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
强制所有 cache_control 使用相同 TTL 类型
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium">仅限 CLI 客户端</span>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
仅允许 Claude Code CLI 格式请求。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cli_only_enabled"
|
||||
class="shrink-0"
|
||||
@update:model-value="(v: boolean) => claudeForm.cli_only_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium">Cache TTL 统一</span>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
强制所有 cache_control 使用同一种 TTL 类型。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cache_ttl_override_enabled"
|
||||
class="shrink-0"
|
||||
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 rounded-xl border border-border/60 bg-muted/30 p-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="space-y-1">
|
||||
<span class="text-sm font-medium">会话数量控制</span>
|
||||
<p class="text-xs leading-5 text-muted-foreground">
|
||||
限制单 Key 同时活跃会话数,降低长期占用风险。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_control_enabled"
|
||||
class="shrink-0"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.cache_ttl_override_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.cache_ttl_override_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="claudeForm.cache_ttl_override_enabled"
|
||||
class="pl-3"
|
||||
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>TTL 类型</Label>
|
||||
<div class="flex gap-0.5 p-0.5 bg-muted/40 rounded-md w-fit">
|
||||
<div class="flex w-fit gap-0.5 rounded-md bg-muted/40 p-0.5">
|
||||
<button
|
||||
v-for="opt in ['ephemeral']"
|
||||
:key="opt"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
class="rounded px-2.5 py-1 text-xs font-medium transition-all"
|
||||
:class="[
|
||||
claudeForm.cache_ttl_override_target === opt
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
: 'text-muted-foreground hover:bg-background/50 hover:text-foreground'
|
||||
]"
|
||||
@click="claudeForm.cache_ttl_override_target = opt"
|
||||
>
|
||||
@@ -222,112 +351,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">会话数量控制</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
限制单 Key 同时活跃会话数
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="claudeForm.session_control_enabled"
|
||||
@update:model-value="(v: boolean) => claudeForm.session_control_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="claudeForm.session_control_enabled"
|
||||
class="grid grid-cols-2 gap-4"
|
||||
class="rounded-xl border border-dashed border-primary/25 bg-primary/5 p-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
最大会话数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.max_sessions ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
空闲超时
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="5"
|
||||
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
|
||||
/>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
最大会话数
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.max_sessions ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => claudeForm.max_sessions = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
空闲超时
|
||||
<span class="text-xs text-muted-foreground">(分钟)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="claudeForm.session_idle_timeout_minutes ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="5"
|
||||
@update:model-value="(v) => claudeForm.session_idle_timeout_minutes = parseNum(v) ?? 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cost Control -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
成本控制
|
||||
</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
成本窗口
|
||||
<span class="text-xs text-muted-foreground">(秒)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_window_seconds ?? ''"
|
||||
type="number"
|
||||
min="3600"
|
||||
max="86400"
|
||||
placeholder="18000 (5 小时)"
|
||||
@update:model-value="(v) => form.cost_window_seconds = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
Key 窗口限额
|
||||
<span class="text-xs text-muted-foreground">(tokens)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_limit_per_key_tokens ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="留空 = 不限"
|
||||
@update:model-value="(v) => form.cost_limit_per_key_tokens = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>
|
||||
软阈值
|
||||
<span class="text-xs text-muted-foreground">(%)</span>
|
||||
</Label>
|
||||
<Input
|
||||
:model-value="form.cost_soft_threshold_percent ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="80"
|
||||
@update:model-value="(v) => form.cost_soft_threshold_percent = parseNum(v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="min-w-[96px] flex-1 sm:flex-none"
|
||||
:disabled="loading"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="min-w-[96px] flex-1 sm:flex-none"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
@@ -339,10 +411,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label, Switch } from '@/components/ui'
|
||||
import { CircleHelp } from 'lucide-vue-next'
|
||||
import { Dialog, Button, Input, Label, Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
import {
|
||||
buildPoolCooldownFieldLayout,
|
||||
buildPoolCostFieldLayout,
|
||||
buildPoolHealthToggleCards,
|
||||
buildPoolSecondarySectionLayout,
|
||||
type PoolHealthToggleKey,
|
||||
} from '@/features/pool/utils/poolAdvancedDialog'
|
||||
import type {
|
||||
PoolAdvancedConfig,
|
||||
ClaudeCodeAdvancedConfig,
|
||||
@@ -369,6 +449,11 @@ const isClaudeCode = computed(() => {
|
||||
return (props.providerType || '').trim().toLowerCase() === 'claude_code'
|
||||
})
|
||||
|
||||
const healthToggleCards = buildPoolHealthToggleCards()
|
||||
const cooldownFieldLayout = buildPoolCooldownFieldLayout()
|
||||
const costFieldLayout = buildPoolCostFieldLayout()
|
||||
const secondarySectionLayout = buildPoolSecondarySectionLayout()
|
||||
|
||||
const form = ref({
|
||||
global_priority: null as number | null | undefined,
|
||||
sticky_session_ttl_seconds: null as number | null | undefined,
|
||||
@@ -410,6 +495,30 @@ function parseNum(v: string | number): number | undefined {
|
||||
return Number.isNaN(n) ? undefined : n
|
||||
}
|
||||
|
||||
function getHealthToggleValue(key: PoolHealthToggleKey): boolean {
|
||||
switch (key) {
|
||||
case 'health_policy_enabled':
|
||||
return form.value.health_policy_enabled
|
||||
case 'probing_enabled':
|
||||
return form.value.probing_enabled
|
||||
case 'auto_remove_banned_keys':
|
||||
return form.value.auto_remove_banned_keys
|
||||
}
|
||||
}
|
||||
|
||||
function updateHealthToggleValue(key: PoolHealthToggleKey, value: boolean): void {
|
||||
switch (key) {
|
||||
case 'health_policy_enabled':
|
||||
form.value.health_policy_enabled = value
|
||||
return
|
||||
case 'probing_enabled':
|
||||
form.value.probing_enabled = value
|
||||
return
|
||||
case 'auto_remove_banned_keys':
|
||||
form.value.auto_remove_banned_keys = value
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (!open) return
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
size="lg"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<div class="max-h-[calc(100dvh-13rem)] space-y-5 overflow-y-auto overscroll-contain pr-1 sm:max-h-[min(72vh,42rem)] sm:space-y-6 sm:pr-2">
|
||||
<!-- Section 1: 分配模式 (distribution_mode 互斥组, 四选一) -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
<h3 class="text-sm font-medium">
|
||||
分配模式
|
||||
</h3>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
@@ -18,19 +18,19 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 p-1 bg-muted/40 rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="{ index, item } in distributionItems"
|
||||
:key="item.preset"
|
||||
type="button"
|
||||
class="flex-1 px-3 py-2 text-sm font-medium rounded-md transition-all duration-200"
|
||||
class="min-h-11 rounded-xl border px-3 py-2.5 text-sm font-medium leading-tight transition-all duration-200"
|
||||
:disabled="!item.applicable"
|
||||
:class="[
|
||||
activeDistributionPreset === item.preset
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
? 'border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20'
|
||||
: item.applicable
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-background/60'
|
||||
: 'text-muted-foreground/40 cursor-not-allowed'
|
||||
? 'border-border/60 bg-background text-foreground hover:border-border hover:bg-muted/40'
|
||||
: 'border-border/30 bg-muted/20 text-muted-foreground/50 cursor-not-allowed'
|
||||
]"
|
||||
@click="item.applicable && selectDistribution(index, item.preset)"
|
||||
>
|
||||
@@ -38,38 +38,53 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="activeDistributionDesc"
|
||||
class="text-xs text-muted-foreground px-1"
|
||||
<div
|
||||
v-if="activeDistributionLabel || activeDistributionDesc"
|
||||
class="rounded-xl border border-primary/15 bg-primary/5 px-3 py-2.5"
|
||||
>
|
||||
{{ activeDistributionDesc }}
|
||||
</p>
|
||||
<p
|
||||
v-if="activeDistributionDesc"
|
||||
class="mt-1 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{{ activeDistributionDesc }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: 策略调度 (非互斥, 可叠加组合 + 拖拽排序) -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4 rounded-2xl border border-border/60 bg-card/70 p-4">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
策略调度
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-sm font-medium">
|
||||
策略调度
|
||||
</h3>
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
已启用 {{ enabledStrategyCount }} 项
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
在分配模式基础上叠加排序因素,可组合启用,拖拽调整优先级。
|
||||
在分配模式基础上叠加排序因素,可组合启用。
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
桌面端支持拖拽排序,移动端可点按上下调整优先级。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-0.5">
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="{ index, item } in strategyItems"
|
||||
:key="item.preset"
|
||||
class="group flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-all duration-200"
|
||||
class="group rounded-xl border px-3 py-2.5 transition-all duration-200"
|
||||
:class="[
|
||||
!item.applicable
|
||||
? 'border-border/30 bg-muted/20 opacity-50'
|
||||
? 'border-border/40 bg-muted/20 opacity-80'
|
||||
: draggedIndex === index
|
||||
? 'border-primary/50 bg-primary/5 shadow-md scale-[1.01]'
|
||||
? 'border-primary/50 bg-primary/5 shadow-md'
|
||||
: dragOverIndex === index
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
: item.enabled
|
||||
? 'border-primary/20 bg-primary/5 hover:border-primary/30'
|
||||
: 'border-border/60 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
:draggable="item.applicable"
|
||||
@dragstart="item.applicable && handleDragStart(index, $event)"
|
||||
@@ -78,57 +93,98 @@
|
||||
@dragleave="handleDragLeave"
|
||||
@drop="item.applicable && handleDrop(index)"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
class="p-1 rounded transition-colors shrink-0"
|
||||
:class="item.applicable
|
||||
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/15 cursor-default'"
|
||||
>
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
:model-value="item.enabled"
|
||||
:disabled="!item.applicable"
|
||||
@update:model-value="(v: boolean) => togglePreset(index, v)"
|
||||
/>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="!item.applicable ? 'text-muted-foreground' : ''"
|
||||
>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="!item.applicable"
|
||||
class="text-[10px] text-muted-foreground/60"
|
||||
>(不适用)</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
|
||||
<!-- Mode sub-config -->
|
||||
<div class="flex items-start gap-2.5">
|
||||
<!-- Drag handle -->
|
||||
<div
|
||||
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
|
||||
class="flex gap-0.5 mt-2 p-0.5 bg-muted/40 rounded-md w-fit"
|
||||
class="hidden rounded-lg p-1 transition-colors sm:flex sm:shrink-0"
|
||||
:class="item.applicable
|
||||
? 'cursor-grab active:cursor-grabbing text-muted-foreground/40 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/20 cursor-default'"
|
||||
>
|
||||
<button
|
||||
v-for="modeOpt in item.modeOptions"
|
||||
:key="modeOpt.value"
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs font-medium rounded transition-all"
|
||||
:class="[
|
||||
item.mode === modeOpt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-background/50'
|
||||
]"
|
||||
@click="setPresetModeByPreset(item.preset, modeOpt.value)"
|
||||
<GripVertical class="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="!item.applicable ? 'text-muted-foreground' : 'text-foreground'"
|
||||
>{{ item.label }}</span>
|
||||
<span
|
||||
v-if="getStrategyPriority(index)"
|
||||
class="rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-medium text-primary"
|
||||
>
|
||||
#{{ getStrategyPriority(index) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!item.applicable"
|
||||
class="rounded-full border border-border/60 bg-muted px-2 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
当前不可用
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs leading-5 text-muted-foreground">
|
||||
{{ item.desc }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="item.applicable"
|
||||
class="flex shrink-0 items-center gap-1.5 sm:hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 items-center justify-center rounded-lg border border-border/60 px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="!canMoveStrategy(index, -1)"
|
||||
@click="moveStrategy(index, -1)"
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-7 items-center justify-center rounded-lg border border-border/60 px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
|
||||
:disabled="!canMoveStrategy(index, 1)"
|
||||
@click="moveStrategy(index, 1)"
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
:model-value="item.enabled && item.applicable"
|
||||
:disabled="!item.applicable"
|
||||
class="mt-0.5 shrink-0"
|
||||
@update:model-value="(v: boolean) => togglePreset(index, v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="(item.modeOptions.length > 0 && item.enabled && item.applicable) || item.applicable"
|
||||
class="mt-2 flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
{{ modeOpt.label }}
|
||||
</button>
|
||||
<!-- Mode sub-config -->
|
||||
<div
|
||||
v-if="item.modeOptions.length > 0 && item.enabled && item.applicable"
|
||||
class="flex flex-wrap gap-1 rounded-lg bg-muted/40 p-1"
|
||||
>
|
||||
<button
|
||||
v-for="modeOpt in item.modeOptions"
|
||||
:key="modeOpt.value"
|
||||
type="button"
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all"
|
||||
:class="[
|
||||
item.mode === modeOpt.value
|
||||
? 'bg-primary text-primary-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground'
|
||||
]"
|
||||
@click="setPresetModeByPreset(item.preset, modeOpt.value)"
|
||||
>
|
||||
{{ modeOpt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,12 +195,14 @@
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="min-w-[96px] flex-1 sm:flex-none"
|
||||
:disabled="loading"
|
||||
@click="emit('update:modelValue', false)"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="min-w-[96px] flex-1 sm:flex-none"
|
||||
:disabled="loading"
|
||||
@click="handleSave"
|
||||
>
|
||||
@@ -162,6 +220,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { updateProvider } from '@/api/endpoints'
|
||||
import { getPoolSchedulingPresets } from '@/api/endpoints/pool'
|
||||
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
|
||||
import type { PoolPresetMeta } from '@/api/endpoints/pool'
|
||||
import type {
|
||||
PoolAdvancedConfig,
|
||||
@@ -665,6 +724,11 @@ const activeDistributionDesc = computed(() => {
|
||||
return found?.item.desc ?? null
|
||||
})
|
||||
|
||||
const activeDistributionLabel = computed(() => {
|
||||
const found = distributionItems.value.find(({ item }) => item.enabled && item.applicable)
|
||||
return found?.item.label ?? null
|
||||
})
|
||||
|
||||
const strategyItems = computed(() => {
|
||||
const items: { index: number; item: PresetListItem }[] = []
|
||||
presetList.value.forEach((item, index) => {
|
||||
@@ -675,6 +739,37 @@ const strategyItems = computed(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const enabledStrategyPriorityMap = computed(() => {
|
||||
const priorities = new Map<number, number>()
|
||||
let priority = 0
|
||||
|
||||
strategyItems.value.forEach(({ index, item }) => {
|
||||
if (!item.enabled || !item.applicable) return
|
||||
priority += 1
|
||||
priorities.set(index, priority)
|
||||
})
|
||||
|
||||
return priorities
|
||||
})
|
||||
|
||||
const enabledStrategyCount = computed(() => enabledStrategyPriorityMap.value.size)
|
||||
|
||||
function getStrategyPriority(index: number): number | null {
|
||||
return enabledStrategyPriorityMap.value.get(index) ?? null
|
||||
}
|
||||
|
||||
function canMoveStrategy(index: number, direction: -1 | 1): boolean {
|
||||
const strategyIndexes = strategyItems.value.map(({ index: currentIndex }) => currentIndex)
|
||||
const currentPosition = strategyIndexes.indexOf(index)
|
||||
if (currentPosition === -1) return false
|
||||
const targetPosition = currentPosition + direction
|
||||
return targetPosition >= 0 && targetPosition < strategyIndexes.length
|
||||
}
|
||||
|
||||
function moveStrategy(index: number, direction: -1 | 1) {
|
||||
presetList.value = moveStrategyItem(presetList.value, index, direction)
|
||||
}
|
||||
|
||||
function handleDragStart(index: number, event: DragEvent) {
|
||||
draggedIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolCooldownFieldLayout,
|
||||
buildPoolHealthToggleCards,
|
||||
buildPoolCostFieldLayout,
|
||||
buildPoolSecondarySectionLayout,
|
||||
} from '@/features/pool/utils/poolAdvancedDialog'
|
||||
|
||||
describe('poolAdvancedDialog', () => {
|
||||
it('returns health toggle cards in the desktop display order', () => {
|
||||
expect(buildPoolHealthToggleCards().map(item => item.key)).toEqual([
|
||||
'health_policy_enabled',
|
||||
'probing_enabled',
|
||||
'auto_remove_banned_keys',
|
||||
])
|
||||
})
|
||||
|
||||
it('provides tooltip copy for every desktop health toggle card', () => {
|
||||
expect(buildPoolHealthToggleCards()).toEqual([
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
label: '异常自动清除',
|
||||
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the four cooldown-related fields in one desktop row order', () => {
|
||||
expect(buildPoolCooldownFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('stacks batch and cost sections as full-width rows on desktop', () => {
|
||||
expect(buildPoolSecondarySectionLayout()).toEqual({
|
||||
wrapperClass: 'space-y-4',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the three cost fields in one desktop row order', () => {
|
||||
expect(buildPoolCostFieldLayout()).toEqual({
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolManagementQueryPatch,
|
||||
readPoolManagementViewState,
|
||||
writePoolManagementViewState,
|
||||
} from '@/features/pool/utils/poolManagementState'
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>()
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value)
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('poolManagementState', () => {
|
||||
let storage: ReturnType<typeof createMemoryStorage>
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createMemoryStorage()
|
||||
})
|
||||
|
||||
it('restores provider, filters and paging from query first', () => {
|
||||
writePoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-a',
|
||||
search: 'stored search',
|
||||
status: 'cooldown',
|
||||
page: 5,
|
||||
pageSize: 20,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
const state = readPoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-b',
|
||||
search: 'query search',
|
||||
status: 'inactive',
|
||||
page: '3',
|
||||
pageSize: '100',
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
expect(state).toEqual({
|
||||
providerId: 'provider-b',
|
||||
search: 'query search',
|
||||
status: 'inactive',
|
||||
page: 3,
|
||||
pageSize: 100,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to storage when query is missing', () => {
|
||||
writePoolManagementViewState(
|
||||
{
|
||||
providerId: 'provider-c',
|
||||
search: 'stored only',
|
||||
status: 'active',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
|
||||
const state = readPoolManagementViewState({}, storage)
|
||||
|
||||
expect(state).toEqual({
|
||||
providerId: 'provider-c',
|
||||
search: 'stored only',
|
||||
status: 'active',
|
||||
page: 2,
|
||||
pageSize: 50,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits defaults when building query patch', () => {
|
||||
expect(
|
||||
buildPoolManagementQueryPatch({
|
||||
providerId: 'provider-d',
|
||||
search: ' ',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
).toEqual({
|
||||
providerId: 'provider-d',
|
||||
search: undefined,
|
||||
status: undefined,
|
||||
page: undefined,
|
||||
pageSize: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPoolMobileTagItems,
|
||||
splitPoolMobileActions,
|
||||
} from '@/features/pool/utils/poolMobilePresentation'
|
||||
|
||||
describe('poolMobilePresentation', () => {
|
||||
it('prioritizes critical mobile tags before secondary identity tags', () => {
|
||||
expect(
|
||||
buildPoolMobileTagItems({
|
||||
priorityLabel: 'P40',
|
||||
authLabel: 'OAuth',
|
||||
oauthStatusLabel: 'Token 过期',
|
||||
oauthStatusTone: 'danger',
|
||||
accountStatusLabel: '账号停用',
|
||||
accountStatusTone: 'danger',
|
||||
planLabel: 'Team',
|
||||
orgLabel: 'Org A',
|
||||
proxyLabel: '独立代理',
|
||||
}).map(item => item.label),
|
||||
).toEqual([
|
||||
'账号停用',
|
||||
'Token 过期',
|
||||
'P40',
|
||||
'OAuth',
|
||||
'Team',
|
||||
'Org A',
|
||||
'独立代理',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps all available mobile actions inline without overflow grouping', () => {
|
||||
expect(
|
||||
splitPoolMobileActions({
|
||||
canRefreshToken: true,
|
||||
canClearCooldown: true,
|
||||
canRecoverHealth: true,
|
||||
canDownloadOrCopy: true,
|
||||
hasProxy: true,
|
||||
}),
|
||||
).toEqual({
|
||||
primary: [
|
||||
'copy_or_download',
|
||||
'refresh_token',
|
||||
'clear_cooldown',
|
||||
'recover_health',
|
||||
'permissions',
|
||||
'proxy',
|
||||
'edit',
|
||||
'toggle',
|
||||
'delete',
|
||||
],
|
||||
overflow: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { moveStrategyItem } from '@/features/pool/utils/poolSchedulingDialog'
|
||||
|
||||
interface TestPresetItem {
|
||||
preset: string
|
||||
mutexGroup: string | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function buildItems(): TestPresetItem[] {
|
||||
return [
|
||||
{ preset: 'cache_affinity', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'lru', mutexGroup: 'distribution_mode', enabled: true },
|
||||
{ preset: 'single_account', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'load_balance', mutexGroup: 'distribution_mode', enabled: false },
|
||||
{ preset: 'recent_refresh', mutexGroup: null, enabled: true },
|
||||
{ preset: 'quota_balanced', mutexGroup: null, enabled: false },
|
||||
{ preset: 'priority_first', mutexGroup: null, enabled: true },
|
||||
]
|
||||
}
|
||||
|
||||
describe('poolSchedulingDialog', () => {
|
||||
it('moves only strategy items upward without disturbing distribution presets', () => {
|
||||
const moved = moveStrategyItem(buildItems(), 6, -1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual([
|
||||
'cache_affinity',
|
||||
'lru',
|
||||
'single_account',
|
||||
'load_balance',
|
||||
'recent_refresh',
|
||||
'priority_first',
|
||||
'quota_balanced',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the original order when a strategy item is already at the top boundary', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 4, -1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
|
||||
it('moves a strategy item downward within the strategy group', () => {
|
||||
const moved = moveStrategyItem(buildItems(), 4, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual([
|
||||
'cache_affinity',
|
||||
'lru',
|
||||
'single_account',
|
||||
'load_balance',
|
||||
'quota_balanced',
|
||||
'recent_refresh',
|
||||
'priority_first',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the original order when a strategy item is already at the bottom boundary', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 6, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
|
||||
it('keeps the original order when the target item is not a strategy preset', () => {
|
||||
const original = buildItems()
|
||||
const moved = moveStrategyItem(original, 1, 1)
|
||||
|
||||
expect(moved.map(item => item.preset)).toEqual(original.map(item => item.preset))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export type PoolHealthToggleKey =
|
||||
| 'health_policy_enabled'
|
||||
| 'probing_enabled'
|
||||
| 'auto_remove_banned_keys'
|
||||
|
||||
export interface PoolHealthToggleCard {
|
||||
key: PoolHealthToggleKey
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface PoolCooldownFieldLayout {
|
||||
fields: string[]
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export interface PoolSecondarySectionLayout {
|
||||
wrapperClass: string
|
||||
}
|
||||
|
||||
export interface PoolCostFieldLayout {
|
||||
fields: string[]
|
||||
desktopColumnsClass: string
|
||||
}
|
||||
|
||||
export function buildPoolHealthToggleCards(): PoolHealthToggleCard[] {
|
||||
return [
|
||||
{
|
||||
key: 'health_policy_enabled',
|
||||
label: '健康策略',
|
||||
description: '按上游错误自动冷却并跳过异常账号。',
|
||||
},
|
||||
{
|
||||
key: 'probing_enabled',
|
||||
label: '主动探测',
|
||||
description: '按固定间隔刷新 Key 的状态与额度,减少号池状态滞后。',
|
||||
},
|
||||
{
|
||||
key: 'auto_remove_banned_keys',
|
||||
label: '异常自动清除',
|
||||
description: '仅在检测到不可恢复的账号异常时自动从号池移除,不处理纯 Token 失效。',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildPoolCooldownFieldLayout(): PoolCooldownFieldLayout {
|
||||
return {
|
||||
fields: [
|
||||
'rate_limit_cooldown_seconds',
|
||||
'overload_cooldown_seconds',
|
||||
'sticky_session_ttl_seconds',
|
||||
'global_priority',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolSecondarySectionLayout(): PoolSecondarySectionLayout {
|
||||
return {
|
||||
wrapperClass: 'space-y-4',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolCostFieldLayout(): PoolCostFieldLayout {
|
||||
return {
|
||||
fields: [
|
||||
'cost_window_seconds',
|
||||
'cost_limit_per_key_tokens',
|
||||
'cost_soft_threshold_percent',
|
||||
],
|
||||
desktopColumnsClass: 'xl:grid-cols-3',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
export type PoolManagementStatus = 'all' | 'active' | 'cooldown' | 'inactive'
|
||||
|
||||
export interface PoolManagementViewState {
|
||||
providerId: string | null
|
||||
search: string
|
||||
status: PoolManagementStatus
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface PoolManagementStateSource {
|
||||
providerId?: string
|
||||
search?: string
|
||||
status?: string
|
||||
page?: string
|
||||
pageSize?: string
|
||||
}
|
||||
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null
|
||||
setItem(key: string, value: string): void
|
||||
removeItem(key: string): void
|
||||
}
|
||||
|
||||
export const POOL_MANAGEMENT_VIEW_STORAGE_KEY = 'aether:pool-management:view-state'
|
||||
|
||||
export const DEFAULT_POOL_MANAGEMENT_VIEW_STATE: PoolManagementViewState = {
|
||||
providerId: null,
|
||||
search: '',
|
||||
status: 'all',
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}
|
||||
|
||||
function normalizeProviderId(value: unknown): string | null {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || null
|
||||
}
|
||||
|
||||
function normalizeSearch(value: unknown): string {
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): PoolManagementStatus {
|
||||
if (value === 'active' || value === 'cooldown' || value === 'inactive') {
|
||||
return value
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
const normalized = Number.parseInt(String(value ?? ''), 10)
|
||||
if (!Number.isFinite(normalized) || normalized <= 0) {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeViewState(input: Partial<PoolManagementViewState>): PoolManagementViewState {
|
||||
return {
|
||||
providerId: normalizeProviderId(input.providerId),
|
||||
search: normalizeSearch(input.search),
|
||||
status: normalizeStatus(input.status),
|
||||
page: normalizePositiveInteger(input.page, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.page),
|
||||
pageSize: normalizePositiveInteger(input.pageSize, DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredState(storage?: StorageLike): Partial<PoolManagementViewState> {
|
||||
if (!storage) return {}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(POOL_MANAGEMENT_VIEW_STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Partial<PoolManagementViewState> | null
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function readPoolManagementViewState(
|
||||
source: PoolManagementStateSource,
|
||||
storage?: StorageLike,
|
||||
): PoolManagementViewState {
|
||||
const stored = normalizeViewState(readStoredState(storage))
|
||||
|
||||
return normalizeViewState({
|
||||
providerId: source.providerId ?? stored.providerId,
|
||||
search: source.search ?? stored.search,
|
||||
status: source.status ?? stored.status,
|
||||
page: source.page ?? stored.page,
|
||||
pageSize: source.pageSize ?? stored.pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
export function writePoolManagementViewState(
|
||||
state: PoolManagementViewState,
|
||||
storage?: StorageLike,
|
||||
): void {
|
||||
if (!storage) return
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
POOL_MANAGEMENT_VIEW_STORAGE_KEY,
|
||||
JSON.stringify(normalizeViewState(state)),
|
||||
)
|
||||
} catch {
|
||||
// 忽略存储失败,避免影响主流程。
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPoolManagementQueryPatch(
|
||||
state: PoolManagementViewState,
|
||||
): Record<string, string | undefined> {
|
||||
const normalized = normalizeViewState(state)
|
||||
const search = normalized.search.trim()
|
||||
|
||||
return {
|
||||
providerId: normalized.providerId || undefined,
|
||||
search: search || undefined,
|
||||
status: normalized.status === 'all' ? undefined : normalized.status,
|
||||
page: normalized.page <= 1 ? undefined : String(normalized.page),
|
||||
pageSize:
|
||||
normalized.pageSize === DEFAULT_POOL_MANAGEMENT_VIEW_STATE.pageSize
|
||||
? undefined
|
||||
: String(normalized.pageSize),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
export type PoolMobileTagTone = 'default' | 'muted' | 'warning' | 'danger' | 'accent'
|
||||
|
||||
export interface PoolMobileTagItem {
|
||||
key: string
|
||||
label: string
|
||||
tone: PoolMobileTagTone
|
||||
}
|
||||
|
||||
export interface PoolMobileTagInput {
|
||||
priorityLabel?: string | null
|
||||
authLabel?: string | null
|
||||
oauthStatusLabel?: string | null
|
||||
oauthStatusTone?: PoolMobileTagTone | null
|
||||
accountStatusLabel?: string | null
|
||||
accountStatusTone?: PoolMobileTagTone | null
|
||||
planLabel?: string | null
|
||||
orgLabel?: string | null
|
||||
proxyLabel?: string | null
|
||||
}
|
||||
|
||||
export type PoolMobileActionId =
|
||||
| 'copy_or_download'
|
||||
| 'refresh_token'
|
||||
| 'clear_cooldown'
|
||||
| 'recover_health'
|
||||
| 'permissions'
|
||||
| 'proxy'
|
||||
| 'edit'
|
||||
| 'toggle'
|
||||
| 'delete'
|
||||
|
||||
export interface PoolMobileActionInput {
|
||||
canDownloadOrCopy?: boolean
|
||||
canRefreshToken?: boolean
|
||||
canClearCooldown?: boolean
|
||||
canRecoverHealth?: boolean
|
||||
hasProxy?: boolean
|
||||
}
|
||||
|
||||
function createTagItem(
|
||||
key: string,
|
||||
label: string | null | undefined,
|
||||
tone: PoolMobileTagTone,
|
||||
): PoolMobileTagItem | null {
|
||||
if (!label) return null
|
||||
return { key, label, tone }
|
||||
}
|
||||
|
||||
export function buildPoolMobileTagItems(input: PoolMobileTagInput): PoolMobileTagItem[] {
|
||||
return [
|
||||
createTagItem('account', input.accountStatusLabel, input.accountStatusTone ?? 'warning'),
|
||||
createTagItem('oauth', input.oauthStatusLabel, input.oauthStatusTone ?? 'warning'),
|
||||
createTagItem('priority', input.priorityLabel, 'muted'),
|
||||
createTagItem('auth', input.authLabel, 'default'),
|
||||
createTagItem('plan', input.planLabel, 'accent'),
|
||||
createTagItem('org', input.orgLabel, 'accent'),
|
||||
createTagItem('proxy', input.proxyLabel, 'muted'),
|
||||
].filter((item): item is PoolMobileTagItem => item !== null)
|
||||
}
|
||||
|
||||
export function splitPoolMobileActions(input: PoolMobileActionInput): {
|
||||
primary: PoolMobileActionId[]
|
||||
overflow: PoolMobileActionId[]
|
||||
} {
|
||||
if (input.canDownloadOrCopy) {
|
||||
const primary: PoolMobileActionId[] = ['copy_or_download']
|
||||
if (input.canRefreshToken) {
|
||||
primary.push('refresh_token')
|
||||
}
|
||||
if (input.canClearCooldown) {
|
||||
primary.push('clear_cooldown')
|
||||
}
|
||||
if (input.canRecoverHealth) {
|
||||
primary.push('recover_health')
|
||||
}
|
||||
primary.push('permissions')
|
||||
if (input.hasProxy) {
|
||||
primary.push('proxy')
|
||||
}
|
||||
primary.push('edit', 'toggle', 'delete')
|
||||
|
||||
return {
|
||||
primary,
|
||||
overflow: [],
|
||||
}
|
||||
}
|
||||
|
||||
const primary: PoolMobileActionId[] = []
|
||||
if (input.canRefreshToken) {
|
||||
primary.push('refresh_token')
|
||||
}
|
||||
if (input.canClearCooldown) {
|
||||
primary.push('clear_cooldown')
|
||||
}
|
||||
if (input.canRecoverHealth) {
|
||||
primary.push('recover_health')
|
||||
}
|
||||
primary.push('permissions')
|
||||
if (input.hasProxy) {
|
||||
primary.push('proxy')
|
||||
}
|
||||
primary.push('edit', 'toggle', 'delete')
|
||||
|
||||
return {
|
||||
primary,
|
||||
overflow: [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface SchedulingDialogPresetLike {
|
||||
mutexGroup: string | null
|
||||
}
|
||||
|
||||
export function moveStrategyItem<T extends SchedulingDialogPresetLike>(
|
||||
items: readonly T[],
|
||||
itemIndex: number,
|
||||
direction: -1 | 1,
|
||||
): T[] {
|
||||
const strategyIndexes: number[] = []
|
||||
|
||||
items.forEach((item, index) => {
|
||||
if (!item.mutexGroup) {
|
||||
strategyIndexes.push(index)
|
||||
}
|
||||
})
|
||||
|
||||
const currentPosition = strategyIndexes.indexOf(itemIndex)
|
||||
if (currentPosition === -1) {
|
||||
return [...items]
|
||||
}
|
||||
|
||||
const targetPosition = currentPosition + direction
|
||||
if (targetPosition < 0 || targetPosition >= strategyIndexes.length) {
|
||||
return [...items]
|
||||
}
|
||||
|
||||
const sourceIndex = strategyIndexes[currentPosition]
|
||||
const targetIndex = strategyIndexes[targetPosition]
|
||||
const nextItems = [...items]
|
||||
|
||||
;[nextItems[sourceIndex], nextItems[targetIndex]] = [nextItems[targetIndex], nextItems[sourceIndex]]
|
||||
|
||||
return nextItems
|
||||
}
|
||||
@@ -409,7 +409,7 @@ const { error: showError, success: showSuccess } = useToast()
|
||||
const modelTest = useModelTest({ providerId: () => props.provider.id })
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const localLoading = ref(false)
|
||||
const dialogOpen = ref(false)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
@@ -429,7 +429,7 @@ const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraf
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
const isLoading = computed(() => Boolean(props.loading) || loading.value)
|
||||
const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
|
||||
|
||||
// 使用 props 传入的数据
|
||||
const models = computed(() => props.models ?? [])
|
||||
|
||||
@@ -285,7 +285,7 @@ const { copyToClipboard } = useClipboard()
|
||||
const modelTest = useModelTest({ providerId: () => props.provider.id })
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const localLoading = ref(false)
|
||||
const localModels = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const pendingTestModel = ref<Model | null>(null)
|
||||
@@ -301,7 +301,7 @@ const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.er
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
const isLoading = computed(() => Boolean(props.loading) || loading.value)
|
||||
const isLoading = computed(() => Boolean(props.loading) || localLoading.value)
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
return [...models.value].sort((a, b) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user