mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5671a57dc8 | ||
|
|
166e8754a2 | ||
|
|
fbaf6c1bdd | ||
|
|
7cdc2dba6f | ||
|
|
a321ccf737 | ||
|
|
4533ae8850 | ||
|
|
7542ed365e | ||
|
|
bf4da7a3e7 | ||
|
|
0fe354b083 | ||
|
|
6d0c583599 | ||
|
|
2e861c03c4 | ||
|
|
d5fc778ea7 | ||
|
|
404cd41ff4 | ||
|
|
851f473b88 | ||
|
|
e97da4f551 | ||
|
|
5544b0fb29 | ||
|
|
428e744710 | ||
|
|
7b406765d6 | ||
|
|
92ae7df115 | ||
|
|
cc42e78334 | ||
|
|
0c90ed30fb | ||
|
|
5a81856510 | ||
|
|
3110b71512 | ||
|
|
eac1b2c39e | ||
|
|
337bb80230 | ||
|
|
82f2a0da06 | ||
|
|
f0d8dad71e | ||
|
|
f5c1d773d7 | ||
|
|
9148e6b11e | ||
|
|
a7d8c3d3df | ||
|
|
997eb86aa0 | ||
|
|
3866acc269 | ||
|
|
38477f4328 | ||
|
|
5168f6a924 | ||
|
|
3a407c635a | ||
|
|
6901e0ed45 | ||
|
|
9758c420fa | ||
|
|
6d2bf06ac7 | ||
|
|
b8faaf250f | ||
|
|
40137b1205 | ||
|
|
c67cde4e90 | ||
|
|
2e9213a784 | ||
|
|
87496b5656 | ||
|
|
c856e4306f | ||
|
|
92090f28bf | ||
|
|
12c2b612de | ||
|
|
e8af0b9c66 | ||
|
|
80776e8e03 | ||
|
|
b296c795ac | ||
|
|
9fc162a82f | ||
|
|
9532bd438b | ||
|
|
73319f1943 | ||
|
|
ee56d6a1d2 | ||
|
|
5d29ddfffc | ||
|
|
45903b684b | ||
|
|
a580874697 | ||
|
|
885369f19f | ||
|
|
24ff339c97 | ||
|
|
e582eb73d2 | ||
|
|
ec252b3b46 | ||
|
|
6503f36750 | ||
|
|
2366f66f33 | ||
|
|
cc0303c65e | ||
|
|
b11eeac29e | ||
|
|
ad5f3ba997 | ||
|
|
15455ca619 | ||
|
|
904315c209 | ||
|
|
95d720dd86 | ||
|
|
74f59cc4f8 | ||
|
|
e979454457 | ||
|
|
0af8610a48 | ||
|
|
8226089ade | ||
|
|
1bd40ffa67 | ||
|
|
34a6ccce43 | ||
|
|
c275119f12 | ||
|
|
9322b7aeb8 | ||
|
|
c6444b2bf1 | ||
|
|
55075677eb | ||
|
|
733aa5f88a | ||
|
|
d704910857 | ||
|
|
ad6c21392f | ||
|
|
55e87c3b16 | ||
|
|
0c4da6c838 | ||
|
|
225c1166c6 | ||
|
|
97b91d3409 | ||
|
|
c953f6f44d | ||
|
|
d6b9fedbc7 | ||
|
|
720eb0c227 | ||
|
|
495c19b5e4 | ||
|
|
237ba96233 | ||
|
|
ae46d89de6 | ||
|
|
69f73e8c5d | ||
|
|
245f87ef35 | ||
|
|
81e51db0b6 | ||
|
|
4634496638 | ||
|
|
4e2f0a9346 | ||
|
|
304b3d4772 | ||
|
|
74629f3ef2 | ||
|
|
c9d48188af | ||
|
|
7e7b88a59e | ||
|
|
5c66dc198d | ||
|
|
47efce7a90 | ||
|
|
ea4d3f0ba7 | ||
|
|
2d03e27dcf | ||
|
|
cf7ff80f71 | ||
|
|
93ab75e42b | ||
|
|
449dc7da79 | ||
|
|
3b129a483a | ||
|
|
3e0d60bb1f | ||
|
|
1d3645fc26 | ||
|
|
18bff86214 | ||
|
|
114084107c | ||
|
|
d60f7238da | ||
|
|
ea684dceeb | ||
|
|
dfd496b41a | ||
|
|
6125b10276 | ||
|
|
071271f003 | ||
|
|
13a9df2e07 | ||
|
|
37a5a6702b | ||
|
|
afb68e97d6 | ||
|
|
93b9741af2 | ||
|
|
013d592454 | ||
|
|
0f56e9b80c | ||
|
|
6ec5ecb9ac | ||
|
|
460d439351 | ||
|
|
f39406dd80 | ||
|
|
9de8e75238 | ||
|
|
fee34b6bd1 | ||
|
|
cbe8699322 | ||
|
|
066e5551f8 | ||
|
|
1d73b736a9 | ||
|
|
8ae6635b9c | ||
|
|
574f0d580e | ||
|
|
0879515ee8 | ||
|
|
be40783454 | ||
|
|
946b9d005d | ||
|
|
f181d54ac3 | ||
|
|
d1a875c19a | ||
|
|
b9335ea89f | ||
|
|
90ce543295 | ||
|
|
aa3e2ddc8f | ||
|
|
ef57b88066 | ||
|
|
997e4ed296 | ||
|
|
a6bac4601e | ||
|
|
8eea10d509 | ||
|
|
a9f9125caf | ||
|
|
5364508219 | ||
|
|
98250268b0 | ||
|
|
bcf57351d6 | ||
|
|
965900574a | ||
|
|
46a9d527e1 | ||
|
|
70f8afb122 | ||
|
|
acaf98512c | ||
|
|
7cb898b40a | ||
|
|
8ddafb9af5 | ||
|
|
e31380a0c7 | ||
|
|
be60e2d763 | ||
|
|
1f3bcf68c7 | ||
|
|
057c95b0ea | ||
|
|
198368363e | ||
|
|
a934ddaa54 | ||
|
|
75773a4608 | ||
|
|
b11e1ecf4b | ||
|
|
a34d3ca7bc | ||
|
|
28f2879a05 | ||
|
|
2474259c22 | ||
|
|
79c2c5948f | ||
|
|
3c4bd8757f | ||
|
|
06f3443e56 | ||
|
|
88fd21f169 |
@@ -40,7 +40,7 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
@@ -61,9 +61,44 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
# PR 场景需要 base 提交做冻结清单的 append-only 比对。
|
||||
fetch-depth: ${{ github.event_name == 'pull_request' && 0 || 1 }}
|
||||
|
||||
# 已应用迁移的字节被 sqlx 启动校验钉死:改动已发布迁移会让所有
|
||||
# 存量部署重启即失败。冻结清单保证旧文件不被改、新文件必须入册。
|
||||
- name: Migration freeze check
|
||||
working-directory: backend/migrations
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sha256sum --check --strict .frozen-sha256
|
||||
for file in *.sql; do
|
||||
if ! awk '{print $2}' .frozen-sha256 | grep -Fxq "$file"; then
|
||||
echo "::error file=backend/migrations/$file::migration $file is not frozen; append its sha256 to backend/migrations/.frozen-sha256"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# 清单自证不足以防「改迁移 + 同 PR 刷新清单行」:校验对新哈希通过。
|
||||
# 对 PR,断言 .frozen-sha256 相对 base 只增不改——base 里的每一行
|
||||
# 必须原样保留(改哈希=删行,删迁移=删行,都会被挡)。
|
||||
if [ -n "${BASE_SHA:-}" ]; then
|
||||
base_manifest="$(git show "${BASE_SHA}:backend/migrations/.frozen-sha256" 2>/dev/null || true)"
|
||||
if [ -n "$base_manifest" ]; then
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
if ! grep -Fxq "$line" .frozen-sha256; then
|
||||
file="$(printf '%s' "$line" | awk '{print $2}')"
|
||||
echo "::error file=backend/migrations/.frozen-sha256::frozen migration $file was modified or removed; released migrations are append-only — add a new numbered migration instead"
|
||||
exit 1
|
||||
fi
|
||||
done <<< "$base_manifest"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
@@ -81,7 +116,29 @@ jobs:
|
||||
|
||||
- name: Rust clippy
|
||||
working-directory: backend
|
||||
run: cargo clippy --all-targets --all-features --locked
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Configure PostgreSQL and Redis test authentication
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
postgres_password="$(openssl rand -hex 24)"
|
||||
redis_password="$(openssl rand -hex 24)"
|
||||
echo "::add-mask::$postgres_password"
|
||||
echo "::add-mask::$redis_password"
|
||||
docker exec --env PGPASSWORD=codex_proxy "${{ job.services.postgres.id }}" psql \
|
||||
--username=codex_proxy --dbname=codex_proxy \
|
||||
--set=ON_ERROR_STOP=1 \
|
||||
--command="ALTER USER codex_proxy WITH PASSWORD '${postgres_password}'" >/dev/null
|
||||
docker exec --env PGPASSWORD="$postgres_password" "${{ job.services.postgres.id }}" psql \
|
||||
--username=codex_proxy --dbname=codex_proxy \
|
||||
--set=ON_ERROR_STOP=1 --command='SELECT 1' >/dev/null
|
||||
docker exec "${{ job.services.redis.id }}" redis-cli CONFIG SET requirepass "$redis_password" >/dev/null
|
||||
docker exec "${{ job.services.redis.id }}" redis-cli --no-auth-warning -a "$redis_password" ping >/dev/null
|
||||
{
|
||||
echo "CPR_TEST_DATABASE_URL=postgres://codex_proxy:${postgres_password}@127.0.0.1:5432/codex_proxy"
|
||||
echo "CPR_TEST_REDIS_URL=redis://:${redis_password}@127.0.0.1:6379"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: backend
|
||||
@@ -95,7 +152,7 @@ jobs:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
@@ -134,7 +191,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
docker_version: ${{ steps.version.outputs.docker_version }}
|
||||
build_time: ${{ steps.version.outputs.build_time }}
|
||||
git_sha: ${{ steps.version.outputs.git_sha }}
|
||||
platform_matrix: ${{ steps.version.outputs.platform_matrix }}
|
||||
@@ -40,7 +41,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
fetch-depth: 0
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
require_string(platform, key, "#{platforms_path}: platforms[#{index}]")
|
||||
end
|
||||
archive = platform["archive"]
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz or zip") unless ["tar.gz", "zip"].include?(archive)
|
||||
fail("#{platforms_path}: platforms[#{index}].archive must be tar.gz") unless archive == "tar.gz"
|
||||
platform_key = "#{platform["os"]}-#{platform["arch"]}"
|
||||
fail("#{platforms_path}: duplicate platform #{platform_key}") if seen_platforms[platform_key]
|
||||
seen_platforms[platform_key] = true
|
||||
@@ -146,6 +147,8 @@ jobs:
|
||||
|
||||
puts "tag=#{release_tag}"
|
||||
puts "version=#{version}"
|
||||
# Docker tags forbid "+", so semver build metadata maps to "-" in image tags.
|
||||
puts "docker_version=#{version.tr("+", "-")}"
|
||||
puts "build_time=#{Time.now.utc.iso8601}"
|
||||
puts "git_sha=#{git_sha.strip}"
|
||||
puts "platform_matrix=#{matrix}"
|
||||
@@ -174,7 +177,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -205,7 +208,7 @@ jobs:
|
||||
-v "$HOME/.cargo:/cargo" \
|
||||
-v "$PWD/..:/workspace" \
|
||||
-w /workspace/backend \
|
||||
rust:1.97-bookworm@sha256:7d0723df719e7f213b69dc7c8c595985c3f4b060cfbee4f7bc0e347a86fe3b6a \
|
||||
rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa \
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin codex-proxy-rs
|
||||
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "../dist/binaries/${{ matrix.os }}-${{ matrix.arch }}/${{ matrix.binary }}"
|
||||
working-directory: backend
|
||||
@@ -234,7 +237,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -286,7 +289,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -321,7 +324,6 @@ jobs:
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
|
||||
@@ -357,8 +359,7 @@ jobs:
|
||||
|
||||
binary_dest = package_root / platform["binary"]
|
||||
shutil.copy2(binary_src, binary_dest)
|
||||
if platform["os"] != "windows":
|
||||
binary_dest.chmod(0o755)
|
||||
binary_dest.chmod(0o755)
|
||||
shutil.copytree(frontend_dir, web_dist, dirs_exist_ok=True)
|
||||
|
||||
archive_name = f"{APP_NAME}_{version}_{platform['os']}_{platform['arch']}.{platform['archive']}"
|
||||
@@ -366,11 +367,6 @@ jobs:
|
||||
if platform["archive"] == "tar.gz":
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
archive.add(package_root, arcname=APP_NAME)
|
||||
elif platform["archive"] == "zip":
|
||||
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(package_root.rglob("*")):
|
||||
if path.is_file():
|
||||
archive.write(path, path.relative_to(package_dir))
|
||||
else:
|
||||
raise SystemExit(f"Unsupported archive format: {platform['archive']}")
|
||||
archives.append(archive_path)
|
||||
@@ -382,7 +378,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
@@ -412,7 +408,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -458,7 +454,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -512,7 +508,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
@@ -529,7 +525,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -539,7 +535,7 @@ jobs:
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
DOCKER_PLATFORMS: ${{ needs.release-info.outputs.docker_platforms }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
@@ -562,7 +558,7 @@ jobs:
|
||||
fi
|
||||
|
||||
target_args=(
|
||||
-t "${image}:${VERSION}"
|
||||
-t "${image}:${DOCKER_VERSION}"
|
||||
-t "${image}:sha-${GIT_SHA}"
|
||||
)
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
@@ -570,7 +566,7 @@ jobs:
|
||||
fi
|
||||
docker buildx imagetools create "${target_args[@]}" "${sources[@]}"
|
||||
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${VERSION}")"
|
||||
inspect_output="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}")"
|
||||
platform_list="$(awk '$1 == "Platform:" { print $2 }' <<< "${inspect_output}")"
|
||||
for platform in "${platforms[@]}"; do
|
||||
if ! grep -Eq "^${platform}(/|$)" <<< "${platform_list}"; then
|
||||
@@ -580,7 +576,7 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
digest="$(docker buildx imagetools inspect "${image}:${VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
digest="$(docker buildx imagetools inspect "${image}:${DOCKER_VERSION}" --format '{{ .Manifest.Digest }}')"
|
||||
if [ -z "${digest}" ]; then
|
||||
echo "missing image digest"
|
||||
exit 1
|
||||
@@ -588,7 +584,7 @@ jobs:
|
||||
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate container provenance attestation
|
||||
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
with:
|
||||
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
subject-digest: ${{ steps.manifest.outputs.digest }}
|
||||
@@ -598,7 +594,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
DIGEST: ${{ steps.manifest.outputs.digest }}
|
||||
VERSION: ${{ needs.release-info.outputs.version }}
|
||||
DOCKER_VERSION: ${{ needs.release-info.outputs.docker_version }}
|
||||
GIT_SHA: ${{ needs.release-info.outputs.git_sha }}
|
||||
PRERELEASE: ${{ needs.release-info.outputs.prerelease }}
|
||||
run: |
|
||||
@@ -609,7 +605,7 @@ jobs:
|
||||
fi
|
||||
|
||||
image="${REGISTRY}/${IMAGE_NAME}"
|
||||
tags=("${image}:${VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
tags=("${image}:${DOCKER_VERSION}" "${image}:sha-${GIT_SHA}")
|
||||
if [ "$PRERELEASE" != true ]; then
|
||||
tags+=("${image}:latest")
|
||||
fi
|
||||
@@ -629,12 +625,15 @@ jobs:
|
||||
prerelease: ${{ needs.release-info.outputs.prerelease == 'true' }}
|
||||
make_latest: ${{ needs.release-info.outputs.prerelease == 'true' && 'false' || 'true' }}
|
||||
body: |
|
||||
> [!WARNING]
|
||||
> **Upgrading across major versions?** Database schema and configuration are only compatible within a major version, and the one-click self-update refuses cross-major targets (releases before 3.0 lack this gate — do not use their built-in update to reach a newer major). Deploy the new major fresh (new `.runtime/` data directory) and re-import or re-authorize provider accounts and client keys. See the [upgrade notes](https://github.com/${{ github.repository }}/blob/main/deploy/README.md#镜像升级与源码构建).
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
**Docker:**
|
||||
```bash
|
||||
# GitHub Container Registry
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.version }}
|
||||
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.release-info.outputs.docker_version }}
|
||||
```
|
||||
|
||||
**Manual download:**
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Rust
|
||||
/backend/target/
|
||||
/backend/**/target/
|
||||
*.profraw
|
||||
*.profdata
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
|
||||
# Codex Proxy RS
|
||||
|
||||
**基于 Rust 的高性能 Codex 多账号网关代理**
|
||||
**基于 Rust 的多 Provider 透明 AI 网关**
|
||||
|
||||
[](https://github.com/zyycn/codex-proxy-rs/actions/workflows/ci.yml)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
[](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
[](https://opensource.org/license/mit)
|
||||
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署文档](deploy/README.md) · [架构](docs/architecture.md)
|
||||
[快速开始](#快速开始) · [客户端接入](#客户端接入) · [运维](#运维) · [部署](deploy/README.md) · [接口](docs/api.md) · [架构](docs/architecture.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,15 +21,16 @@
|
||||
|
||||
## 能力
|
||||
|
||||
| 领域 | 实现 |
|
||||
| -------- | ------------------------------------------------ |
|
||||
| 协议 | Responses HTTP、SSE、官方 WebSocket |
|
||||
| 传输 | 会话级 WebSocket 池、共享 HTTP/2 后备 |
|
||||
| 调度 | 智能调度、额度优先、轮询、粘滞 |
|
||||
| 故障处理 | 额度、认证、封禁、模型与传输故障切换 |
|
||||
| 身份 | 账号身份隔离、稳定 installation ID、会话拓扑透传 |
|
||||
| 管理 | 账号、客户端 Key、模型别名、运行设置与在线更新 |
|
||||
| 遥测 | Token、费用、缓存、TTFT、延迟与错误 |
|
||||
| 领域 | 实现 |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
|
||||
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
|
||||
| 透明边界 | OpenAI 请求保留未知字段和字段顺序,SSE 与 WebSocket 原样转发业务字节;xAI 在 Provider 内做协议转换 |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
|
||||
| 账号 | 导入、OAuth、credential 刷新、主动额度刷新,以及正常响应中的被动额度/状态观测 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因,并记录响应确认的实际 `service_tier` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -48,19 +49,23 @@ sudo chown "$(id -u):10001" deploy/config.yaml
|
||||
chmod 0640 deploy/config.yaml
|
||||
```
|
||||
|
||||
分别执行三次:
|
||||
分别生成 PostgreSQL 与 Redis 密码:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 24
|
||||
openssl rand -hex 24
|
||||
```
|
||||
|
||||
将结果写入 `deploy/config.yaml`:
|
||||
把两个结果写入 `deploy/config.yaml`,并单独设置管理员初始密码:
|
||||
|
||||
| 配置 | 约束 |
|
||||
| ------------------------------ | ------------------------ |
|
||||
| `x-cpr.database.password` | 48 位十六进制 |
|
||||
| `x-cpr.redis.password` | 48 位十六进制 |
|
||||
| `x-cpr.admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
| 配置 | 约束 |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `store.database.password` | 48 位十六进制 |
|
||||
| `store.redis.password` | 48 位十六进制 |
|
||||
| `admin.default_password` | 至少 12 位,不能包含 `$` |
|
||||
|
||||
PostgreSQL、Redis 容器和应用通过同一份 `config.yaml` 使用这两个基础设施密码,不需要额外
|
||||
导出环境变量。管理员密码只在首次创建管理员时生效。
|
||||
|
||||
Linux 需要允许容器组写入运行目录:
|
||||
|
||||
@@ -84,9 +89,18 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
### 3. 初始化
|
||||
|
||||
1. 使用 `admin@cpr.local` 与初始密码登录。
|
||||
2. 添加 Codex 账号并执行连接测试。
|
||||
3. 创建 `sk_...` 客户端 Key。
|
||||
4. 复制 Codex CLI 配置,或导入 CCSwitch。
|
||||
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
|
||||
和兼容账号文件导入。
|
||||
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
|
||||
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置速率与并发限制。
|
||||
|
||||
OpenAI 账号导入和首次 OAuth 创建在 credential 提交后会立即尝试一次额度观测;额度接口临时失败不会
|
||||
回滚已提交的账号。Free、K12 等套餐共用同一套额度与状态转换逻辑,套餐字段只作为账号事实并隔离
|
||||
对应的模型目录 cache。credential 刷新、额度刷新和正常请求中的被动额度观测是三条独立链路,详见
|
||||
[账号接口](docs/api.md#5-账号)。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> xAI 使用 OAuth session,不支持把 xAI API Key 作为上游 credential。
|
||||
|
||||
> [!NOTE]
|
||||
> `admin.default_password` 只在首次创建管理员时生效。
|
||||
@@ -118,23 +132,26 @@ curl http://127.0.0.1:8080/v1/responses \
|
||||
<details>
|
||||
<summary>API 路由</summary>
|
||||
|
||||
| 路由 | 用途 |
|
||||
| --------------------------- | ------------------- |
|
||||
| `POST /v1/responses` | JSON 或 SSE |
|
||||
| `GET /v1/responses` | Responses WebSocket |
|
||||
| `POST /v1/responses/review` | Review |
|
||||
| `GET /v1/models` | 模型列表 |
|
||||
| `GET /v1/models/catalog` | 模型目录 |
|
||||
| `GET /v1/models/{model_id}` | 模型详情 |
|
||||
| 路由 | 用途 |
|
||||
| ---------------------------------- | ------------------------------ |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/responses/review` | review 子代理请求 |
|
||||
| `GET /v1/models` | 当前 Client Key 可用的模型列表 |
|
||||
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
|
||||
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要客户端 API Key。
|
||||
|
||||
</details>
|
||||
|
||||
完整的客户端与管理端路由、鉴权和 mutation 规则见 [接口文档](docs/api.md)。
|
||||
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
# 升级
|
||||
# 实例升级
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
|
||||
@@ -147,14 +164,34 @@ docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存数据库、Redis、身份密钥和日志。删除该目录会永久清除运行状态。
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
|
||||
> 运行状态。
|
||||
|
||||
> [!WARNING]
|
||||
> 跨大版本不是原地升级:数据库 schema 与配置只在同一大版本内兼容,内置一键自更新
|
||||
> 会拒绝跨大版本目标(3.0 之前的旧版没有这道门禁,不要用旧版界面的一键更新跨大版本)。
|
||||
> 升级到新的大版本需要全新部署(全新 `.runtime/` 数据目录),并重新导入或重新授权
|
||||
> Provider 账号与客户端 Key。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
|
||||
## 维护与发布
|
||||
|
||||
发布仓库版本必须从干净、已同步上游的发布分支运行:
|
||||
|
||||
```bash
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
|
||||
不要手工修改版本后单独打 tag。该脚本只发布仓库版本和触发 Release 构建,不会登录服务器,也不会
|
||||
改变任何运行实例;源码提交、仓库发版和实例升级是三个独立状态。
|
||||
|
||||
## 文档
|
||||
|
||||
- [部署](deploy/README.md)
|
||||
- [架构](docs/architecture.md)
|
||||
- [客户端与管理端接口](docs/api.md)
|
||||
- [架构与数据边界](docs/architecture.md)
|
||||
- [Release](https://github.com/zyycn/codex-proxy-rs/releases)
|
||||
- [容器镜像](https://github.com/zyycn/codex-proxy-rs/pkgs/container/codex-proxy-rs)
|
||||
|
||||
Generated
+912
-126
File diff suppressed because it is too large
Load Diff
+18
-62
@@ -1,73 +1,29 @@
|
||||
[package]
|
||||
name = "codex-proxy-rs"
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/gateway",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
"crates/providers/xai",
|
||||
]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "json", "query", "tokio", "tracing"] }
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde", "clock"] }
|
||||
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
flate2 = "1.1.9"
|
||||
futures = "0.3.32"
|
||||
hex = "0.4.3"
|
||||
hmac = "0.13.0"
|
||||
hyper = { version = "1.10.1", features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1.20", features = ["tokio"] }
|
||||
indexmap = { version = "2.0", features = ["serde"] }
|
||||
ipnet = "2.12.0"
|
||||
rand = "0.10.1"
|
||||
# Enables password_hash::rand_core::OsRng for argon2 0.5.
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
redis = { version = "1.3.0", default-features = false, features = ["tokio-comp", "connection-manager", "script"] }
|
||||
roxmltree = "0.21.1"
|
||||
# Pinned to the Codex Desktop 26.707.41301 HTTP stack; re-audit a newer Desktop before upgrading.
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "cookies", "rustls-tls-native-roots", "gzip", "brotli", "zstd", "deflate", "http2"] }
|
||||
rustls = { version = "=0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
|
||||
rustls-native-certs = "0.8.4"
|
||||
rustls-pki-types = "1.14.1"
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
semver = "1.0.27"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["preserve_order"] }
|
||||
sha2 = "0.11.0"
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "json"] }
|
||||
subtle = "2.6.1"
|
||||
tar = "0.4.44"
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt-multi-thread", "signal", "fs", "time", "sync", "net"] }
|
||||
tokio-util = { version = "0.7.18", features = ["rt"] }
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["proxy", "rustls-tls-native-roots"] }
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
tower-http = { version = "0.6.11", features = ["trace", "cors", "request-id", "timeout", "fs"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-appender = "0.2.5"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
|
||||
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v4", "v7", "serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
sqlx = { version = "0.9.0", default-features = false, features = ["derive"] }
|
||||
syn = { version = "2.0.118", features = ["full", "visit"] }
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["test-util"] }
|
||||
wiremock = "0.6.5"
|
||||
|
||||
[patch.crates-io]
|
||||
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" }
|
||||
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" }
|
||||
|
||||
[lints.rust]
|
||||
[workspace.lints.rust]
|
||||
warnings = "deny"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[lints.clippy]
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "deny", priority = 10 }
|
||||
redundant_clone = { level = "deny", priority = 9 }
|
||||
large_enum_variant = { level = "warn", priority = 8 }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "codex-proxy-rs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
rust-version.workspace = true
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gateway-admin = { path = "../../crates/gateway-admin" }
|
||||
gateway-api = { path = "../../crates/gateway-api" }
|
||||
gateway-core = { path = "../../crates/gateway-core" }
|
||||
gateway-host = { path = "../../crates/gateway-host" }
|
||||
gateway-store = { path = "../../crates/gateway-store" }
|
||||
provider-openai = { path = "../../crates/providers/openai" }
|
||||
provider-xai = { path = "../../crates/providers/xai" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-thread"] }
|
||||
|
||||
[dev-dependencies]
|
||||
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
syn = { version = "3.0.3", features = ["full"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 网关唯一组合根:加载包级配置并连接各 Bundle。
|
||||
|
||||
use gateway_core::engine::provider::{ProviderRegistry, RegistryError};
|
||||
use gateway_host::{ConfigError, HostConfig, LoadableConfig};
|
||||
use serde::Deserialize;
|
||||
|
||||
const CONFIG_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// 顶层配置只组合各包拥有的配置段,不解释任何业务字段。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GatewayConfig {
|
||||
schema_version: u32,
|
||||
host: HostConfig,
|
||||
store: gateway_store::StoreConfig,
|
||||
admin: gateway_admin::AdminConfig,
|
||||
api: gateway_api::ApiConfig,
|
||||
openai: provider_openai::OpenAiConfig,
|
||||
xai: provider_xai::XaiConfig,
|
||||
#[serde(default, rename = "services")]
|
||||
_compose_services: Option<serde::de::IgnoredAny>,
|
||||
}
|
||||
|
||||
impl LoadableConfig for GatewayConfig {
|
||||
fn resolve_and_validate(&mut self, source_dir: &std::path::Path) -> Result<(), ConfigError> {
|
||||
if self.schema_version != CONFIG_SCHEMA_VERSION {
|
||||
return Err(ConfigError::InvalidField("schema_version"));
|
||||
}
|
||||
self.host.resolve_and_validate(source_dir)?;
|
||||
self.store
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("store"))?;
|
||||
self.admin
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("admin"))?;
|
||||
self.api
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("api"))?;
|
||||
self.openai
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("openai"))?;
|
||||
self.xai
|
||||
.resolve_and_validate(source_dir)
|
||||
.map_err(|_| ConfigError::InvalidField("xai"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 按冻结顺序初始化全部 Bundle,并把进程阻塞权交给 Host。
|
||||
pub async fn run() -> Result<(), BootstrapError> {
|
||||
let config = gateway_host::load_config::<GatewayConfig>()?;
|
||||
let GatewayConfig {
|
||||
schema_version: _,
|
||||
host,
|
||||
store,
|
||||
admin,
|
||||
api,
|
||||
openai,
|
||||
xai,
|
||||
_compose_services: _,
|
||||
} = config;
|
||||
|
||||
let host = gateway_host::initialize(host).await?;
|
||||
host.report_startup_ready("Host");
|
||||
let mut store = gateway_store::initialize(store).await?;
|
||||
host.report_startup_ready("Store");
|
||||
let provider_ports = store.provider_ports();
|
||||
let mut openai = provider_openai::initialize(openai, provider_ports.clone()).await?;
|
||||
host.report_startup_ready("OpenAI Provider");
|
||||
let mut xai = provider_xai::initialize(xai, provider_ports).await?;
|
||||
host.report_startup_ready("xAI Provider");
|
||||
let providers = ProviderRegistry::new([openai.core_provider(), xai.core_provider()])?;
|
||||
let mut core = gateway_core::initialize(store.core_ports(), providers).await?;
|
||||
host.report_startup_ready("Core");
|
||||
let admin = gateway_admin::initialize(
|
||||
admin,
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
host.system_operations(),
|
||||
)
|
||||
.await?;
|
||||
host.report_startup_ready("Admin");
|
||||
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
let api = gateway_api::initialize(
|
||||
api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
probes,
|
||||
host.worker_health(),
|
||||
host.connection_lifecycle(),
|
||||
)?;
|
||||
host.report_startup_ready("API");
|
||||
|
||||
let mut plan = store.take_worker_contributions();
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
host.start_workers(plan, store.worker_leader_lease())?;
|
||||
host.report_startup_ready("Workers");
|
||||
host.serve(api.router()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 组合根只保留包级错误分类,不展开内部实现或敏感配置。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BootstrapError {
|
||||
#[error(transparent)]
|
||||
Config(#[from] gateway_host::ConfigError),
|
||||
#[error(transparent)]
|
||||
Host(#[from] gateway_host::HostError),
|
||||
#[error(transparent)]
|
||||
Store(#[from] gateway_store::StoreError),
|
||||
#[error(transparent)]
|
||||
OpenAi(#[from] provider_openai::OpenAiInitializeError),
|
||||
#[error(transparent)]
|
||||
Xai(#[from] provider_xai::XaiInitializeError),
|
||||
#[error(transparent)]
|
||||
Registry(#[from] RegistryError),
|
||||
#[error(transparent)]
|
||||
Core(#[from] gateway_core::CoreError),
|
||||
#[error(transparent)]
|
||||
Admin(#[from] gateway_admin::model::AdminError),
|
||||
#[error(transparent)]
|
||||
Api(#[from] gateway_api::ApiError),
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! 网关进程组装根。
|
||||
|
||||
pub mod bootstrap;
|
||||
@@ -0,0 +1,32 @@
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
match arguments.next().as_deref().and_then(|value| value.to_str()) {
|
||||
None | Some("serve") => {
|
||||
reject_extra_arguments(arguments)?;
|
||||
runtime()?.block_on(codex_proxy_rs::bootstrap::run())?;
|
||||
Ok(())
|
||||
}
|
||||
Some(command) => {
|
||||
Err(invalid_cli(&format!("unknown command {command:?}; expected serve")).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime() -> Result<tokio::runtime::Runtime, io::Error> {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
}
|
||||
|
||||
fn reject_extra_arguments(mut arguments: impl Iterator) -> Result<(), io::Error> {
|
||||
if arguments.next().is_some() {
|
||||
return Err(invalid_cli("unexpected extra command arguments"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn invalid_cli(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidInput, message)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
"crates/gateway-core",
|
||||
"crates/gateway-host",
|
||||
"crates/gateway-protocol",
|
||||
"crates/gateway-store",
|
||||
"crates/providers/openai",
|
||||
"crates/providers/xai",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn workspace_member_list_matches_the_frozen_dag_scope() {
|
||||
let manifest =
|
||||
fs::read_to_string(backend_root().join("Cargo.toml")).expect("read workspace manifest");
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
assert!(
|
||||
manifest.contains(&format!("\"{member}\"")),
|
||||
"workspace must include {member}"
|
||||
);
|
||||
}
|
||||
let member_count = manifest
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| line.starts_with("\"apps/") || line.starts_with("\"crates/"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
member_count,
|
||||
WORKSPACE_MEMBERS.len(),
|
||||
"new workspace members must extend the dependency DAG rules"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_core_depends_on_no_http_db_redis_or_provider_crate() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-core",
|
||||
&[
|
||||
"axum",
|
||||
"hyper",
|
||||
"reqwest",
|
||||
"sqlx",
|
||||
"redis",
|
||||
"gateway-host",
|
||||
"gateway-store",
|
||||
"provider-openai",
|
||||
"provider-xai",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_protocol_has_no_workspace_dependencies() {
|
||||
for name in dependency_names("crates/gateway-protocol") {
|
||||
assert!(
|
||||
!name.starts_with("gateway-") && !name.starts_with("provider-"),
|
||||
"gateway-protocol must not depend on workspace crate `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_crates_do_not_depend_on_each_other() {
|
||||
assert_no_dependency("crates/providers/openai", &["provider-xai"]);
|
||||
assert_no_dependency("crates/providers/xai", &["provider-openai"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_admin_stays_free_of_infrastructure_dependencies() {
|
||||
assert_no_dependency(
|
||||
"crates/gateway-admin",
|
||||
&["axum", "sqlx", "redis", "reqwest"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let src = backend_root().join(member).join("src");
|
||||
let sources = super::rust_files(&src);
|
||||
assert!(!sources.is_empty(), "{member} has no production sources");
|
||||
for relative in sources {
|
||||
let path = src.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("#[cfg(test)]"),
|
||||
"{} hosts tests in production src",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.expect("backend workspace root")
|
||||
}
|
||||
|
||||
/// 提取成员 `[dependencies]` 段内声明的依赖名;段落以下一个 `[` 表头结束。
|
||||
fn dependency_names(member: &str) -> Vec<String> {
|
||||
let manifest = fs::read_to_string(backend_root().join(member).join("Cargo.toml"))
|
||||
.expect("read member manifest");
|
||||
let mut in_dependencies = false;
|
||||
let mut names = Vec::new();
|
||||
for line in manifest.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('[') {
|
||||
in_dependencies = line == "[dependencies]";
|
||||
continue;
|
||||
}
|
||||
if !in_dependencies || line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((name, _)) = line.split_once('=') {
|
||||
names.push(name.trim().to_owned());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn assert_no_dependency(member: &str, forbidden: &[&str]) {
|
||||
for name in dependency_names(member) {
|
||||
assert!(
|
||||
!forbidden.contains(&name.as_str()),
|
||||
"{member} must not depend on `{name}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
use std::{fs, process::Command};
|
||||
|
||||
use codex_proxy_rs::bootstrap::GatewayConfig;
|
||||
use gateway_host::LoadableConfig;
|
||||
|
||||
const CONFIG_EXAMPLE: &str = include_str!("../../../../deploy/config.example.yaml");
|
||||
const POSTGRES_PASSWORD: &str = "111111111111111111111111111111111111111111111111";
|
||||
const REDIS_PASSWORD: &str = "222222222222222222222222222222222222222222222222";
|
||||
const ADMIN_PASSWORD: &str = "test-admin-password";
|
||||
const TOPOLOGY_CHILD_ENV: &str = "CPR_TEST_TOPOLOGY_CHILD";
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_load_complete_terminal_example() {
|
||||
parse_config(&valid_config()).expect("terminal config example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_resolve_paths_relative_to_config_file() {
|
||||
let (config, _directory) = parse_config(&valid_config()).expect("resolved config");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(debug.contains(".runtime/logs"));
|
||||
assert!(debug.contains("frontend/dist"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_inject_connection_passwords_into_urls() {
|
||||
parse_config(&valid_config()).expect("Store validates password injection into both URLs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_apply_only_explicit_topology_overrides() {
|
||||
let invalid = valid_config()
|
||||
.replace("host: '127.0.0.1'", "host: ''")
|
||||
.replace("port: 8080", "port: 0")
|
||||
.replace(
|
||||
"url: 'postgres://codex_proxy@127.0.0.1:5432/codex_proxy'",
|
||||
"url: 'invalid-postgres-url'",
|
||||
)
|
||||
.replace("url: 'redis://127.0.0.1:6379/'", "url: 'invalid-redis-url'")
|
||||
.replace(POSTGRES_PASSWORD, "invalid-postgres-password")
|
||||
.replace(REDIS_PASSWORD, "invalid-redis-password");
|
||||
if std::env::var_os(TOPOLOGY_CHILD_ENV).is_some() {
|
||||
parse_config(&invalid).expect("explicit package-owned environment overrides");
|
||||
return;
|
||||
}
|
||||
assert!(parse_config(&invalid).is_err());
|
||||
let status = Command::new(std::env::current_exe().expect("current test executable"))
|
||||
.args([
|
||||
"--exact",
|
||||
"bootstrap::config_loader_should_apply_only_explicit_topology_overrides",
|
||||
])
|
||||
.env(TOPOLOGY_CHILD_ENV, "1")
|
||||
.env("CPR_SERVER_HOST", "127.0.0.1")
|
||||
.env("CPR_SERVER_PORT", "8080")
|
||||
.env(
|
||||
"CPR_DATABASE_URL",
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
)
|
||||
.env("CPR_REDIS_URL", "redis://127.0.0.1:6379/")
|
||||
.env("CPR_DATABASE_PASSWORD", POSTGRES_PASSWORD)
|
||||
.env("CPR_REDIS_PASSWORD", REDIS_PASSWORD)
|
||||
.status()
|
||||
.expect("run isolated environment override test");
|
||||
assert!(status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_config_debug_should_redact_all_passwords() {
|
||||
let (config, _directory) = parse_config(&valid_config()).expect("config");
|
||||
let debug = format!("{config:?}");
|
||||
assert!(!debug.contains(POSTGRES_PASSWORD));
|
||||
assert!(!debug.contains(REDIS_PASSWORD));
|
||||
assert!(!debug.contains(ADMIN_PASSWORD));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unknown_fields() {
|
||||
assert_rejected(format!(
|
||||
"{}\nunknown_terminal_field: true\n",
|
||||
valid_config()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_tls_section() {
|
||||
assert_rejected(valid_config().replace("openai:\n", "openai:\n tls: {}\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_missing_explicit_fields() {
|
||||
assert_rejected(valid_config().replace(" request_id_header: 'x-request-id'\n", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_unsupported_schema_version() {
|
||||
assert_rejected(valid_config().replace("schema_version: 1", "schema_version: 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_embedded_database_password() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"postgres://codex_proxy@127.0.0.1:5432/codex_proxy",
|
||||
"postgres://codex_proxy:embedded@127.0.0.1:5432/codex_proxy",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_non_hex_postgres_password() {
|
||||
assert_rejected(valid_config().replace(POSTGRES_PASSWORD, &"g".repeat(48)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_wrong_length_redis_password() {
|
||||
assert_rejected(valid_config().replace(REDIS_PASSWORD, "1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_weak_admin_password() {
|
||||
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_admin_password_with_compose_interpolation() {
|
||||
assert_rejected(valid_config().replace(ADMIN_PASSWORD, "unsafe$password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"openai:\n",
|
||||
"openai:\n fingerprint:\n browser: removed\n",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_core_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.145.0'", "codex_version: 'latest'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_disabled_all_log_outputs() {
|
||||
assert_rejected(
|
||||
valid_config()
|
||||
.replace("stdout: true", "stdout: false")
|
||||
.replace("enabled: true", "enabled: false"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_zero_server_port() {
|
||||
assert_rejected(valid_config().replace("port: 8080", "port: 0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '5828'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
assert!(parse_config(&config).is_err());
|
||||
}
|
||||
|
||||
fn valid_config() -> String {
|
||||
CONFIG_EXAMPLE
|
||||
.replacen(
|
||||
"password: &postgres_password ''",
|
||||
&format!("password: &postgres_password '{POSTGRES_PASSWORD}'"),
|
||||
1,
|
||||
)
|
||||
.replacen(
|
||||
"password: &redis_password ''",
|
||||
&format!("password: &redis_password '{REDIS_PASSWORD}'"),
|
||||
1,
|
||||
)
|
||||
.replace(
|
||||
"default_password: ''",
|
||||
&format!("default_password: '{ADMIN_PASSWORD}'"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_config(config: &str) -> Result<(GatewayConfig, tempfile::TempDir), String> {
|
||||
let directory = tempfile::tempdir().map_err(|error| error.to_string())?;
|
||||
let deploy = directory.path().join("deploy");
|
||||
fs::create_dir(&deploy).map_err(|error| error.to_string())?;
|
||||
let path = deploy.join("config.yaml");
|
||||
fs::write(&path, config).map_err(|error| error.to_string())?;
|
||||
let mut config = config::Config::builder()
|
||||
.add_source(config::File::from(path).required(true))
|
||||
.build()
|
||||
.and_then(config::Config::try_deserialize::<GatewayConfig>)
|
||||
.map_err(|error| error.to_string())?;
|
||||
config
|
||||
.resolve_and_validate(&deploy)
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((config, directory))
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
use std::{
|
||||
collections::BTreeSet,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use syn::{Attribute, Item, Meta};
|
||||
|
||||
#[test]
|
||||
fn app_tree_matches_frozen_terminal_manifest() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
assert_eq!(
|
||||
rust_files(&root.join("src")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("lib.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
assert_eq!(
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_library_root_is_conventional_lib() {
|
||||
let manifest = fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"))
|
||||
.expect("read app Cargo.toml");
|
||||
assert!(!manifest.contains("[lib]"));
|
||||
assert!(
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/lib.rs")
|
||||
.is_file()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
for relative in ["bootstrap.rs", "lib.rs", "main.rs"] {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
path.display()
|
||||
);
|
||||
let syntax = syn::parse_file(&source).expect("parse production source");
|
||||
for item in &syntax.items {
|
||||
if let Item::Mod(module) = item {
|
||||
assert!(
|
||||
module.content.is_none(),
|
||||
"{} has an inline module",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
for attribute in item_attrs(item) {
|
||||
assert!(
|
||||
!is_path_or_test_cfg(attribute),
|
||||
"{} has a production test/path hook",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_owns_only_bundle_wiring() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let bootstrap = fs::read_to_string(root.join("bootstrap.rs")).expect("read bootstrap");
|
||||
for forbidden in [
|
||||
"sqlx::",
|
||||
"redis::",
|
||||
"Repository",
|
||||
"impl Provider",
|
||||
"impl ExecutionService",
|
||||
"tokio::spawn",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
] {
|
||||
assert!(
|
||||
!bootstrap.contains(forbidden),
|
||||
"bootstrap owns `{forbidden}`"
|
||||
);
|
||||
}
|
||||
assert!(bootstrap.lines().count() <= 300);
|
||||
}
|
||||
|
||||
fn rust_files(root: &Path) -> BTreeSet<PathBuf> {
|
||||
let mut result = BTreeSet::new();
|
||||
collect_rust_files(root, root, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
fn collect_rust_files(root: &Path, current: &Path, result: &mut BTreeSet<PathBuf>) {
|
||||
for entry in fs::read_dir(current).expect("read app tree") {
|
||||
let path = entry.expect("read app tree entry").path();
|
||||
if path.is_dir() {
|
||||
collect_rust_files(root, &path, result);
|
||||
} else if path.extension().is_some_and(|extension| extension == "rs") {
|
||||
result.insert(
|
||||
path.strip_prefix(root)
|
||||
.expect("relative app path")
|
||||
.to_path_buf(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_path_or_test_cfg(attribute: &Attribute) -> bool {
|
||||
if attribute.path().is_ident("path") {
|
||||
return true;
|
||||
}
|
||||
if !attribute.path().is_ident("cfg") && !attribute.path().is_ident("cfg_attr") {
|
||||
return false;
|
||||
}
|
||||
let Meta::List(list) = &attribute.meta else {
|
||||
return false;
|
||||
};
|
||||
list.tokens
|
||||
.to_string()
|
||||
.split(|character: char| !character.is_ascii_alphanumeric() && character != '_')
|
||||
.any(|segment| segment == "test" || segment.starts_with("test_"))
|
||||
}
|
||||
|
||||
fn item_attrs(item: &Item) -> &[Attribute] {
|
||||
match item {
|
||||
Item::Const(item) => &item.attrs,
|
||||
Item::Enum(item) => &item.attrs,
|
||||
Item::ExternCrate(item) => &item.attrs,
|
||||
Item::Fn(item) => &item.attrs,
|
||||
Item::ForeignMod(item) => &item.attrs,
|
||||
Item::Impl(item) => &item.attrs,
|
||||
Item::Macro(item) => &item.attrs,
|
||||
Item::Mod(item) => &item.attrs,
|
||||
Item::Static(item) => &item.attrs,
|
||||
Item::Struct(item) => &item.attrs,
|
||||
Item::Trait(item) => &item.attrs,
|
||||
Item::TraitAlias(item) => &item.attrs,
|
||||
Item::Type(item) => &item.attrs,
|
||||
Item::Union(item) => &item.attrs,
|
||||
Item::Use(item) => &item.attrs,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "gateway-admin"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
rust-version.workspace = true
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
chrono = "0.4.45"
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
hex = "0.4.3"
|
||||
rand_core = { version = "0.6.4", features = ["getrandom"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
secrecy = { version = "0.10.3", features = ["serde"] }
|
||||
subtle = "2.6.1"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,290 @@
|
||||
//! 管理控制面的语义模型、用例与外部能力端口。
|
||||
//!
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
|
||||
|
||||
use std::{fmt, path::Path, sync::Arc};
|
||||
|
||||
use gateway_core::{
|
||||
engine::probe::AccountProbe,
|
||||
routing::{ProviderKind, snapshot::SnapshotControl},
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod model;
|
||||
pub mod ports;
|
||||
mod use_case;
|
||||
|
||||
pub use use_case::{
|
||||
accounts::AccountsService, auth::AuthService, client_keys::ClientKeyService,
|
||||
observability::ObservabilityService, openai::OpenAiService, settings::SettingsService,
|
||||
system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
use ports::{
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::AdminStorePorts,
|
||||
system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
accounts::DefaultAccountsService, auth::DefaultAuthService,
|
||||
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
|
||||
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
|
||||
xai::DefaultXaiService,
|
||||
};
|
||||
|
||||
const OPENAI_PROVIDER_KIND: &str = "openai";
|
||||
const XAI_PROVIDER_KIND: &str = "xai";
|
||||
const MINIMUM_INITIAL_PASSWORD_BYTES: usize = 12;
|
||||
const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
"",
|
||||
"admin",
|
||||
"123456",
|
||||
"password",
|
||||
"changeme",
|
||||
"change-me",
|
||||
"replace-me",
|
||||
"codex-proxy-rs",
|
||||
];
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct InitialAdminPassword(SecretString);
|
||||
|
||||
impl InitialAdminPassword {
|
||||
#[must_use]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(SecretString::from(value.into()))
|
||||
}
|
||||
|
||||
fn expose(&self) -> &str {
|
||||
self.0.expose_secret()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for InitialAdminPassword {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.expose() == other.expose()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for InitialAdminPassword {}
|
||||
|
||||
impl fmt::Debug for InitialAdminPassword {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("InitialAdminPassword([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理控制面的启动配置。
|
||||
#[derive(Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AdminConfig {
|
||||
pub session_ttl_minutes: u64,
|
||||
pub default_username: String,
|
||||
pub default_password: InitialAdminPassword,
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
/// 校验 Admin-owned 字段;当前配置不含相对路径。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 用户名、会话有效期或初始密码不满足安全约束时返回错误。
|
||||
pub fn resolve_and_validate(&mut self, _source_dir: &Path) -> Result<(), AdminConfigError> {
|
||||
if self.default_username.trim().is_empty()
|
||||
|| self.default_username.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminConfigError::InvalidField("admin.default_username"));
|
||||
}
|
||||
if self.session_ttl_minutes == 0 || i64::try_from(self.session_ttl_minutes).is_err() {
|
||||
return Err(AdminConfigError::InvalidField("admin.session_ttl_minutes"));
|
||||
}
|
||||
let password = self.default_password.expose().trim();
|
||||
if password.len() < MINIMUM_INITIAL_PASSWORD_BYTES
|
||||
|| password.contains('$')
|
||||
|| WEAK_INITIAL_PASSWORDS.contains(&password.to_ascii_lowercase().as_str())
|
||||
{
|
||||
return Err(AdminConfigError::WeakInitialPassword);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AdminConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminConfig")
|
||||
.field("session_ttl_minutes", &self.session_ttl_minutes)
|
||||
.field("default_username", &self.default_username)
|
||||
.field("default_password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin-owned 启动配置错误;不回显任何配置值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AdminConfigError {
|
||||
#[error("configuration field `{0}` is invalid")]
|
||||
InvalidField(&'static str),
|
||||
#[error("admin.default_password does not meet the initial password policy")]
|
||||
WeakInitialPassword,
|
||||
}
|
||||
|
||||
/// API 持有的管理资源能力集合。
|
||||
///
|
||||
/// 字段全部私有;调用方经 accessor 直接调用能力,不需要命名内部 `use_case` 模块。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminServices {
|
||||
auth: Arc<dyn AuthService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
openai: Arc<dyn OpenAiService>,
|
||||
xai: Arc<dyn XaiService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> &dyn AuthService {
|
||||
self.auth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> &dyn AccountsService {
|
||||
self.accounts.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_keys(&self) -> &dyn ClientKeyService {
|
||||
self.client_keys.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> &dyn ObservabilityService {
|
||||
self.observability.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> &dyn SettingsService {
|
||||
self.settings.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn system(&self) -> &dyn SystemService {
|
||||
self.system.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn openai(&self) -> &dyn OpenAiService {
|
||||
self.openai.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn xai(&self) -> &dyn XaiService {
|
||||
self.xai.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 初始化完成后的封闭能力包。
|
||||
pub struct AdminBundle {
|
||||
services: AdminServices,
|
||||
}
|
||||
|
||||
impl AdminBundle {
|
||||
#[must_use]
|
||||
pub fn services(&self) -> AdminServices {
|
||||
self.services.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 配置非法、Provider 注册冲突/缺失或默认管理员初始化失败时返回错误。
|
||||
pub async fn initialize(
|
||||
mut config: AdminConfig,
|
||||
store: AdminStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(|error| AdminError::invalid(error.to_string()))?;
|
||||
let registry = ProviderAdminRegistry::new(providers).map_err(map_provider_registry_error)?;
|
||||
let openai = registry
|
||||
.require(&provider_kind(OPENAI_PROVIDER_KIND)?)
|
||||
.map_err(map_provider_registry_error)?;
|
||||
let xai = registry
|
||||
.require(&provider_kind(XAI_PROVIDER_KIND)?)
|
||||
.map_err(map_provider_registry_error)?;
|
||||
|
||||
let auth = Arc::new(DefaultAuthService::new(
|
||||
config.default_username,
|
||||
config.session_ttl_minutes,
|
||||
store.auth(),
|
||||
));
|
||||
auth.ensure_default_admin(config.default_password.expose())
|
||||
.await?;
|
||||
|
||||
let accounts = Arc::new(DefaultAccountsService::new(
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
));
|
||||
let services = AdminServices {
|
||||
auth,
|
||||
accounts,
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
store.observability(),
|
||||
store.settings(),
|
||||
registry,
|
||||
)),
|
||||
settings: Arc::new(DefaultSettingsService::new(
|
||||
store.settings(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
system: Arc::new(DefaultSystemService::new(system)),
|
||||
openai: Arc::new(DefaultOpenAiService::new(
|
||||
openai,
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
xai: Arc::new(DefaultXaiService::new(
|
||||
xai,
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
};
|
||||
Ok(AdminBundle { services })
|
||||
}
|
||||
|
||||
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
|
||||
ProviderKind::new(value).map_err(|_| AdminError::internal("Built-in Provider kind is invalid"))
|
||||
}
|
||||
|
||||
fn map_provider_registry_error(error: ProviderAdminError) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid | ProviderAdminErrorKind::Unsupported => {
|
||||
AdminErrorKind::Invalid
|
||||
}
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, "Provider registry initialization failed")
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 多 Provider 账号目录与连接测试的公共事实。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
|
||||
use gateway_core::routing::ProviderKind;
|
||||
|
||||
use super::{PageSize, Revision, observability::TimeRange};
|
||||
|
||||
/// Provider 账号当前可用性;权威定义与字符串编解码在 gateway-core,此处仅复用。
|
||||
pub use gateway_core::engine::credential::AccountAvailability;
|
||||
|
||||
/// 管理页使用的归一化账号状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
Expired,
|
||||
QuotaExhausted,
|
||||
Disabled,
|
||||
Banned,
|
||||
}
|
||||
|
||||
/// 账号列表排序字段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountSortField {
|
||||
Email,
|
||||
Status,
|
||||
PlanType,
|
||||
Usage,
|
||||
LastUsedAt,
|
||||
ExpiresAt,
|
||||
}
|
||||
|
||||
/// 账号列表排序方向。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// 一组完整的账号排序规则。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSort {
|
||||
pub field: AccountSortField,
|
||||
pub direction: SortDirection,
|
||||
}
|
||||
|
||||
/// 账号列表的存储查询条件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub provider_kind: Option<ProviderKind>,
|
||||
pub search: Option<String>,
|
||||
pub status: Option<AccountStatus>,
|
||||
pub sort: Option<AccountSort>,
|
||||
}
|
||||
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
pub credential_revision: Revision,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
pub quota_observed_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 单一货币的账号成本聚合。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountCost {
|
||||
pub currency: String,
|
||||
pub amount: super::observability::DecimalAmount,
|
||||
}
|
||||
|
||||
/// 账号在一个模型上的历史用量。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountModelUsage {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: super::observability::CostCoverage,
|
||||
pub costs: Vec<AccountCost>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 账号在一个小时窗口内的请求数。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRequestBucket {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// 账号历史用量聚合。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsage {
|
||||
pub account_id: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cache_write_tokens: Option<u64>,
|
||||
pub reasoning_tokens: Option<u64>,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_failed_count: u64,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub cost_coverage: super::observability::CostCoverage,
|
||||
pub costs: Vec<AccountCost>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub request_buckets: Vec<AccountRequestBucket>,
|
||||
pub models: Vec<AccountModelUsage>,
|
||||
}
|
||||
|
||||
/// 某个账号在调用方指定时间窗口内的本地用量查询。
|
||||
///
|
||||
/// `key` 只用于把聚合结果关联回调用方的窗口,不承载 Provider 私有语义。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowQuery {
|
||||
pub account_id: String,
|
||||
pub key: String,
|
||||
pub range: TimeRange,
|
||||
}
|
||||
|
||||
/// 一个账号时间窗口用量查询的结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUsageWindowResult {
|
||||
pub account_id: String,
|
||||
pub key: String,
|
||||
pub usage: AccountUsage,
|
||||
}
|
||||
|
||||
/// 账号列表页所需的完整存储事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSummary {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub unavailable: u64,
|
||||
}
|
||||
|
||||
/// 账号启停写入命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountEnabled {
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 账号批量删除命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// 账号连接测试的语义事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountConnectionTestEvent {
|
||||
Started {
|
||||
model: String,
|
||||
},
|
||||
Request {
|
||||
model: String,
|
||||
input_text: String,
|
||||
stream: bool,
|
||||
store: bool,
|
||||
},
|
||||
Content {
|
||||
text: String,
|
||||
},
|
||||
Completed {
|
||||
account_status: AccountStatus,
|
||||
},
|
||||
Failed {
|
||||
message: String,
|
||||
provider_error_code: Option<String>,
|
||||
provider_error_type: Option<String>,
|
||||
upstream_status: Option<u16>,
|
||||
upstream_content_type: Option<String>,
|
||||
upstream_body: Option<String>,
|
||||
account_status: AccountStatus,
|
||||
},
|
||||
}
|
||||
|
||||
/// 每次连接测试独占的有限事件流。
|
||||
pub type AccountConnectionTestEventStream =
|
||||
Pin<Box<dyn Stream<Item = AccountConnectionTestEvent> + Send + 'static>>;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! 管理员认证、会话和安全审计事实。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::{MutationActor, MutationContext, Revision};
|
||||
|
||||
/// 已认证的管理主体。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AdminPrincipal {
|
||||
Session { admin_user_id: String },
|
||||
ApiKey,
|
||||
}
|
||||
|
||||
/// 传给管理用例的安全请求上下文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminRequestContext {
|
||||
pub principal: AdminPrincipal,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
impl AdminRequestContext {
|
||||
#[must_use]
|
||||
pub fn mutation_context(&self) -> MutationContext {
|
||||
let actor = match &self.principal {
|
||||
AdminPrincipal::Session { admin_user_id } => MutationActor::AdminSession {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
},
|
||||
AdminPrincipal::ApiKey => MutationActor::AdminApiKey,
|
||||
};
|
||||
MutationContext {
|
||||
actor,
|
||||
request_id: self.request_id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员登录命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct LoginCommand {
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginCommand {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("LoginCommand")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功后返回的会话事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LoginResult {
|
||||
pub session_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 登录状态机可被 API 精确映射的失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("invalid administrator credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("administrator authentication is unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Redis 中可恢复的管理员会话。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminSession {
|
||||
pub admin_user_id: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 安全审计事件类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuditActorKind {
|
||||
AdminSession,
|
||||
AdminApiKey,
|
||||
System,
|
||||
Anonymous,
|
||||
}
|
||||
|
||||
/// 管理写操作留下的最小审计事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminAuditEvent {
|
||||
pub id: String,
|
||||
pub actor_kind: AuditActorKind,
|
||||
pub actor_admin_user_id: Option<String>,
|
||||
pub actor_ref: String,
|
||||
pub request_id: Option<String>,
|
||||
pub action: String,
|
||||
pub entity_kind: String,
|
||||
pub entity_ref: String,
|
||||
pub config_revision: Option<Revision>,
|
||||
pub changed_fields: Vec<String>,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Client API Key 的 Command、Result 与安全秘密类型。
|
||||
|
||||
use std::{fmt, num::NonZeroU16};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision};
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ClientKeyPageSize(NonZeroU16);
|
||||
|
||||
impl ClientKeyPageSize {
|
||||
/// 创建 1 至 65535 的 Client Key 页大小。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 为零时返回 [`AdminModelError::InvalidClientKeyPageSize`]。
|
||||
pub fn new(value: u16) -> Result<Self, AdminModelError> {
|
||||
NonZeroU16::new(value)
|
||||
.map(Self)
|
||||
.ok_or(AdminModelError::InvalidClientKeyPageSize)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u16 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 列表排序字段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeySortField {
|
||||
Name,
|
||||
Enabled,
|
||||
CreatedAt,
|
||||
LastUsedAt,
|
||||
}
|
||||
|
||||
/// Client Key 列表排序方向。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// Client Key 列表排序规则。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ClientKeySort {
|
||||
pub field: ClientKeySortField,
|
||||
pub direction: SortDirection,
|
||||
}
|
||||
|
||||
/// 与排序字段绑定的游标值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientKeyCursorValue {
|
||||
Name(String),
|
||||
Enabled(bool),
|
||||
CreatedAt(DateTime<Utc>),
|
||||
LastUsedAt(Option<DateTime<Utc>>),
|
||||
}
|
||||
|
||||
/// Client Key 的稳定键集游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyCursor {
|
||||
pub sort: ClientKeySort,
|
||||
pub value: ClientKeyCursorValue,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
/// Client Key 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyListQuery {
|
||||
pub cursor: Option<ClientKeyCursor>,
|
||||
pub page_size: ClientKeyPageSize,
|
||||
pub search: Option<String>,
|
||||
pub sort: ClientKeySort,
|
||||
}
|
||||
|
||||
/// 不含完整明文 Key 的管理投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyRecord {
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub limits: RateLimits,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Client Key 列表页。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<ClientKeyRecord>,
|
||||
pub total: u64,
|
||||
pub next_cursor: Option<ClientKeyCursor>,
|
||||
}
|
||||
|
||||
/// 仅在创建或显式 reveal 时跨越管理边界的明文 Key。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeySecret {
|
||||
pub record: ClientKeyRecord,
|
||||
plaintext: String,
|
||||
}
|
||||
|
||||
impl ClientKeySecret {
|
||||
#[must_use]
|
||||
pub fn new(record: ClientKeyRecord, plaintext: impl Into<String>) -> Self {
|
||||
Self {
|
||||
record,
|
||||
plaintext: plaintext.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose_for_response(&self) -> &str {
|
||||
&self.plaintext
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ClientKeySecret {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ClientKeySecret")
|
||||
.field("record", &self.record)
|
||||
.field("plaintext", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub limits: RateLimits,
|
||||
}
|
||||
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub limits: RateLimits,
|
||||
pub plaintext: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for NewClientKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("NewClientKey")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("provider_kind", &self.provider_kind)
|
||||
.field("plaintext", &"[REDACTED]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub limits: RateLimits,
|
||||
}
|
||||
|
||||
/// 修改 Client Key 启用状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
/// Client Key 创建结果;完整明文仅存在于该一次性结果中。
|
||||
pub struct CreatedClientKey {
|
||||
pub config_revision: Revision,
|
||||
pub secret: ClientKeySecret,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreatedClientKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreatedClientKey")
|
||||
.field("config_revision", &self.config_revision)
|
||||
.field("secret", &self.secret)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 普通写操作结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub record: Option<ClientKeyRecord>,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! 管理控制面使用的 Command、Result 与稳定值对象。
|
||||
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
|
||||
/// 管理用例对外返回的稳定错误分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminErrorKind {
|
||||
Invalid,
|
||||
Unauthorized,
|
||||
NotFound,
|
||||
Conflict,
|
||||
RateLimited,
|
||||
BadGateway,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不携带基础设施细节的管理用例错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct AdminError {
|
||||
kind: AdminErrorKind,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl AdminError {
|
||||
#[must_use]
|
||||
pub fn new(kind: AdminErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AdminErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invalid(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Invalid, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::NotFound, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Conflict, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Unavailable, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Internal, message)
|
||||
}
|
||||
}
|
||||
|
||||
/// PostgreSQL 中所有正整数 revision 的管理层表示。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Revision(NonZeroU64);
|
||||
|
||||
impl Revision {
|
||||
/// 创建正整数 revision。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 为零时返回 [`AdminModelError::ZeroRevision`]。
|
||||
pub fn new(value: u64) -> Result<Self, AdminModelError> {
|
||||
NonZeroU64::new(value)
|
||||
.map(Self)
|
||||
.ok_or(AdminModelError::ZeroRevision)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u64 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理列表统一使用的受限页大小。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct PageSize(NonZeroU16);
|
||||
|
||||
impl PageSize {
|
||||
pub const MAX: u16 = 200;
|
||||
|
||||
/// 创建 1 至 200 的页大小。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// `value` 不在有效范围时返回 [`AdminModelError::InvalidPageSize`]。
|
||||
pub fn new(value: u16) -> Result<Self, AdminModelError> {
|
||||
if value > Self::MAX {
|
||||
return Err(AdminModelError::InvalidPageSize(value));
|
||||
}
|
||||
NonZeroU16::new(value)
|
||||
.map(Self)
|
||||
.ok_or(AdminModelError::InvalidPageSize(value))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn get(self) -> u16 {
|
||||
self.0.get()
|
||||
}
|
||||
}
|
||||
|
||||
/// 可审计管理写操作的发起者。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MutationActor {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
System,
|
||||
}
|
||||
|
||||
/// 管理写操作必须携带的审计上下文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MutationContext {
|
||||
pub actor: MutationActor,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
/// 领域值对象构造失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum AdminModelError {
|
||||
#[error("revision must be greater than zero")]
|
||||
ZeroRevision,
|
||||
#[error("page size {0} is outside 1..=200")]
|
||||
InvalidPageSize(u16),
|
||||
#[error("client key page size must be inside 1..=65535")]
|
||||
InvalidClientKeyPageSize,
|
||||
#[error("request outcome must be 1..=256 bytes without control characters")]
|
||||
InvalidRequestOutcome,
|
||||
#[error("time range must be positive and no longer than 366 days")]
|
||||
InvalidTimeRange,
|
||||
#[error("decimal amount is not a non-negative numeric(20,10) value")]
|
||||
InvalidDecimalAmount,
|
||||
#[error("latency percentile must be finite and non-negative")]
|
||||
InvalidLatencyPercentile,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,724 @@
|
||||
//! Provider 管理能力交换的中立 Command 与 Result。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{AccountAvailability, AccountRecord, AccountStatus, AccountSummary, AccountUsage},
|
||||
};
|
||||
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct ProviderDocument(OpaqueProviderData);
|
||||
|
||||
impl ProviderDocument {
|
||||
#[must_use]
|
||||
pub const fn new(data: OpaqueProviderData) -> Self {
|
||||
Self(data)
|
||||
}
|
||||
|
||||
/// 仅具体 Provider 可以解释内部字段。
|
||||
#[must_use]
|
||||
pub const fn expose_to_provider(&self) -> &OpaqueProviderData {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_provider_data(self) -> OpaqueProviderData {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderDocument {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("ProviderDocument([PROVIDER_OWNED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 列表稳定游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialCursor {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
/// Provider credential 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialListQuery {
|
||||
pub availability: Option<CredentialAvailabilityFilter>,
|
||||
pub enabled: Option<bool>,
|
||||
pub window: CredentialListWindow,
|
||||
}
|
||||
|
||||
/// Credential 可用性筛选;支持一个 wire 值对应多个持久状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialAvailabilityFilter {
|
||||
Exact(AccountAvailability),
|
||||
AnyOf(Vec<AccountAvailability>),
|
||||
}
|
||||
|
||||
impl CredentialAvailabilityFilter {
|
||||
#[must_use]
|
||||
pub fn matches(&self, availability: AccountAvailability) -> bool {
|
||||
match self {
|
||||
Self::Exact(expected) => *expected == availability,
|
||||
Self::AnyOf(expected) => expected.contains(&availability),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 目录的集合窗口;完整列表与游标分页互斥。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialListWindow {
|
||||
All,
|
||||
Page {
|
||||
cursor: Option<CredentialCursor>,
|
||||
page_size: PageSize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Provider credential 列表。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub next_cursor: Option<CredentialCursor>,
|
||||
}
|
||||
|
||||
/// Provider credential 详情。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDetails {
|
||||
pub config_revision: Revision,
|
||||
pub credential: AccountRecord,
|
||||
}
|
||||
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub context: MutationContext,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ImportCredentials {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ImportCredentials")
|
||||
.field("context", &self.context)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量导入提交结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialImportResult {
|
||||
pub config_revision: Revision,
|
||||
pub credential_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Provider 解析导入文档时只接收不透明文档,不接触 revision 或审计上下文。
|
||||
pub struct PrepareCredentialImport {
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PrepareCredentialImport {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PrepareCredentialImport")
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 已验证、可由 Store 原子创建的一份 credential。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialCreate {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
pub enabled: bool,
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_reason: Option<String>,
|
||||
pub cooldown_until: Option<DateTime<Utc>>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Provider 对一份导入文档的完整验证结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialImport {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub credentials: Vec<PreparedCredentialCreate>,
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationOwner {
|
||||
AdminSession { admin_user_id: String },
|
||||
AdminApiKey,
|
||||
System,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationOwner {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AuthorizationOwner([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 写入 pending payload 与 Redis owner binding 所需的全部中立字段。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationOwnerBinding {
|
||||
owner: AuthorizationOwner,
|
||||
started_request_id: String,
|
||||
}
|
||||
|
||||
impl AuthorizationOwnerBinding {
|
||||
#[must_use]
|
||||
pub fn from_context(context: &MutationContext) -> Self {
|
||||
let owner = match &context.actor {
|
||||
MutationActor::AdminSession { admin_user_id } => AuthorizationOwner::AdminSession {
|
||||
admin_user_id: admin_user_id.clone(),
|
||||
},
|
||||
MutationActor::AdminApiKey => AuthorizationOwner::AdminApiKey,
|
||||
MutationActor::System => AuthorizationOwner::System,
|
||||
};
|
||||
Self {
|
||||
owner,
|
||||
started_request_id: context.request_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn owner(&self) -> &AuthorizationOwner {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn started_request_id(&self) -> &str {
|
||||
&self.started_request_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matches_context(&self, context: &MutationContext) -> bool {
|
||||
Self::from_context(context).owner == self.owner
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationOwnerBinding {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AuthorizationOwnerBinding([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
|
||||
///
|
||||
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
|
||||
/// 长流程期间的后台刷新不得让恢复操作失效。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationMutationTarget {
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: ProviderAccountId },
|
||||
}
|
||||
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
}
|
||||
|
||||
impl PendingAuthorizationMutation {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn target(&self) -> &AuthorizationMutationTarget {
|
||||
&self.target
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn owner_binding(&self) -> &AuthorizationOwnerBinding {
|
||||
&self.owner_binding
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动 Provider OAuth Authorization Code 流程。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationStarted {
|
||||
pub flow_id: String,
|
||||
pub authorization_url: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// refresh/rotate/reauthorize 的 lease 或 completion 生命周期。
|
||||
///
|
||||
/// 该 guard 不可 Clone。Admin 只能在 Store CAS 与审计事务成功后调用 `finish`;失败路径直接
|
||||
/// drop,使 Provider 可以释放 lease 或执行补偿。
|
||||
pub trait CredentialCommitGuard: Send + 'static {
|
||||
fn finish(self: Box<Self>);
|
||||
}
|
||||
|
||||
/// OAuth pending claim 在 Store 事务结果确定后执行的显式结算动作。
|
||||
///
|
||||
/// OAuth provider 必须在 credential 准备阶段持有 claim:Store 提交成功后消费 flow,提交或
|
||||
/// 校验失败后释放 flow。异步结算不能依赖 `Drop`,否则请求提前返回会让同一授权流无谓地
|
||||
/// 进入短暂的不可重试状态。
|
||||
#[async_trait]
|
||||
pub trait AuthorizationCommitGuard: Send + 'static {
|
||||
/// Store 已提交 OAuth credential 后消费对应的一次性 flow。
|
||||
async fn commit(self: Box<Self>) -> Result<(), AdminError>;
|
||||
|
||||
/// Store 未提交 OAuth credential 时释放 claim,允许同一 flow 重试。
|
||||
async fn abort(self: Box<Self>) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// OAuth complete 后由 Provider 返回的准备结果;Store 仍是唯一提交者。
|
||||
pub enum PreparedAuthorizationCredential {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotation),
|
||||
}
|
||||
|
||||
/// Provider 从 opaque pending payload 恢复的信封与已验证 credential 必须一起返回。
|
||||
pub struct PreparedAuthorizationCommit {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: PreparedAuthorizationCredential,
|
||||
authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedAuthorizationCommit")
|
||||
.field("pending", &self.pending)
|
||||
.field("credential", &"[PREPARED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Store 可持久化的 OAuth credential facts,不携带 Provider guard。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum AuthorizationCredentialCommit {
|
||||
Create(PreparedCredentialCreate),
|
||||
Reauthorize(PreparedCredentialRotationFacts),
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的 OAuth 原子事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AuthorizationCommit {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: AuthorizationCredentialCommit,
|
||||
}
|
||||
|
||||
/// OAuth 准备结果拆解后的 Store 命令与两个结算 guard。
|
||||
///
|
||||
/// Store 成功时先结束 credential guard,再消费 OAuth claim;失败时丢弃 credential guard 并
|
||||
/// 释放 OAuth claim,使同一授权流可以重试。
|
||||
pub(crate) struct AuthorizationCommitSettlement {
|
||||
pub(crate) command: AuthorizationCommit,
|
||||
pub(crate) credential_guard: Option<Box<dyn CredentialCommitGuard>>,
|
||||
pub(crate) authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
|
||||
}
|
||||
|
||||
impl PreparedAuthorizationCommit {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
pending: PendingAuthorizationMutation,
|
||||
credential: PreparedAuthorizationCredential,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending,
|
||||
credential,
|
||||
authorization_guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让 OAuth claim 覆盖准备完成到 Store 事务结算之间的全部窗口。
|
||||
#[must_use]
|
||||
pub fn with_authorization_guard(mut self, guard: Box<dyn AuthorizationCommitGuard>) -> Self {
|
||||
self.authorization_guard = Some(guard);
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn into_commit(self) -> AuthorizationCommitSettlement {
|
||||
let (credential, guard) = match self.credential {
|
||||
PreparedAuthorizationCredential::Create(credential) => {
|
||||
(AuthorizationCredentialCommit::Create(credential), None)
|
||||
}
|
||||
PreparedAuthorizationCredential::Reauthorize(prepared) => {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
(
|
||||
AuthorizationCredentialCommit::Reauthorize(facts),
|
||||
Some(guard),
|
||||
)
|
||||
}
|
||||
};
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
pending: self.pending,
|
||||
credential,
|
||||
},
|
||||
credential_guard: guard,
|
||||
authorization_guard: self.authorization_guard,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn abort(self) -> Result<(), AdminError> {
|
||||
let AuthorizationCommitSettlement {
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
..
|
||||
} = self.into_commit();
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.abort().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成 Provider OAuth 流程。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct CompleteAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub flow_id: String,
|
||||
pub callback_url: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CompleteAuthorization {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CompleteAuthorization")
|
||||
.field("context", &self.context)
|
||||
.field("flow_id", &"[REDACTED]")
|
||||
.field("callback_url", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential 生命周期写操作。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
/// Credential 写入提交结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutationResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Option<Revision>,
|
||||
}
|
||||
|
||||
/// 同一 Provider 管理范围内的 credential 批量删除。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Credential 批量删除提交结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletionResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// Provider-owned token 轮换命令。
|
||||
pub struct RotateCredential {
|
||||
pub mutation: CredentialMutation,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入。
|
||||
pub struct PrepareCredentialRotation {
|
||||
pub account: AccountRecord,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PrepareCredentialRotation {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PrepareCredentialRotation")
|
||||
.field("account", &self.account)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 已验证、可由 Store 以 credential revision CAS 原子提交的轮换 facts。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PreparedCredentialRotationFacts {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub replacement_identity: Option<ProviderAccountIdentity>,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub provider_material: ProviderDocument,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub next_refresh_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 返回的轮换准备结果;guard 必须覆盖后续 Store CAS 与审计事务。
|
||||
pub struct PreparedCredentialRotation {
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
guard: Box<dyn CredentialCommitGuard>,
|
||||
}
|
||||
|
||||
impl PreparedCredentialRotation {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
facts: PreparedCredentialRotationFacts,
|
||||
guard: Box<dyn CredentialCommitGuard>,
|
||||
) -> Self {
|
||||
Self { facts, guard }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn facts(&self) -> &PreparedCredentialRotationFacts {
|
||||
&self.facts
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_parts(
|
||||
self,
|
||||
) -> (
|
||||
PreparedCredentialRotationFacts,
|
||||
Box<dyn CredentialCommitGuard>,
|
||||
) {
|
||||
(self.facts, self.guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedCredentialRotation {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedCredentialRotation")
|
||||
.field("facts", &self.facts)
|
||||
.field("guard", &"[COMPLETION-GUARD]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RotateCredential {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RotateCredential")
|
||||
.field("mutation", &self.mutation)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个 Provider quota 窗口的公共投影。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub source: Option<String>,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at: Option<DateTime<Utc>>,
|
||||
pub local_usage: Option<AccountUsage>,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
/// Provider 解释 quota 所需的公共请求事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderQuotaRequest {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub refresh: bool,
|
||||
pub rolling_usage: Option<AccountUsage>,
|
||||
}
|
||||
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
///
|
||||
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
|
||||
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
self.windows
|
||||
.iter()
|
||||
.filter_map(|window| {
|
||||
let used_percent = window
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0))?;
|
||||
Some((quota_usage_priority(window), used_percent))
|
||||
})
|
||||
.fold(None::<(u8, f64)>, |selected, candidate| match selected {
|
||||
Some(current)
|
||||
if current.0 < candidate.0
|
||||
|| (current.0 == candidate.0 && current.1 >= candidate.1) =>
|
||||
{
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
})
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_usage_priority(window: &ProviderQuotaWindow) -> u8 {
|
||||
match window.group.as_str() {
|
||||
"shortTerm" if window.window_seconds.is_some_and(is_week_window) => 1,
|
||||
"shortTerm" => 0,
|
||||
"monthly" => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_week_window(seconds: u64) -> bool {
|
||||
const WEEK_SECONDS: u64 = 7 * 24 * 60 * 60;
|
||||
seconds > 0 && seconds.abs_diff(WEEK_SECONDS) <= WEEK_SECONDS / 20
|
||||
}
|
||||
|
||||
/// Provider 实时模型目录的一项。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModel {
|
||||
pub id: UpstreamModelId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Provider 实时模型目录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModels {
|
||||
pub models: Vec<ProviderModel>,
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Provider 执行 refresh 时所需的当前公共账号事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PrepareCredentialRefresh {
|
||||
pub account: AccountRecord,
|
||||
}
|
||||
|
||||
/// Provider 敏感导出结果。
|
||||
pub struct ProviderExport {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
/// Store 为 Provider 导出序列化准备的最小输入;material 对公共层保持不透明。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderExportCredentialInput {
|
||||
pub account: AccountRecord,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderExport {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderExport")
|
||||
.field("provider_kind", &self.provider_kind)
|
||||
.field("account_ids", &self.account_ids)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一账号目录的一行完整结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub status: AccountStatus,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
}
|
||||
|
||||
/// 统一账号目录页。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountDirectoryItem>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 凭据刷新提交后的完整账号结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountRefreshResult {
|
||||
pub config_revision: Revision,
|
||||
pub account: AccountDirectoryItem,
|
||||
}
|
||||
|
||||
/// 多 Provider 导出文档集合。
|
||||
pub struct AccountExportBundle {
|
||||
pub exported_at: DateTime<Utc>,
|
||||
pub documents: Vec<ProviderExport>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AccountExportBundle {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AccountExportBundle")
|
||||
.field("exported_at", &self.exported_at)
|
||||
.field("document_count", &self.documents.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Runtime settings 与明文管理员 API Key 的语义模型。
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
use super::Revision;
|
||||
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// 账号调度策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationStrategy {
|
||||
Smart,
|
||||
QuotaResetPriority,
|
||||
RoundRobin,
|
||||
Sticky,
|
||||
}
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub config_revision: Revision,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
}
|
||||
|
||||
/// 明文管理员 API Key;按产品约束明文落库,但禁止 Debug 泄漏。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AdminApiKey(String);
|
||||
|
||||
impl AdminApiKey {
|
||||
#[must_use]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn expose_for_auth(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// 仅供显式 regenerate 响应读取一次。
|
||||
#[must_use]
|
||||
pub fn expose_for_response(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AdminApiKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AdminApiKey([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员 API Key 更新结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AdminApiKeyMutation {
|
||||
pub config_revision: Revision,
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// 新管理员 API Key 的一次性返回结果。
|
||||
pub struct RegeneratedAdminApiKey {
|
||||
pub mutation: AdminApiKeyMutation,
|
||||
pub key: AdminApiKey,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RegeneratedAdminApiKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RegeneratedAdminApiKey")
|
||||
.field("mutation", &self.mutation)
|
||||
.field("key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! 版本、自更新、回滚与进程重启的 UTC 语义模型。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// 当前运行版本及更新检查摘要。
|
||||
///
|
||||
/// `deployment_mode_label` 之类的本地化字符串由 API 根据原始枚举值生成。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemVersion {
|
||||
pub version: String,
|
||||
pub git_sha: String,
|
||||
pub build_time: String,
|
||||
pub deployment_mode: String,
|
||||
pub update_channel: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
pub update_cached: bool,
|
||||
pub update_warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 发布源提供的完整更新详情。
|
||||
///
|
||||
/// 部署模式与构建类型的展示标签归 API;其余字段均为 Host 已确认的原始事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateDetail {
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub has_update: bool,
|
||||
pub deployment_mode: String,
|
||||
pub build_type: String,
|
||||
pub release_url: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub cached: bool,
|
||||
pub update_supported: bool,
|
||||
pub unsupported_reason: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
/// 系统操作类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationKind {
|
||||
Update,
|
||||
Rollback,
|
||||
Restart,
|
||||
}
|
||||
|
||||
/// 系统操作状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationStatus {
|
||||
Idle,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 持久化系统操作的完整状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemOperationState {
|
||||
pub operation_id: Option<String>,
|
||||
pub kind: Option<SystemOperationKind>,
|
||||
pub status: SystemOperationStatus,
|
||||
pub target_version: Option<String>,
|
||||
pub message: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 最近一次系统操作及版本交换状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateStatus {
|
||||
pub previous_version: Option<String>,
|
||||
pub current_version: Option<String>,
|
||||
pub operation: SystemOperationState,
|
||||
}
|
||||
|
||||
/// 自更新日志级别。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemUpdateEventLevel {
|
||||
Info,
|
||||
Warning,
|
||||
Success,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 一条已脱敏的系统更新事件。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SystemUpdateEvent {
|
||||
pub id: String,
|
||||
pub operation_id: Option<String>,
|
||||
pub level: SystemUpdateEventLevel,
|
||||
pub step: Option<String>,
|
||||
pub message: String,
|
||||
pub terminal: bool,
|
||||
pub progress_percent: Option<u8>,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 已受理的异步系统操作;不同 wire shape 由枚举保持为合法状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SystemOperationAccepted {
|
||||
Update {
|
||||
operation_id: String,
|
||||
deployment_mode: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
target_version: String,
|
||||
},
|
||||
Rollback {
|
||||
operation_id: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
},
|
||||
Restart {
|
||||
operation_id: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl SystemOperationAccepted {
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> SystemOperationKind {
|
||||
match self {
|
||||
Self::Update { .. } => SystemOperationKind::Update,
|
||||
Self::Rollback { .. } => SystemOperationKind::Rollback,
|
||||
Self::Restart { .. } => SystemOperationKind::Restart,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! 管理用例依赖的外部能力端口。
|
||||
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Provider 管理能力与动态注册表。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
operation::Operation,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
|
||||
use crate::model::observability::{
|
||||
CalculatedBillingBreakdown, DashboardWireProfile, ProviderBillingInput,
|
||||
};
|
||||
use crate::model::provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, PendingAuthorizationMutation,
|
||||
PrepareCredentialImport, PrepareCredentialRefresh, PrepareCredentialRotation,
|
||||
PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota,
|
||||
ProviderQuotaRequest,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdminErrorKind {
|
||||
Invalid,
|
||||
Unsupported,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("provider admin operation failed: {kind:?}")]
|
||||
pub struct ProviderAdminError {
|
||||
kind: ProviderAdminErrorKind,
|
||||
}
|
||||
|
||||
impl ProviderAdminError {
|
||||
#[must_use]
|
||||
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
|
||||
Self { kind }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProviderAdminErrorKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
|
||||
///
|
||||
/// 数据变更由 Provider 返回 prepared facts;config revision、审计与 PostgreSQL 事务
|
||||
/// 全部由 [`crate::ports::store::AccountStore`] 提交。运行时资源通知只在事务成功后发生。
|
||||
#[async_trait]
|
||||
pub trait ProviderAdmin: Send + Sync {
|
||||
fn provider_kind(&self) -> &ProviderKind;
|
||||
|
||||
/// 账号已经由控制面提交为不可调度状态,释放 Provider 持有的账号级运行时资源。
|
||||
///
|
||||
/// 无账号级运行时资源的 Provider 不需要执行额外操作。该通知发生在 Store 事务
|
||||
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
|
||||
async fn account_unavailable(&self, account_id: &ProviderAccountId);
|
||||
|
||||
/// 账号资格事实已经由控制面提交,失效 Provider 持有的可重建派生状态。
|
||||
///
|
||||
/// 该通知发生在 Store 事务成功之后、下一份 RuntimeSnapshot 编译之前;通知
|
||||
/// 不参与已提交事务成败。没有账号派生状态的 Provider 可使用默认空实现。
|
||||
async fn account_facts_changed(&self, _account_ids: &[ProviderAccountId]) {}
|
||||
|
||||
/// 生成一次连接测试所需的 Provider-owned operation;Core 负责实际执行与落账。
|
||||
fn connection_test_operation(
|
||||
&self,
|
||||
upstream_model: &UpstreamModelId,
|
||||
input_text: &str,
|
||||
) -> Result<Operation, ProviderAdminError>;
|
||||
|
||||
/// 返回该 Provider 实际持有的 Dashboard 上游身份画像。
|
||||
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile>;
|
||||
|
||||
/// 使用 Provider-owned 价格规则恢复持久请求的逐项费用。
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
input: &ProviderBillingInput,
|
||||
) -> Result<Option<CalculatedBillingBreakdown>, ProviderAdminError>;
|
||||
|
||||
async fn prepare_import(
|
||||
&self,
|
||||
command: PrepareCredentialImport,
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError>;
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
pending: PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError>;
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<PreparedAuthorizationCommit, ProviderAdminError>;
|
||||
|
||||
async fn prepare_rotation(
|
||||
&self,
|
||||
command: PrepareCredentialRotation,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
|
||||
|
||||
async fn prepare_refresh(
|
||||
&self,
|
||||
command: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError>;
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
request: ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError>;
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError>;
|
||||
|
||||
async fn export_credentials(
|
||||
&self,
|
||||
credentials: Vec<ProviderExportCredentialInput>,
|
||||
) -> Result<ProviderExport, ProviderAdminError>;
|
||||
}
|
||||
|
||||
/// 按 ProviderKind 动态发现管理能力;不含具体 Provider 分支。
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderAdminRegistry {
|
||||
providers: Arc<BTreeMap<ProviderKind, Arc<dyn ProviderAdmin>>>,
|
||||
}
|
||||
|
||||
impl ProviderAdminRegistry {
|
||||
/// 创建无重复 ProviderKind 的注册表。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 重复注册同一 ProviderKind 时返回 Conflict。
|
||||
pub fn new(
|
||||
providers: impl IntoIterator<Item = Arc<dyn ProviderAdmin>>,
|
||||
) -> Result<Self, ProviderAdminError> {
|
||||
let mut registered = BTreeMap::new();
|
||||
for provider in providers {
|
||||
let kind = provider.provider_kind().clone();
|
||||
if registered.insert(kind, provider).is_some() {
|
||||
return Err(ProviderAdminError::new(ProviderAdminErrorKind::Conflict));
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
providers: Arc::new(registered),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn require(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
) -> Result<Arc<dyn ProviderAdmin>, ProviderAdminError> {
|
||||
self.providers
|
||||
.get(provider_kind)
|
||||
.cloned()
|
||||
.ok_or_else(|| ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 返回所有已注册 Provider 的 Dashboard 上游身份画像。
|
||||
pub fn dashboard_wire_profiles(&self) -> Vec<DashboardWireProfile> {
|
||||
self.providers
|
||||
.values()
|
||||
.filter_map(|provider| provider.dashboard_wire_profile())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 动态分派 Provider-owned 费用规则,不含任何具体 Provider 分支。
|
||||
pub fn calculated_billing(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
input: &ProviderBillingInput,
|
||||
) -> Result<Option<CalculatedBillingBreakdown>, ProviderAdminError> {
|
||||
self.require(provider_kind)?.calculated_billing(input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! 管理控制面所需的持久化能力。
|
||||
//!
|
||||
//! 端口按业务资源拆分,方法使用领域模型,不暴露连接池、事务或 Redis client。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation,
|
||||
OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageCalculatedBillingFact,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, CredentialDetails, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
ProviderExportCredentialInput,
|
||||
},
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
};
|
||||
|
||||
/// 管理端可判定的持久化失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AdminStoreErrorKind {
|
||||
Invalid,
|
||||
NotFound,
|
||||
StaleRevision,
|
||||
Conflict,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 隐藏数据库实现细节的持久化错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{resource} store operation failed: {message}")]
|
||||
pub struct AdminStoreError {
|
||||
kind: AdminStoreErrorKind,
|
||||
resource: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl AdminStoreError {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
kind: AdminStoreErrorKind,
|
||||
resource: &'static str,
|
||||
message: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
resource,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> AdminStoreErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn resource(&self) -> &'static str {
|
||||
self.resource
|
||||
}
|
||||
}
|
||||
|
||||
pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
|
||||
/// 账号目录与公共账号写操作。
|
||||
#[async_trait]
|
||||
pub trait AccountStore: Send + Sync {
|
||||
async fn list_accounts(&self, query: AccountListQuery) -> AdminStoreResult<AccountPage>;
|
||||
|
||||
async fn load_account(&self, account_id: &str) -> AdminStoreResult<Option<AccountRecord>>;
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
account_ids: &[String],
|
||||
) -> AdminStoreResult<Vec<AccountUsage>>;
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
windows: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>>;
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
query: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage>;
|
||||
|
||||
async fn credential_details(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_id: &gateway_core::engine::credential::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
command: CredentialImportCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult>;
|
||||
|
||||
async fn commit_authorization(
|
||||
&self,
|
||||
command: AuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
&self,
|
||||
command: CredentialRotationCommit,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
|
||||
async fn set_account_enabled(
|
||||
&self,
|
||||
command: SetAccountEnabled,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
command: DeleteAccounts,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
|
||||
async fn record_credential_export(
|
||||
&self,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// 管理员密码、会话和安全审计。
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
admin_user_id: &str,
|
||||
password_hash: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>>;
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
|
||||
async fn store_session(&self, session_id: &str, session: &AdminSession)
|
||||
-> AdminStoreResult<()>;
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
|
||||
async fn reveal_client_key(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeySecret>>;
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
command: NewClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
|
||||
|
||||
async fn update_client_key(
|
||||
&self,
|
||||
command: UpdateClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
|
||||
|
||||
async fn set_client_key_enabled(
|
||||
&self,
|
||||
command: SetClientKeyEnabled,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)>;
|
||||
|
||||
async fn delete_client_key(
|
||||
&self,
|
||||
command: DeleteClientKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
}
|
||||
|
||||
/// 用量、趋势、诊断与运维错误的只读能力。
|
||||
#[async_trait]
|
||||
pub trait ObservabilityStore: Send + Sync {
|
||||
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation>;
|
||||
|
||||
/// 返回 Dashboard 可选的实时槽位事实。
|
||||
///
|
||||
/// 该状态来自可丢失的运行时存储;无实现或运行时存储不可用时返回 `None`,不影响
|
||||
/// 持久观测数据的读取。
|
||||
async fn dashboard_runtime_slots(
|
||||
&self,
|
||||
_observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, range: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>>;
|
||||
|
||||
/// 返回可由 Provider 重新校验的已计算费用事实,用于恢复标准费用趋势。
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>>;
|
||||
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage>;
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> AdminStoreResult<UsageDetail>;
|
||||
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> AdminStoreResult<UsageOverview>;
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>>;
|
||||
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
command: ReplaceRuntimeSettings,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings>;
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
|
||||
/// 管理用例所需能力的封闭集合。
|
||||
///
|
||||
/// 字段保持私有,每个 getter 只交出一种明确能力。该类型不提供通用拆包入口。
|
||||
#[derive(Clone)]
|
||||
pub struct AdminStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
auth,
|
||||
client_keys,
|
||||
observability,
|
||||
settings,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn accounts(&self) -> Arc<dyn AccountStore> {
|
||||
self.accounts.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> Arc<dyn AuthStore> {
|
||||
self.auth.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_keys(&self) -> Arc<dyn ClientKeyStore> {
|
||||
self.client_keys.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> Arc<dyn ObservabilityStore> {
|
||||
self.observability.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> Arc<dyn SettingsStore> {
|
||||
self.settings.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Host 为系统管理用例提供的进程与操作系统能力。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
use crate::model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
};
|
||||
|
||||
/// Host 系统操作失败类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SystemOperationErrorKind {
|
||||
Invalid,
|
||||
Conflict,
|
||||
Upstream,
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不泄漏路径、命令行或发布凭据的系统操作错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("system operation failed: {message}")]
|
||||
pub struct SystemOperationError {
|
||||
kind: SystemOperationErrorKind,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl SystemOperationError {
|
||||
#[must_use]
|
||||
pub fn new(kind: SystemOperationErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> SystemOperationErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// 返回 Host 已完成脱敏的客户端安全消息。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// 每个订阅者独占的系统事件流。
|
||||
pub type SystemUpdateEventStream = Pin<Box<dyn Stream<Item = SystemUpdateEvent> + Send + 'static>>;
|
||||
|
||||
/// 版本、自更新、回滚和重启能力;实现唯一归 gateway-host。
|
||||
#[async_trait]
|
||||
pub trait SystemOperations: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError>;
|
||||
|
||||
async fn update_detail(
|
||||
&self,
|
||||
refresh: bool,
|
||||
) -> Result<SystemUpdateDetail, SystemOperationError>;
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError>;
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError>;
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
//! 统一账号目录与跨 Provider 动态分派。
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeRequest},
|
||||
},
|
||||
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountRecord, AccountStatus, AccountUsageWindowQuery,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
store::{AccountStore, SettingsStore},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
commit_credential_refresh, map_provider_error, map_store_error, publish_committed,
|
||||
validate_prepared_rotation,
|
||||
};
|
||||
|
||||
const CONNECTION_TEST_INPUT: &str = "Reply with exactly OK.";
|
||||
|
||||
/// 统一账号页消费的服务。
|
||||
#[async_trait]
|
||||
pub trait AccountsService: Send + Sync {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError>;
|
||||
|
||||
async fn export(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_ids: Vec<ProviderAccountId>,
|
||||
) -> Result<AccountExportBundle, AdminError>;
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError>;
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<AccountDirectoryItem, AdminError>;
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, AdminError>;
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
upstream_model: UpstreamModelId,
|
||||
) -> Result<AccountConnectionTestEventStream, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultAccountsService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
}
|
||||
|
||||
impl DefaultAccountsService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
settings,
|
||||
providers,
|
||||
snapshot,
|
||||
probe,
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountRecord, AdminError> {
|
||||
self.accounts
|
||||
.load_account(account_id.as_str())
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?
|
||||
.ok_or_else(|| AdminError::not_found("Provider account was not found"))
|
||||
}
|
||||
|
||||
async fn provider_for_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<
|
||||
(
|
||||
AccountRecord,
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
),
|
||||
AdminError,
|
||||
> {
|
||||
let account = self.load_account(account_id).await?;
|
||||
let provider = self
|
||||
.providers
|
||||
.require(&account.provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider account"))?;
|
||||
Ok((account, provider))
|
||||
}
|
||||
|
||||
async fn attach_quota_local_usage(
|
||||
&self,
|
||||
accounts: &[AccountRecord],
|
||||
quotas: &mut [ProviderQuota],
|
||||
) -> Result<(), AdminError> {
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.zip(quotas.iter())
|
||||
.flat_map(|(account, quota)| {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter_map(|window| quota_usage_window(&account.id, window))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if windows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let usage_by_window = self
|
||||
.accounts
|
||||
.load_account_usage_by_windows(&windows)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "quota window usage"))?
|
||||
.into_iter()
|
||||
.map(|result| ((result.account_id, result.key), result.usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (account, quota) in accounts.iter().zip(quotas) {
|
||||
for window in &mut quota.windows {
|
||||
if window.local_usage.is_none() {
|
||||
let key = (account.id.clone(), window.key.clone());
|
||||
if let Some(usage) = usage_by_window.get(&key) {
|
||||
window.local_usage = Some(usage.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_directory_item(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh_quota: bool,
|
||||
) -> Result<AccountDirectoryItem, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(account_id).await?;
|
||||
let settings = self
|
||||
.settings
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "runtime settings"))?;
|
||||
let now = Utc::now();
|
||||
let retained_range = retained_usage_range(now, settings.usage_retention_days);
|
||||
let rolling_range = TimeRange {
|
||||
start: now - Duration::hours(24),
|
||||
end: now,
|
||||
};
|
||||
let ids = vec![account.id.clone()];
|
||||
let (usage, rolling_usage) = futures::try_join!(
|
||||
async {
|
||||
self.accounts
|
||||
.load_account_usage(retained_range, &ids)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account usage"))
|
||||
},
|
||||
async {
|
||||
self.accounts
|
||||
.load_account_usage(rolling_range, &ids)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "rolling account usage"))
|
||||
},
|
||||
)?;
|
||||
let rolling_usage = rolling_usage.into_iter().next();
|
||||
let mut quota = provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: refresh_quota,
|
||||
rolling_usage: rolling_usage.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
self.attach_quota_local_usage(
|
||||
std::slice::from_ref(&account),
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
Ok(AccountDirectoryItem {
|
||||
status: account_status(&account, now),
|
||||
usage: usage.into_iter().next(),
|
||||
account,
|
||||
quota,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountsService for DefaultAccountsService {
|
||||
async fn list(&self, query: AccountListQuery) -> Result<AccountDirectoryPage, AdminError> {
|
||||
let (page, settings) = futures::try_join!(
|
||||
self.accounts.list_accounts(query),
|
||||
self.settings.load_runtime_settings(),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "account directory"))?;
|
||||
let now = Utc::now();
|
||||
let retained_range = retained_usage_range(now, settings.usage_retention_days);
|
||||
let rolling_range = TimeRange {
|
||||
start: now - Duration::hours(24),
|
||||
end: now,
|
||||
};
|
||||
let ids = page
|
||||
.items
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let (usage, rolling_usage) = futures::try_join!(
|
||||
self.accounts.load_account_usage(retained_range, &ids),
|
||||
self.accounts.load_account_usage(rolling_range, &ids),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "account usage"))?;
|
||||
let usage = usage
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let rolling_usage = rolling_usage
|
||||
.into_iter()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|account| async {
|
||||
let account_id = ProviderAccountId::new(account.id.clone())
|
||||
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
|
||||
let provider = self
|
||||
.providers
|
||||
.require(&account.provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: rolling_usage.get(&account.id).cloned(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
.collect::<Result<Vec<_>, AdminError>>()?;
|
||||
self.attach_quota_local_usage(&page.items, &mut quotas)
|
||||
.await?;
|
||||
let items = page
|
||||
.items
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(account, quota)| AccountDirectoryItem {
|
||||
status: account_status(&account, now),
|
||||
usage: usage.get(&account.id).cloned(),
|
||||
account,
|
||||
quota,
|
||||
})
|
||||
.collect();
|
||||
Ok(AccountDirectoryPage {
|
||||
config_revision: page.config_revision,
|
||||
items,
|
||||
total: page.total,
|
||||
summary: page.summary,
|
||||
})
|
||||
}
|
||||
|
||||
async fn export(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_ids: Vec<ProviderAccountId>,
|
||||
) -> Result<AccountExportBundle, AdminError> {
|
||||
if account_ids.is_empty() || account_ids.len() > 200 {
|
||||
return Err(AdminError::invalid(
|
||||
"Account export requires between 1 and 200 accounts",
|
||||
));
|
||||
}
|
||||
let exported_ids = account_ids.clone();
|
||||
let mut grouped = BTreeMap::<ProviderKind, Vec<ProviderAccountId>>::new();
|
||||
for account_id in account_ids {
|
||||
let account = self.load_account(&account_id).await?;
|
||||
grouped
|
||||
.entry(account.provider_kind)
|
||||
.or_default()
|
||||
.push(account_id);
|
||||
}
|
||||
if grouped.values().any(|ids| {
|
||||
let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
|
||||
unique.len() != ids.len()
|
||||
}) {
|
||||
return Err(AdminError::invalid("Account export contains duplicate IDs"));
|
||||
}
|
||||
let mut documents = Vec::with_capacity(grouped.len());
|
||||
for (provider_kind, ids) in grouped {
|
||||
let provider = self
|
||||
.providers
|
||||
.require(&provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider account export"))?;
|
||||
let credentials = self
|
||||
.accounts
|
||||
.load_credentials_for_export(&provider_kind, &ids)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account export"))?;
|
||||
documents.push(
|
||||
provider
|
||||
.export_credentials(credentials)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider account export"))?,
|
||||
);
|
||||
}
|
||||
self.accounts
|
||||
.record_credential_export(&exported_ids, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account export audit"))?;
|
||||
Ok(AccountExportBundle {
|
||||
exported_at: Utc::now(),
|
||||
documents,
|
||||
})
|
||||
}
|
||||
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(&account_id).await?;
|
||||
let prepared = provider
|
||||
.prepare_refresh(PrepareCredentialRefresh {
|
||||
account: account.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider credential refresh"))?;
|
||||
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
|
||||
let result = commit_credential_refresh(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
context,
|
||||
"provider credential refresh",
|
||||
)
|
||||
.await?;
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
let account = self.load_directory_item(&result.account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
config_revision: result.config_revision,
|
||||
account,
|
||||
})
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<AccountDirectoryItem, AdminError> {
|
||||
self.load_directory_item(account_id, refresh).await
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
refresh: bool,
|
||||
) -> Result<ProviderModels, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.models(account_id, refresh)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider model catalog"))
|
||||
}
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
account_id: ProviderAccountId,
|
||||
upstream_model: UpstreamModelId,
|
||||
) -> Result<AccountConnectionTestEventStream, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(&account_id).await?;
|
||||
let initial_status = account_status(&account, Utc::now());
|
||||
let model = upstream_model.as_str().to_owned();
|
||||
let operation = provider
|
||||
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
|
||||
.map_err(|error| map_provider_error(error, "provider connection test"))?;
|
||||
let initial = vec![
|
||||
AccountConnectionTestEvent::Started {
|
||||
model: model.clone(),
|
||||
},
|
||||
AccountConnectionTestEvent::Request {
|
||||
model,
|
||||
input_text: CONNECTION_TEST_INPUT.to_owned(),
|
||||
stream: true,
|
||||
store: false,
|
||||
},
|
||||
];
|
||||
let probe = Arc::clone(&self.probe);
|
||||
let accounts = Arc::clone(&self.accounts);
|
||||
let stored_account_id = account.id.clone();
|
||||
let terminal = futures::stream::once(async move {
|
||||
let result = probe
|
||||
.probe(AccountProbeRequest {
|
||||
account_id,
|
||||
provider_kind: account.provider_kind,
|
||||
upstream_model,
|
||||
operation,
|
||||
})
|
||||
.await;
|
||||
let status = accounts
|
||||
.load_account(&stored_account_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map_or(initial_status, |account| {
|
||||
account_status(&account, Utc::now())
|
||||
});
|
||||
match result {
|
||||
Ok(result) => result
|
||||
.text
|
||||
.into_iter()
|
||||
.map(|text| AccountConnectionTestEvent::Content { text })
|
||||
.chain(std::iter::once(AccountConnectionTestEvent::Completed {
|
||||
account_status: status,
|
||||
}))
|
||||
.collect(),
|
||||
Err(error) => {
|
||||
let upstream_status = error
|
||||
.upstream_response()
|
||||
.map(gateway_core::engine::probe::AccountProbeUpstreamResponse::status);
|
||||
let upstream_content_type = error
|
||||
.upstream_response()
|
||||
.and_then(|response| response.content_type())
|
||||
.and_then(|value| std::str::from_utf8(value).ok())
|
||||
.map(ToOwned::to_owned);
|
||||
let upstream_body = error
|
||||
.upstream_response()
|
||||
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
|
||||
let message = upstream_body
|
||||
.as_deref()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| error.client_message())
|
||||
.to_owned();
|
||||
vec![AccountConnectionTestEvent::Failed {
|
||||
message,
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
|
||||
upstream_status,
|
||||
upstream_content_type,
|
||||
upstream_body,
|
||||
account_status: status,
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.flat_map(futures::stream::iter);
|
||||
Ok(Box::pin(futures::stream::iter(initial).chain(terminal)))
|
||||
}
|
||||
}
|
||||
|
||||
fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> AccountStatus {
|
||||
if !account.enabled {
|
||||
AccountStatus::Disabled
|
||||
} else if account.availability == crate::model::accounts::AccountAvailability::Banned {
|
||||
AccountStatus::Banned
|
||||
} else if account.availability == crate::model::accounts::AccountAvailability::QuotaExhausted
|
||||
|| (account.availability == crate::model::accounts::AccountAvailability::Cooldown
|
||||
&& account.availability_reason.as_deref() == Some("usage_limit_exhausted")
|
||||
&& account.cooldown_until.is_none_or(|until| until > now))
|
||||
{
|
||||
AccountStatus::QuotaExhausted
|
||||
} else if matches!(
|
||||
account.availability,
|
||||
crate::model::accounts::AccountAvailability::Expired
|
||||
| crate::model::accounts::AccountAvailability::Invalid
|
||||
) || account
|
||||
.access_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
{
|
||||
AccountStatus::Expired
|
||||
} else {
|
||||
AccountStatus::Active
|
||||
}
|
||||
}
|
||||
|
||||
fn retained_usage_range(now: chrono::DateTime<Utc>, retention_days: u32) -> TimeRange {
|
||||
// 账号留存投影是内部查询,不受外部观测接口 366 天上限约束。
|
||||
TimeRange {
|
||||
start: now - Duration::days(i64::from(retention_days)),
|
||||
end: now,
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_usage_window(
|
||||
account_id: &str,
|
||||
window: &ProviderQuotaWindow,
|
||||
) -> Option<AccountUsageWindowQuery> {
|
||||
if window.local_usage.is_some() {
|
||||
return None;
|
||||
}
|
||||
let reset_at = window.reset_at?;
|
||||
let seconds = i64::try_from(window.window_seconds?).ok()?;
|
||||
let start = reset_at.checked_sub_signed(Duration::seconds(seconds))?;
|
||||
let range = TimeRange::new(start, reset_at).ok()?;
|
||||
// 上游百分比以该 reset 边界定义;以当前时间回推会让本地 Token 属于另一窗口。
|
||||
Some(AccountUsageWindowQuery {
|
||||
account_id: account_id.to_owned(),
|
||||
key: window.key.clone(),
|
||||
range,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 管理员认证状态机。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use subtle::ConstantTimeEq as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
auth::{
|
||||
AdminAuditEvent, AdminSession, AuditActorKind, LoginCommand, LoginError, LoginResult,
|
||||
},
|
||||
},
|
||||
ports::store::AuthStore,
|
||||
};
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// API 鉴权与管理员登录消费的统一服务。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError>;
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError>;
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError>;
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError>;
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError>;
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError>;
|
||||
}
|
||||
|
||||
/// 会话有效期上限(366 天);超出的配置按上限截断,保证 TTL 构造与
|
||||
/// `Utc::now() + session_ttl` 永不越界。
|
||||
const MAX_SESSION_TTL_MINUTES: i64 = 366 * 24 * 60;
|
||||
|
||||
/// 单管理员认证策略的最终实现。
|
||||
pub(crate) struct DefaultAuthService {
|
||||
default_admin_user_id: String,
|
||||
session_ttl: Duration,
|
||||
store: Arc<dyn AuthStore>,
|
||||
}
|
||||
|
||||
impl DefaultAuthService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
default_admin_user_id: impl Into<String>,
|
||||
session_ttl_minutes: u64,
|
||||
store: Arc<dyn AuthStore>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(MAX_SESSION_TTL_MINUTES)
|
||||
.clamp(1, MAX_SESSION_TTL_MINUTES);
|
||||
Self {
|
||||
default_admin_user_id: default_admin_user_id.into(),
|
||||
session_ttl: Duration::minutes(minutes),
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind: AuditActorKind::AdminSession,
|
||||
actor_admin_user_id: Some(self.default_admin_user_id.clone()),
|
||||
actor_ref: format!("admin:{}", self.default_admin_user_id),
|
||||
request_id: None,
|
||||
action: action.to_owned(),
|
||||
entity_kind: "admin_session".to_owned(),
|
||||
entity_ref: self.default_admin_user_id.clone(),
|
||||
config_revision: None,
|
||||
changed_fields: Vec::new(),
|
||||
occurred_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthService for DefaultAuthService {
|
||||
async fn ensure_default_admin(&self, password: &str) -> Result<bool, AdminError> {
|
||||
let hash = hash_admin_password(password)?;
|
||||
self.store
|
||||
.create_password_hash_if_absent(&self.default_admin_user_id, &hash)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator"))
|
||||
}
|
||||
|
||||
async fn resolve_admin_user_id(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
let Some(session_id) = session_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.store
|
||||
.load_session(session_id)
|
||||
.await
|
||||
.map(|session| {
|
||||
session
|
||||
.filter(|session| session.expires_at > Utc::now())
|
||||
.map(|session| session.admin_user_id)
|
||||
})
|
||||
.map_err(|error| map_store_error(error, "administrator session"))
|
||||
}
|
||||
|
||||
async fn verify_admin_api_key(&self, key: &str) -> Result<bool, AdminError> {
|
||||
if !valid_admin_api_key_shape(key) {
|
||||
return Ok(false);
|
||||
}
|
||||
let stored = self
|
||||
.store
|
||||
.load_admin_api_key()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
Ok(stored.as_ref().is_some_and(|stored| {
|
||||
let stored = stored.expose_for_auth();
|
||||
key.len() == stored.len() && bool::from(key.as_bytes().ct_eq(stored.as_bytes()))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
|
||||
if command
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
.store
|
||||
.load_password_hash(&self.default_admin_user_id)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
|
||||
let session_id = random_session_token();
|
||||
let expires_at = Utc::now() + self.session_ttl;
|
||||
self.store
|
||||
.store_session(
|
||||
&session_id,
|
||||
&AdminSession {
|
||||
admin_user_id: self.default_admin_user_id.clone(),
|
||||
expires_at,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
if self
|
||||
.store
|
||||
.append_audit_event(self.auth_audit("admin.login", Utc::now()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self.store.delete_session(&session_id).await;
|
||||
return Err(LoginError::Unavailable);
|
||||
}
|
||||
Ok(LoginResult {
|
||||
session_id,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
async fn validate_session(&self, session_id: Option<&str>) -> Result<bool, AdminError> {
|
||||
Ok(self.resolve_admin_user_id(session_id).await?.is_some())
|
||||
}
|
||||
|
||||
async fn logout(&self, session_id: &str) -> Result<(), AdminError> {
|
||||
let session = self
|
||||
.store
|
||||
.delete_session(session_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator session"))?;
|
||||
if let Some(session) = session {
|
||||
let mut event = self.auth_audit("admin.logout", Utc::now());
|
||||
event.actor_admin_user_id = Some(session.admin_user_id.clone());
|
||||
event.actor_ref = format!("admin:{}", session.admin_user_id);
|
||||
event.entity_ref = session.admin_user_id;
|
||||
self.store
|
||||
.append_audit_event(event)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator audit"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|_| AdminError::internal("Failed to hash administrator password"))
|
||||
}
|
||||
|
||||
fn verify_admin_password(password: &str, encoded: &str) -> Result<bool, AdminError> {
|
||||
let hash = PasswordHash::new(encoded)
|
||||
.map_err(|_| AdminError::internal("Stored administrator password hash is invalid"))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
fn random_session_token() -> String {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
format!("session_{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
fn valid_admin_api_key_shape(value: &str) -> bool {
|
||||
value.len() == 70
|
||||
&& value.starts_with("admin-")
|
||||
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Client API Key 管理用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeySecret, ClientKeySortField, CreateClientKey, CreatedClientKey,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::ClientKeyStore,
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API 消费的 Client Key 管理服务。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyEnabled,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultClientKeyService {
|
||||
store: Arc<dyn ClientKeyStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultClientKeyService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(store: Arc<dyn ClientKeyStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
|
||||
Self { store, snapshot }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError> {
|
||||
validate_cursor(&query)?;
|
||||
self.store
|
||||
.list_client_keys(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))
|
||||
}
|
||||
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError> {
|
||||
self.store
|
||||
.reveal_client_key(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?
|
||||
.ok_or_else(|| AdminError::not_found("Client API key was not found"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateClientKey,
|
||||
) -> Result<CreatedClientKey, AdminError> {
|
||||
let id = ClientApiKeyId::new(format!("key_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("Failed to create Client API key ID"))?;
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let plaintext = format!("sk_{}", URL_SAFE_NO_PAD.encode(bytes));
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
provider_kind: command.provider_kind,
|
||||
limits: command.limits,
|
||||
plaintext: plaintext.clone(),
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(CreatedClientKey {
|
||||
config_revision,
|
||||
secret: ClientKeySecret::new(record, plaintext),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
let id = command.id.clone();
|
||||
let (config_revision, record) = self
|
||||
.store
|
||||
.update_client_key(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
record: Some(record),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyEnabled,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
let id = command.id.clone();
|
||||
let (config_revision, record) =
|
||||
self.store
|
||||
.set_client_key_enabled(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
record: Some(record),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteClientKey,
|
||||
) -> Result<ClientKeyMutation, AdminError> {
|
||||
let id = command.id.clone();
|
||||
let config_revision = self
|
||||
.store
|
||||
.delete_client_key(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), config_revision).await?;
|
||||
Ok(ClientKeyMutation {
|
||||
config_revision,
|
||||
record: None,
|
||||
id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cursor(query: &ClientKeyListQuery) -> Result<(), AdminError> {
|
||||
let Some(cursor) = &query.cursor else {
|
||||
return Ok(());
|
||||
};
|
||||
if cursor.sort != query.sort {
|
||||
return Err(AdminError::invalid(
|
||||
"Client API key cursor sort does not match the query",
|
||||
));
|
||||
}
|
||||
let matches = matches!(
|
||||
(cursor.sort.field, &cursor.value),
|
||||
(ClientKeySortField::Name, ClientKeyCursorValue::Name(value)) if !value.trim().is_empty()
|
||||
) || matches!(
|
||||
(cursor.sort.field, &cursor.value),
|
||||
(
|
||||
ClientKeySortField::Enabled,
|
||||
ClientKeyCursorValue::Enabled(_)
|
||||
) | (
|
||||
ClientKeySortField::CreatedAt,
|
||||
ClientKeyCursorValue::CreatedAt(_)
|
||||
) | (
|
||||
ClientKeySortField::LastUsedAt,
|
||||
ClientKeyCursorValue::LastUsedAt(_)
|
||||
)
|
||||
);
|
||||
if matches {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("Invalid Client API key cursor"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
//! 管理控制面的用例实现。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod xai;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
accounts::{DeleteAccounts, SetAccountEnabled},
|
||||
provider_credentials::{
|
||||
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialDetails, CredentialMutation, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdmin,
|
||||
store::{AccountStore, AdminStoreError, AdminStoreErrorKind},
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
|
||||
};
|
||||
|
||||
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
AdminStoreErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
AdminStoreErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
AdminStoreErrorKind::StaleRevision | AdminStoreErrorKind::Conflict => {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
AdminStoreErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
};
|
||||
AdminError::new(kind, format!("{resource} operation failed"))
|
||||
}
|
||||
|
||||
fn map_provider_error(
|
||||
error: crate::ports::provider::ProviderAdminError,
|
||||
resource: &'static str,
|
||||
) -> AdminError {
|
||||
use crate::ports::provider::ProviderAdminErrorKind;
|
||||
|
||||
let kind = match error.kind() {
|
||||
ProviderAdminErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::Unsupported => AdminErrorKind::Invalid,
|
||||
ProviderAdminErrorKind::NotFound => AdminErrorKind::NotFound,
|
||||
ProviderAdminErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, format!("{resource} operation failed"))
|
||||
}
|
||||
|
||||
async fn publish_committed(
|
||||
snapshot: &dyn SnapshotControl,
|
||||
revision: crate::model::Revision,
|
||||
) -> Result<(), AdminError> {
|
||||
let revision = ConfigRevision::new(revision.get())
|
||||
.map_err(|_| AdminError::internal("Committed configuration revision is invalid"))?;
|
||||
snapshot.publish_committed(revision).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn required_credential(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
account_id: &ProviderAccountId,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
accounts
|
||||
.credential_details(provider_kind, account_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?
|
||||
.ok_or_else(|| AdminError::not_found(format!("{resource} was not found")))
|
||||
}
|
||||
|
||||
async fn pending_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
provider_kind: &ProviderKind,
|
||||
command: &StartAuthorization,
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let target = match &command.reauthorization {
|
||||
Some(account_id) => {
|
||||
required_credential(accounts, provider_kind, account_id, resource).await?;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => AuthorizationMutationTarget::Create {
|
||||
name: command.name.clone(),
|
||||
},
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
&command.context,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_prepared_import(
|
||||
provider_kind: &ProviderKind,
|
||||
prepared: &PreparedCredentialImport,
|
||||
resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
if prepared.provider_kind != *provider_kind
|
||||
|| prepared
|
||||
.credentials
|
||||
.iter()
|
||||
.any(|credential| credential.provider_kind != *provider_kind)
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared facts do not match the requested Provider scope"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_prepared_rotation(
|
||||
account: &crate::model::accounts::AccountRecord,
|
||||
prepared: &PreparedCredentialRotation,
|
||||
resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
let facts = prepared.facts();
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared facts do not match the current credential"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_authorization_commit(
|
||||
provider_kind: &ProviderKind,
|
||||
context: &MutationContext,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
resource: &'static str,
|
||||
) -> Result<PreparedAuthorizationCommit, AdminError> {
|
||||
if prepared.pending.provider_kind() != provider_kind
|
||||
|| !prepared.pending.owner_binding().matches_context(context)
|
||||
{
|
||||
let validation_error = AdminError::conflict(format!(
|
||||
"{resource} pending authorization binding is invalid"
|
||||
));
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
let matches_target = match (prepared.pending.target(), &prepared.credential) {
|
||||
(
|
||||
AuthorizationMutationTarget::Create { .. },
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
) => {
|
||||
let facts = credential.facts();
|
||||
facts.provider_kind == *provider_kind && facts.account_id == *account_id
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !matches_target {
|
||||
let validation_error = AdminError::conflict(format!(
|
||||
"{resource} prepared credential does not match its pending target"
|
||||
));
|
||||
if let Err(error) = prepared.abort().await {
|
||||
tracing::warn!(
|
||||
resource,
|
||||
settlement_error = %error,
|
||||
"OAuth authorization claim release failed after preparation validation"
|
||||
);
|
||||
return Err(error);
|
||||
}
|
||||
return Err(validation_error);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let crate::model::provider_credentials::AuthorizationCommitSettlement {
|
||||
command,
|
||||
credential_guard,
|
||||
authorization_guard,
|
||||
} = prepared.into_commit();
|
||||
match accounts.commit_authorization(command, context).await {
|
||||
Ok(result) => {
|
||||
if let Some(guard) = credential_guard {
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.commit().await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(credential_guard);
|
||||
if let Some(guard) = authorization_guard
|
||||
&& let Err(settlement_error) = guard.abort().await
|
||||
{
|
||||
tracing::warn!(
|
||||
resource,
|
||||
store_error = %error,
|
||||
settlement_error = %settlement_error,
|
||||
"OAuth authorization claim release failed after Store commit failure"
|
||||
);
|
||||
return Err(settlement_error);
|
||||
}
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
guard.finish();
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
Err(map_store_error(error, resource))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_credential_enabled(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialMutation,
|
||||
enabled: bool,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
required_credential(
|
||||
accounts,
|
||||
provider.provider_kind(),
|
||||
&command.account_id,
|
||||
resource,
|
||||
)
|
||||
.await?;
|
||||
let account_id = command.account_id;
|
||||
let revision = accounts
|
||||
.set_account_enabled(
|
||||
SetAccountEnabled {
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
enabled,
|
||||
},
|
||||
&command.context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
if !enabled {
|
||||
provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
Ok(CredentialMutationResult {
|
||||
config_revision: revision,
|
||||
account_id,
|
||||
credential_revision: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_credentials(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
command: CredentialDeletion,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
for account_id in &command.account_ids {
|
||||
required_credential(accounts, provider.provider_kind(), account_id, resource).await?;
|
||||
}
|
||||
let account_ids = command.account_ids;
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
.collect(),
|
||||
},
|
||||
&command.context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, resource))?;
|
||||
for account_id in &account_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
provider.account_facts_changed(&account_ids).await;
|
||||
Ok(CredentialDeletionResult {
|
||||
config_revision: revision,
|
||||
account_ids,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
//! 观测查询、趋势、费用覆盖与健康阈值规则。
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, btree_map::Entry},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
observability::{
|
||||
CostCoverage, CurrencyCost, DashboardAccountUsage, DashboardCapacity,
|
||||
DashboardPeriodMetrics, DashboardResult, DecimalAmount, DiagnosticDimension,
|
||||
DiagnosticsItem, DiagnosticsResult, HealthStatus, HealthTimeline, HealthTimelinePoint,
|
||||
OpsErrorPage, OpsErrorQuery, ProviderBillingInput, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, Trend, TrendKind, TrendPoint, TrendSummary, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::{ObservabilityStore, SettingsStore},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{map_provider_error, map_store_error};
|
||||
|
||||
const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_SLOTS: i64 = 24 * 4;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 99.0;
|
||||
|
||||
/// API 消费的观测控制面服务。
|
||||
#[async_trait]
|
||||
pub trait ObservabilityService: Send + Sync {
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError>;
|
||||
async fn dashboard_trend(&self, range: TimeRange, kind: TrendKind)
|
||||
-> Result<Trend, AdminError>;
|
||||
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError>;
|
||||
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError>;
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageSummary, AdminError>;
|
||||
async fn usage_insights(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError>;
|
||||
async fn diagnostics(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError>;
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultObservabilityService {
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
settings,
|
||||
providers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityService for DefaultObservabilityService {
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let (mut observation, settings, runtime_slots) = futures::try_join!(
|
||||
self.store.dashboard_summary(range),
|
||||
self.settings.load_runtime_settings(),
|
||||
self.store.dashboard_runtime_slots(range.end),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_billing(&mut observation.recent_requests);
|
||||
self.enrich_dashboard_quotas(&mut observation.account_usage)
|
||||
.await;
|
||||
let today_start = china_day_start(observation.range.end);
|
||||
let yesterday_start = today_start - Duration::days(1);
|
||||
let today =
|
||||
dashboard_period_metrics(&observation.trend, today_start, observation.range.end);
|
||||
let yesterday = dashboard_period_metrics(&observation.trend, yesterday_start, today_start);
|
||||
let total_billing_usd = observation
|
||||
.attempts
|
||||
.costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency == "USD")
|
||||
.map(|cost| cost.amount.clone());
|
||||
let total_cached_token_rate = rate_or_zero(
|
||||
observation.requests.cached_tokens,
|
||||
observation.requests.input_tokens,
|
||||
);
|
||||
let average_first_token_latency_ms = average(
|
||||
observation.requests.first_token_latency_sum_ms,
|
||||
observation.requests.first_token_latency_count,
|
||||
);
|
||||
let trend = trend(kind, observation.trend.clone())?;
|
||||
let health_timeline = health_timeline_at(&observation.trend, Utc::now());
|
||||
let wire_profiles = self.providers.dashboard_wire_profiles();
|
||||
let max_concurrent_per_account = u64::from(settings.max_concurrent_per_account);
|
||||
let active_accounts = runtime_slots
|
||||
.as_ref()
|
||||
.map_or(observation.provider_accounts.active, |slots| {
|
||||
slots.active_accounts
|
||||
});
|
||||
let total_slots = active_accounts.saturating_mul(max_concurrent_per_account);
|
||||
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
|
||||
Ok(DashboardResult {
|
||||
capacity: DashboardCapacity {
|
||||
max_concurrent_per_account,
|
||||
total_slots,
|
||||
used_slots,
|
||||
available_slots: used_slots.map(|used| total_slots.saturating_sub(used)),
|
||||
},
|
||||
rotation_strategy: settings.rotation_strategy,
|
||||
observation,
|
||||
today,
|
||||
yesterday,
|
||||
total_billing_usd,
|
||||
total_cached_token_rate,
|
||||
average_first_token_latency_ms,
|
||||
trend,
|
||||
health_timeline,
|
||||
wire_profiles,
|
||||
})
|
||||
}
|
||||
|
||||
async fn dashboard_trend(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<Trend, AdminError> {
|
||||
let points = self
|
||||
.store
|
||||
.dashboard_trend(range)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "dashboard trend"))?;
|
||||
trend(kind, points)
|
||||
}
|
||||
|
||||
async fn usage_records(&self, query: UsageQuery) -> Result<UsagePage, AdminError> {
|
||||
let mut page = self
|
||||
.store
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_billing(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, request_id: &str) -> Result<UsageDetail, AdminError> {
|
||||
if request_id.trim().is_empty() {
|
||||
return Err(AdminError::invalid("Usage record ID is required"));
|
||||
}
|
||||
let mut detail = self
|
||||
.store
|
||||
.usage_record_detail(request_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage record"))?;
|
||||
self.enrich_billing(std::slice::from_mut(&mut detail.request));
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
async fn usage_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageSummary, AdminError> {
|
||||
let overview = self
|
||||
.store
|
||||
.usage_summary(range, filter)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage summary"))?;
|
||||
let average_latency_ms = average(
|
||||
overview.requests.latency_sum_ms,
|
||||
overview.requests.latency_count,
|
||||
);
|
||||
Ok(UsageSummary {
|
||||
overview,
|
||||
average_latency_ms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn usage_insights(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let (overview, trend, billing_facts) = futures::try_join!(
|
||||
self.store.usage_summary(range, filter.clone()),
|
||||
self.store.usage_trend(range, filter.clone()),
|
||||
self.store.usage_calculated_billing_facts(range, filter),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "usage insights"))?;
|
||||
let standard_costs = recover_standard_costs(&self.providers, billing_facts)?;
|
||||
build_usage_insights(overview, trend, standard_costs)
|
||||
}
|
||||
|
||||
async fn diagnostics(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
filter: UsageFilter,
|
||||
dimension: DiagnosticDimension,
|
||||
) -> Result<DiagnosticsResult, AdminError> {
|
||||
let items = self
|
||||
.store
|
||||
.usage_diagnostics(range, filter, dimension)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage diagnostics"))?;
|
||||
let total_requests = items.iter().fold(0_u64, |total, item| {
|
||||
total.saturating_add(item.request_count)
|
||||
});
|
||||
Ok(DiagnosticsResult {
|
||||
dimension,
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|item| DiagnosticsItem {
|
||||
name: item.name,
|
||||
request_count: item.request_count,
|
||||
success_count: item.request_count.saturating_sub(item.failure_count),
|
||||
error_count: item.failure_count,
|
||||
error_rate: rate_or_zero(item.failure_count, item.request_count),
|
||||
request_share: rate_or_zero(item.request_count, total_requests),
|
||||
average_latency_ms: item.average_latency_ms,
|
||||
estimated_cost: usd_cost(&item.costs),
|
||||
attempt_count: item.attempt_count,
|
||||
total_tokens: item.total_tokens,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn ops_errors(&self, query: OpsErrorQuery) -> Result<OpsErrorPage, AdminError> {
|
||||
self.store
|
||||
.list_ops_errors(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "operations errors"))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_usage_insights(
|
||||
overview: UsageOverview,
|
||||
trend: Vec<RequestMetricPoint>,
|
||||
standard_costs: UsageStandardCosts,
|
||||
) -> Result<UsageInsights, AdminError> {
|
||||
let granularity = trend.first().map_or(
|
||||
crate::model::observability::Granularity::FifteenMinutes,
|
||||
|point| point.granularity,
|
||||
);
|
||||
if trend.iter().any(|point| point.granularity != granularity) {
|
||||
return Err(AdminError::internal(
|
||||
"Usage insight trend contains mixed granularities",
|
||||
));
|
||||
}
|
||||
|
||||
let requests = &overview.requests;
|
||||
let failed_requests = service_failure_count(requests);
|
||||
let health_requests = requests.success_count.saturating_add(failed_requests);
|
||||
let health = UsageInsightsHealth {
|
||||
total_requests: health_requests,
|
||||
success_requests: requests.success_count,
|
||||
failed_requests,
|
||||
cancelled_requests: requests.cancelled_count,
|
||||
incomplete_requests: requests.incomplete_count,
|
||||
caller_error_requests: requests.caller_error_count,
|
||||
success_rate: rate_or_zero(requests.success_count, health_requests),
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let failed_requests = service_failure_count(&point.metrics);
|
||||
let health_requests = point.metrics.success_count.saturating_add(failed_requests);
|
||||
UsageInsightsHealthPoint {
|
||||
bucket_start: point.bucket_start,
|
||||
success_requests: point.metrics.success_count,
|
||||
failed_requests,
|
||||
cancelled_requests: point.metrics.cancelled_count,
|
||||
incomplete_requests: point.metrics.incomplete_count,
|
||||
caller_error_requests: point.metrics.caller_error_count,
|
||||
error_rate: rate_or_zero(failed_requests, health_requests),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let performance = UsageInsightsPerformance {
|
||||
latency_percentiles: requests.latency_percentiles,
|
||||
first_token_latency_percentiles: requests.first_token_latency_percentiles,
|
||||
latency_coverage: rate_or_zero(requests.latency_count, requests.request_count),
|
||||
first_token_coverage: rate_or_zero(
|
||||
requests.first_token_latency_count,
|
||||
requests.request_count,
|
||||
),
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| UsageInsightsPerformancePoint {
|
||||
bucket_start: point.bucket_start,
|
||||
latency_percentiles: point.metrics.latency_percentiles,
|
||||
first_token_latency_percentiles: point.metrics.first_token_latency_percentiles,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let estimated_cost = usd_cost(&overview.attempts.costs);
|
||||
let UsageStandardCosts {
|
||||
total: standard_cost,
|
||||
by_bucket: standard_costs_by_bucket,
|
||||
} = standard_costs;
|
||||
let cost = UsageInsightsCost {
|
||||
cost_per_request: estimated_cost
|
||||
.as_ref()
|
||||
.and_then(|cost| cost.checked_div_u64(requests.request_count)),
|
||||
estimated_cost,
|
||||
standard_cost,
|
||||
tokens_per_request: rate_or_zero(requests.total_tokens, requests.request_count),
|
||||
cached_token_rate: rate_or_zero(requests.cached_tokens, requests.input_tokens),
|
||||
cache_hit_request_rate: ratio(
|
||||
requests.cache_hit_request_count,
|
||||
requests.cache_eligible_request_count,
|
||||
),
|
||||
input_tokens: requests.input_tokens,
|
||||
output_tokens: requests.output_tokens,
|
||||
cached_tokens: requests.cached_tokens,
|
||||
total_tokens: requests.total_tokens,
|
||||
points: trend
|
||||
.iter()
|
||||
.map(|point| UsageInsightsCostPoint {
|
||||
bucket_start: point.bucket_start,
|
||||
input_tokens: point.metrics.input_tokens,
|
||||
output_tokens: point.metrics.output_tokens,
|
||||
cached_tokens: point.metrics.cached_tokens,
|
||||
total_tokens: point.metrics.total_tokens,
|
||||
estimated_cost: usd_cost(&point.costs),
|
||||
standard_cost: standard_costs_by_bucket.get(&point.bucket_start).cloned(),
|
||||
cached_token_rate: rate_or_zero(
|
||||
point.metrics.cached_tokens,
|
||||
point.metrics.input_tokens,
|
||||
),
|
||||
cache_hit_request_rate: ratio(
|
||||
point.metrics.cache_hit_request_count,
|
||||
point.metrics.cache_eligible_request_count,
|
||||
),
|
||||
})
|
||||
.collect(),
|
||||
costs: overview.attempts.costs.clone(),
|
||||
coverage: overview.attempts.cost_coverage.clone(),
|
||||
};
|
||||
Ok(UsageInsights {
|
||||
granularity,
|
||||
health,
|
||||
performance,
|
||||
cost,
|
||||
attempts: overview.attempts,
|
||||
providers: overview.providers,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageStandardCosts {
|
||||
total: Option<DecimalAmount>,
|
||||
by_bucket: BTreeMap<DateTime<Utc>, DecimalAmount>,
|
||||
}
|
||||
|
||||
impl UsageStandardCosts {
|
||||
fn add(
|
||||
&mut self,
|
||||
bucket_start: DateTime<Utc>,
|
||||
amount: DecimalAmount,
|
||||
) -> Result<(), AdminError> {
|
||||
self.total = Some(match self.total.take() {
|
||||
Some(current) => current
|
||||
.checked_add(&amount)
|
||||
.ok_or_else(|| AdminError::internal("Usage standard cost exceeds decimal range"))?,
|
||||
None => amount.clone(),
|
||||
});
|
||||
match self.by_bucket.entry(bucket_start) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let sum = entry.get().checked_add(&amount).ok_or_else(|| {
|
||||
AdminError::internal("Usage standard cost exceeds decimal range")
|
||||
})?;
|
||||
entry.insert(sum);
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(amount);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn recover_standard_costs(
|
||||
providers: &ProviderAdminRegistry,
|
||||
facts: Vec<UsageCalculatedBillingFact>,
|
||||
) -> Result<UsageStandardCosts, AdminError> {
|
||||
let mut standard_costs = UsageStandardCosts::default();
|
||||
for fact in facts {
|
||||
let Ok(provider_kind) = ProviderKind::new(fact.provider_kind) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: fact.upstream_model_id,
|
||||
service_tier: fact.service_tier,
|
||||
input_tokens: fact.input_tokens,
|
||||
output_tokens: fact.output_tokens,
|
||||
cached_tokens: fact.cached_tokens,
|
||||
cache_write_tokens: fact.cache_write_tokens,
|
||||
total: fact.total,
|
||||
};
|
||||
let breakdown = match providers.calculated_billing(&provider_kind, &input) {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(error) if error.kind() == ProviderAdminErrorKind::Unsupported => continue,
|
||||
Err(error) => return Err(map_provider_error(error, "usage billing")),
|
||||
};
|
||||
let Some(breakdown) = breakdown else {
|
||||
continue;
|
||||
};
|
||||
if !breakdown
|
||||
.standard_amount
|
||||
.currency
|
||||
.eq_ignore_ascii_case("USD")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
standard_costs.add(fact.bucket_start, breakdown.standard_amount.amount)?;
|
||||
}
|
||||
Ok(standard_costs)
|
||||
}
|
||||
|
||||
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
|
||||
costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"))
|
||||
.map(|cost| cost.amount.clone())
|
||||
}
|
||||
|
||||
impl DefaultObservabilityService {
|
||||
async fn enrich_dashboard_quotas(&self, accounts: &mut [DashboardAccountUsage]) {
|
||||
let providers = self.providers.clone();
|
||||
let quota_reads = accounts.iter().map(|account| {
|
||||
let account_id = ProviderAccountId::new(account.account_id.clone()).ok();
|
||||
let provider = ProviderKind::new(account.provider_kind.clone())
|
||||
.ok()
|
||||
.and_then(|kind| providers.require(&kind).ok());
|
||||
async move {
|
||||
let (Some(account_id), Some(provider)) = (account_id, provider) else {
|
||||
return None;
|
||||
};
|
||||
provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id,
|
||||
refresh: false,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|quota| quota.representative_used_percent())
|
||||
}
|
||||
});
|
||||
let quota_used = futures::future::join_all(quota_reads).await;
|
||||
for (account, used_percent) in accounts.iter_mut().zip(quota_used) {
|
||||
account.quota_used_percent = used_percent
|
||||
.or_else(|| (account.availability == "quota_exhausted").then_some(100.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 Provider kind、
|
||||
/// 不支持的来源或费用规则失败)只保留该条已存的总额,不影响整页返回。
|
||||
fn enrich_billing(&self, records: &mut [crate::model::observability::UsageRecord]) {
|
||||
for record in records {
|
||||
let Some(UsageBilling::Total { source, total }) = record.billing.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(source.as_str(), "calculated" | "provider_reported") {
|
||||
continue;
|
||||
}
|
||||
let (Some(provider), Some(upstream_model_id)) = (
|
||||
record.provider_kind.as_deref(),
|
||||
record.upstream_model_id.as_ref(),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(provider_kind) = ProviderKind::new(provider.to_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let input = ProviderBillingInput {
|
||||
upstream_model_id: upstream_model_id.clone(),
|
||||
service_tier: record.service_tier.clone(),
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
cached_tokens: record.cached_tokens,
|
||||
cache_write_tokens: record.cache_write_tokens,
|
||||
total: total.clone(),
|
||||
};
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按指定时刻计算中国自然日的 96 个 15 分钟健康桶。
|
||||
#[must_use]
|
||||
fn health_timeline_at(records: &[RequestMetricPoint], now: DateTime<Utc>) -> HealthTimeline {
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let start = china_day_start(now);
|
||||
let mut buckets = (0..HEALTH_TIMELINE_SLOTS)
|
||||
.map(|index| {
|
||||
(
|
||||
start + Duration::minutes(HEALTH_TIMELINE_SLOT_MINUTES * index),
|
||||
HealthWindow::default(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for record in records {
|
||||
if record.bucket_start < start || record.bucket_start > now {
|
||||
continue;
|
||||
}
|
||||
let record_slot = quarter_hour_start(record.bucket_start);
|
||||
if let Some((_, bucket)) = buckets
|
||||
.iter_mut()
|
||||
.find(|(bucket_start, _)| *bucket_start == record_slot)
|
||||
{
|
||||
bucket.add_metrics(&record.metrics);
|
||||
}
|
||||
}
|
||||
|
||||
let totals = buckets
|
||||
.iter()
|
||||
.filter(|(bucket_start, _)| *bucket_start <= current_slot)
|
||||
.fold(HealthWindow::default(), |mut totals, (_, bucket)| {
|
||||
totals.add_window(*bucket);
|
||||
totals
|
||||
});
|
||||
HealthTimeline {
|
||||
reliability_percent: health_reliability(totals),
|
||||
status: health_status(totals, false),
|
||||
success_requests: totals.success_requests,
|
||||
failed_requests: totals.failed_requests,
|
||||
cancelled_requests: totals.cancelled_requests,
|
||||
incomplete_requests: totals.incomplete_requests,
|
||||
caller_error_requests: totals.caller_error_requests,
|
||||
points: buckets
|
||||
.into_iter()
|
||||
.map(|(bucket_start, bucket)| HealthTimelinePoint {
|
||||
bucket_start,
|
||||
status: health_status(bucket, bucket_start > current_slot),
|
||||
reliability_percent: health_reliability(bucket),
|
||||
success_requests: bucket.success_requests,
|
||||
failed_requests: bucket.failed_requests,
|
||||
cancelled_requests: bucket.cancelled_requests,
|
||||
incomplete_requests: bucket.incomplete_requests,
|
||||
caller_error_requests: bucket.caller_error_requests,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn trend(kind: TrendKind, points: Vec<RequestMetricPoint>) -> Result<Trend, AdminError> {
|
||||
let mut metrics = RequestMetrics::default();
|
||||
let mut coverage = CostCoverage::default();
|
||||
let mut costs = BTreeMap::<String, DecimalAmount>::new();
|
||||
for point in &points {
|
||||
add_request_metrics(&mut metrics, &point.metrics);
|
||||
coverage.provider_reported_count = coverage
|
||||
.provider_reported_count
|
||||
.saturating_add(point.cost_coverage.provider_reported_count);
|
||||
coverage.calculated_count = coverage
|
||||
.calculated_count
|
||||
.saturating_add(point.cost_coverage.calculated_count);
|
||||
coverage.partial_count = coverage
|
||||
.partial_count
|
||||
.saturating_add(point.cost_coverage.partial_count);
|
||||
coverage.unavailable_count = coverage
|
||||
.unavailable_count
|
||||
.saturating_add(point.cost_coverage.unavailable_count);
|
||||
coverage.not_billable_count = coverage
|
||||
.not_billable_count
|
||||
.saturating_add(point.cost_coverage.not_billable_count);
|
||||
for cost in &point.costs {
|
||||
if let Some(amount) = costs.get_mut(&cost.currency) {
|
||||
*amount = amount.checked_add(&cost.amount).ok_or_else(|| {
|
||||
AdminError::internal("Cost aggregation exceeded numeric bounds")
|
||||
})?;
|
||||
} else {
|
||||
costs.insert(cost.currency.clone(), cost.amount.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let average_latency_ms = average(metrics.latency_sum_ms, metrics.latency_count);
|
||||
let service_failure_count = service_failure_count(&metrics);
|
||||
let success_rate = ratio(
|
||||
metrics.success_count,
|
||||
metrics.success_count.saturating_add(service_failure_count),
|
||||
);
|
||||
let cache_hit_request_rate = ratio(
|
||||
metrics.cache_hit_request_count,
|
||||
metrics.cache_eligible_request_count,
|
||||
);
|
||||
Ok(Trend {
|
||||
kind,
|
||||
points: points.into_iter().map(trend_point).collect(),
|
||||
summary: TrendSummary {
|
||||
request_count: metrics.request_count,
|
||||
input_tokens: metrics.input_tokens,
|
||||
output_tokens: metrics.output_tokens,
|
||||
cached_tokens: metrics.cached_tokens,
|
||||
total_tokens: metrics.total_tokens,
|
||||
service_failure_count,
|
||||
average_latency_ms,
|
||||
max_latency_ms: metrics.max_latency_ms,
|
||||
min_latency_ms: metrics.min_latency_ms,
|
||||
success_rate,
|
||||
cache_hit_request_rate,
|
||||
costs: costs
|
||||
.into_iter()
|
||||
.map(|(currency, amount)| CurrencyCost { currency, amount })
|
||||
.collect(),
|
||||
cost_coverage: coverage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn trend_point(point: RequestMetricPoint) -> TrendPoint {
|
||||
let service_failure_count = service_failure_count(&point.metrics);
|
||||
TrendPoint {
|
||||
bucket_start: point.bucket_start,
|
||||
granularity: point.granularity,
|
||||
service_failure_count,
|
||||
average_latency_ms: average(point.metrics.latency_sum_ms, point.metrics.latency_count),
|
||||
average_first_token_latency_ms: average(
|
||||
point.metrics.first_token_latency_sum_ms,
|
||||
point.metrics.first_token_latency_count,
|
||||
),
|
||||
cached_token_rate: rate_or_zero(point.metrics.cached_tokens, point.metrics.input_tokens),
|
||||
cache_hit_request_rate: ratio(
|
||||
point.metrics.cache_hit_request_count,
|
||||
point.metrics.cache_eligible_request_count,
|
||||
),
|
||||
success_rate: ratio(point.metrics.success_count, point.metrics.request_count),
|
||||
metrics: point.metrics,
|
||||
cost_coverage: point.cost_coverage,
|
||||
costs: point.costs,
|
||||
}
|
||||
}
|
||||
|
||||
fn dashboard_period_metrics(
|
||||
points: &[RequestMetricPoint],
|
||||
start: DateTime<Utc>,
|
||||
end: DateTime<Utc>,
|
||||
) -> DashboardPeriodMetrics {
|
||||
let mut result = DashboardPeriodMetrics::default();
|
||||
for metrics in points
|
||||
.iter()
|
||||
.filter(|point| point.bucket_start >= start && point.bucket_start < end)
|
||||
.map(|point| &point.metrics)
|
||||
{
|
||||
result.request_count = result.request_count.saturating_add(metrics.request_count);
|
||||
result.input_tokens = result.input_tokens.saturating_add(metrics.input_tokens);
|
||||
result.output_tokens = result.output_tokens.saturating_add(metrics.output_tokens);
|
||||
result.cached_tokens = result.cached_tokens.saturating_add(metrics.cached_tokens);
|
||||
result.total_tokens = result.total_tokens.saturating_add(metrics.total_tokens);
|
||||
}
|
||||
result.cached_token_rate = rate_or_zero(result.cached_tokens, result.input_tokens);
|
||||
result.observed_cached_token_rate = ratio(result.cached_tokens, result.input_tokens);
|
||||
result
|
||||
}
|
||||
|
||||
fn add_request_metrics(total: &mut RequestMetrics, value: &RequestMetrics) {
|
||||
total.request_count = total.request_count.saturating_add(value.request_count);
|
||||
total.success_count = total.success_count.saturating_add(value.success_count);
|
||||
total.failure_count = total.failure_count.saturating_add(value.failure_count);
|
||||
total.cancelled_count = total.cancelled_count.saturating_add(value.cancelled_count);
|
||||
total.incomplete_count = total
|
||||
.incomplete_count
|
||||
.saturating_add(value.incomplete_count);
|
||||
total.caller_error_count = total
|
||||
.caller_error_count
|
||||
.saturating_add(value.caller_error_count);
|
||||
total.input_tokens = total.input_tokens.saturating_add(value.input_tokens);
|
||||
total.output_tokens = total.output_tokens.saturating_add(value.output_tokens);
|
||||
total.cached_tokens = total.cached_tokens.saturating_add(value.cached_tokens);
|
||||
total.cache_write_tokens = total
|
||||
.cache_write_tokens
|
||||
.saturating_add(value.cache_write_tokens);
|
||||
total.reasoning_tokens = total
|
||||
.reasoning_tokens
|
||||
.saturating_add(value.reasoning_tokens);
|
||||
total.total_tokens = total.total_tokens.saturating_add(value.total_tokens);
|
||||
total.first_token_latency_sum_ms = total
|
||||
.first_token_latency_sum_ms
|
||||
.saturating_add(value.first_token_latency_sum_ms);
|
||||
total.first_token_latency_count = total
|
||||
.first_token_latency_count
|
||||
.saturating_add(value.first_token_latency_count);
|
||||
total.latency_sum_ms = total.latency_sum_ms.saturating_add(value.latency_sum_ms);
|
||||
total.latency_count = total.latency_count.saturating_add(value.latency_count);
|
||||
total.min_latency_ms = match (total.min_latency_ms, value.min_latency_ms) {
|
||||
(Some(left), Some(right)) => Some(left.min(right)),
|
||||
(left, right) => left.or(right),
|
||||
};
|
||||
total.max_latency_ms = match (total.max_latency_ms, value.max_latency_ms) {
|
||||
(Some(left), Some(right)) => Some(left.max(right)),
|
||||
(left, right) => left.or(right),
|
||||
};
|
||||
total.cache_eligible_request_count = total
|
||||
.cache_eligible_request_count
|
||||
.saturating_add(value.cache_eligible_request_count);
|
||||
total.cache_hit_request_count = total
|
||||
.cache_hit_request_count
|
||||
.saturating_add(value.cache_hit_request_count);
|
||||
}
|
||||
|
||||
fn average(sum: u64, count: u64) -> Option<u64> {
|
||||
(count > 0).then(|| sum / count)
|
||||
}
|
||||
|
||||
fn ratio(numerator: u64, denominator: u64) -> Option<f64> {
|
||||
(denominator > 0).then(|| numerator as f64 / denominator as f64)
|
||||
}
|
||||
|
||||
fn rate_or_zero(numerator: u64, denominator: u64) -> f64 {
|
||||
ratio(numerator, denominator).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
struct HealthWindow {
|
||||
success_requests: u64,
|
||||
failed_requests: u64,
|
||||
cancelled_requests: u64,
|
||||
incomplete_requests: u64,
|
||||
caller_error_requests: u64,
|
||||
}
|
||||
|
||||
impl HealthWindow {
|
||||
fn add_metrics(&mut self, metrics: &RequestMetrics) {
|
||||
self.success_requests = self.success_requests.saturating_add(metrics.success_count);
|
||||
self.failed_requests = self
|
||||
.failed_requests
|
||||
.saturating_add(service_failure_count(metrics));
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(metrics.cancelled_count);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(metrics.incomplete_count);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(metrics.caller_error_count);
|
||||
}
|
||||
|
||||
fn add_window(&mut self, other: Self) {
|
||||
self.success_requests = self.success_requests.saturating_add(other.success_requests);
|
||||
self.failed_requests = self.failed_requests.saturating_add(other.failed_requests);
|
||||
self.cancelled_requests = self
|
||||
.cancelled_requests
|
||||
.saturating_add(other.cancelled_requests);
|
||||
self.incomplete_requests = self
|
||||
.incomplete_requests
|
||||
.saturating_add(other.incomplete_requests);
|
||||
self.caller_error_requests = self
|
||||
.caller_error_requests
|
||||
.saturating_add(other.caller_error_requests);
|
||||
}
|
||||
}
|
||||
|
||||
fn health_status(bucket: HealthWindow, is_future: bool) -> HealthStatus {
|
||||
let eligible_requests = bucket
|
||||
.success_requests
|
||||
.saturating_add(bucket.failed_requests);
|
||||
if is_future {
|
||||
HealthStatus::Future
|
||||
} else if eligible_requests == 0 {
|
||||
HealthStatus::NoData
|
||||
} else if bucket.success_requests == 0
|
||||
&& bucket.failed_requests >= HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD
|
||||
{
|
||||
HealthStatus::Unavailable
|
||||
} else if eligible_requests < HEALTH_TIMELINE_MIN_SAMPLE_SIZE {
|
||||
HealthStatus::LowSample
|
||||
} else if health_reliability(bucket)
|
||||
.is_some_and(|reliability| reliability < HEALTH_TIMELINE_STABLE_RELIABILITY)
|
||||
{
|
||||
HealthStatus::Unstable
|
||||
} else {
|
||||
HealthStatus::Stable
|
||||
}
|
||||
}
|
||||
|
||||
fn health_reliability(bucket: HealthWindow) -> Option<f64> {
|
||||
let eligible_requests = bucket
|
||||
.success_requests
|
||||
.saturating_add(bucket.failed_requests);
|
||||
(eligible_requests > 0)
|
||||
.then(|| bucket.success_requests as f64 / eligible_requests as f64 * 100.0)
|
||||
}
|
||||
|
||||
fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
metrics
|
||||
.failure_count
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
.rem_euclid(HEALTH_TIMELINE_SLOT_MINUTES * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! OpenAI 管理资源的中立 ProviderAdmin 委托。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutation,
|
||||
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, ProviderQuotaRequest, RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
|
||||
use super::{
|
||||
commit_authorization, commit_credential_rotation, delete_credentials, map_provider_error,
|
||||
map_store_error, pending_authorization, publish_committed, required_credential,
|
||||
set_credential_enabled, validate_authorization_commit, validate_prepared_import,
|
||||
validate_prepared_rotation,
|
||||
};
|
||||
|
||||
/// OpenAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait OpenAiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError>;
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError>;
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn rotate(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultOpenAiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultOpenAiService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
request_id,
|
||||
account_id = %account_id.as_str(),
|
||||
quota_error = ?error.kind(),
|
||||
"OpenAI initial quota observation failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OpenAiService for DefaultOpenAiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential"))
|
||||
}
|
||||
|
||||
async fn details(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<CredentialDetails, AdminError> {
|
||||
required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
account_id,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"OpenAI credential import",
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "OpenAI credential import"))?;
|
||||
self.provider
|
||||
.account_facts_changed(&result.credential_ids)
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
|
||||
self.observe_initial_quotas(&result.credential_ids, &context.request_id)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(pending)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
prepared,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
&context,
|
||||
"OpenAI authorization",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
self.observe_initial_quotas(
|
||||
std::slice::from_ref(&result.account_id),
|
||||
&context.request_id,
|
||||
)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn rotate(
|
||||
&self,
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let account_id = command.mutation.account_id;
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&account_id,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
let account = details.credential;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: account.clone(),
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI credential rotation"))?;
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.account_facts_changed(std::slice::from_ref(&result.account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
true,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
false,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
let result = delete_credentials(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
"OpenAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Runtime settings 与管理员 API Key 用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, RegeneratedAdminApiKey, ReplaceRuntimeSettings,
|
||||
RuntimeSettings,
|
||||
},
|
||||
},
|
||||
ports::store::SettingsStore,
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API 消费的 Runtime settings 管理服务。
|
||||
#[async_trait]
|
||||
pub trait SettingsService: Send + Sync {
|
||||
async fn load(&self) -> Result<RuntimeSettings, AdminError>;
|
||||
async fn replace(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ReplaceRuntimeSettings,
|
||||
) -> Result<RuntimeSettings, AdminError>;
|
||||
async fn admin_api_key_exists(&self) -> Result<bool, AdminError>;
|
||||
async fn regenerate_admin_api_key(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError>;
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultSettingsService {
|
||||
store: Arc<dyn SettingsStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultSettingsService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(store: Arc<dyn SettingsStore>, snapshot: Arc<dyn SnapshotControl>) -> Self {
|
||||
Self { store, snapshot }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsService for DefaultSettingsService {
|
||||
async fn load(&self) -> Result<RuntimeSettings, AdminError> {
|
||||
self.store
|
||||
.load_runtime_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "runtime settings"))
|
||||
}
|
||||
|
||||
async fn replace(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: ReplaceRuntimeSettings,
|
||||
) -> Result<RuntimeSettings, AdminError> {
|
||||
validate_settings(&command)?;
|
||||
let settings = self
|
||||
.store
|
||||
.replace_runtime_settings(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "runtime settings"))?;
|
||||
publish_committed(self.snapshot.as_ref(), settings.config_revision).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> Result<bool, AdminError> {
|
||||
self.store
|
||||
.admin_api_key_exists()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))
|
||||
}
|
||||
|
||||
async fn regenerate_admin_api_key(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError> {
|
||||
let mut bytes = [0_u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let key = AdminApiKey::new(format!("admin-{}", hex::encode(bytes)));
|
||||
let mutation = self
|
||||
.store
|
||||
.replace_admin_api_key(key.clone(), context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
Ok(RegeneratedAdminApiKey { mutation, key })
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError> {
|
||||
let mutation = self
|
||||
.store
|
||||
.delete_admin_api_key(context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "administrator API key"))?;
|
||||
publish_committed(self.snapshot.as_ref(), mutation.config_revision).await?;
|
||||
Ok(mutation)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError> {
|
||||
let valid = command.refresh_margin_seconds > 0
|
||||
&& command.refresh_concurrency > 0
|
||||
&& command.max_concurrent_per_account > 0
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
&& i64::try_from(command.request_interval_ms).is_ok();
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AdminError::invalid("Runtime settings violate constraints"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! 系统管理用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
},
|
||||
ports::system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations, SystemUpdateEventStream,
|
||||
},
|
||||
};
|
||||
|
||||
/// API 消费的系统管理服务。
|
||||
#[async_trait]
|
||||
pub trait SystemService: Send + Sync {
|
||||
async fn version(&self) -> Result<SystemVersion, AdminError>;
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError>;
|
||||
fn update_events(&self) -> SystemUpdateEventStream;
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError>;
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError>;
|
||||
}
|
||||
|
||||
/// 保持 Host 能力窄边界的默认系统用例。
|
||||
pub(crate) struct DefaultSystemService {
|
||||
operations: Arc<dyn SystemOperations>,
|
||||
}
|
||||
|
||||
impl DefaultSystemService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(operations: Arc<dyn SystemOperations>) -> Self {
|
||||
Self { operations }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemService for DefaultSystemService {
|
||||
async fn version(&self) -> Result<SystemVersion, AdminError> {
|
||||
self.operations.version().await.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn update_detail(&self, refresh: bool) -> Result<SystemUpdateDetail, AdminError> {
|
||||
self.operations
|
||||
.update_detail(refresh)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
self.operations.update_events()
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, AdminError> {
|
||||
let target_version = target_version
|
||||
.map(|version| version.trim().to_owned())
|
||||
.filter(|version| !version.is_empty());
|
||||
self.operations
|
||||
.perform_update(target_version)
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, AdminError> {
|
||||
self.operations
|
||||
.update_status()
|
||||
.await
|
||||
.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations.rollback().await.map_err(map_system_error)
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, AdminError> {
|
||||
self.operations.restart().await.map_err(map_system_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_system_error(error: SystemOperationError) -> AdminError {
|
||||
let kind = match error.kind() {
|
||||
SystemOperationErrorKind::Invalid => AdminErrorKind::Invalid,
|
||||
SystemOperationErrorKind::Conflict => AdminErrorKind::Conflict,
|
||||
SystemOperationErrorKind::Upstream => AdminErrorKind::BadGateway,
|
||||
SystemOperationErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, error.message())
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! xAI 管理资源的中立 ProviderAdmin 委托。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
|
||||
ImportCredentials, PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
};
|
||||
|
||||
use super::{
|
||||
commit_authorization, delete_credentials, map_provider_error, map_store_error,
|
||||
pending_authorization, publish_committed, set_credential_enabled,
|
||||
validate_authorization_commit, validate_prepared_import,
|
||||
};
|
||||
|
||||
/// xAI 固定管理路由消费的服务。
|
||||
#[async_trait]
|
||||
pub trait XaiService: Send + Sync {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError>;
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError>;
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError>;
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultXaiService {
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultXaiService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
provider: Arc<dyn ProviderAdmin>,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider,
|
||||
accounts,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl XaiService for DefaultXaiService {
|
||||
async fn list(&self, query: CredentialListQuery) -> Result<CredentialPage, AdminError> {
|
||||
self.accounts
|
||||
.list_credentials(self.provider.provider_kind(), query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential"))
|
||||
}
|
||||
|
||||
async fn import_document(
|
||||
&self,
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
document: command.document,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI credential import"))?;
|
||||
validate_prepared_import(
|
||||
self.provider.provider_kind(),
|
||||
&prepared,
|
||||
"xAI credential import",
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(CredentialImportCommit { prepared }, &context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "xAI credential import"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
|
||||
for account_id in &result.credential_ids {
|
||||
let _ = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
command: StartAuthorization,
|
||||
) -> Result<AuthorizationStarted, AdminError> {
|
||||
let pending = pending_authorization(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.provider_kind(),
|
||||
&command,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
self.provider
|
||||
.start_authorization(pending)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
command: CompleteAuthorization,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.context.clone();
|
||||
let prepared = self
|
||||
.provider
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
prepared,
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
&context,
|
||||
"xAI authorization",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn enable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
true,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn disable(
|
||||
&self,
|
||||
command: CredentialMutation,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let result = set_credential_enabled(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
false,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
command: CredentialDeletion,
|
||||
) -> Result<CredentialDeletionResult, AdminError> {
|
||||
let result = delete_credentials(
|
||||
self.accounts.as_ref(),
|
||||
self.provider.as_ref(),
|
||||
command,
|
||||
"xAI credential",
|
||||
)
|
||||
.await?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
mod use_case;
|
||||
|
||||
#[test]
|
||||
fn revision_should_reject_zero() {
|
||||
assert!(Revision::new(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_should_accept_frozen_upper_bound() {
|
||||
assert_eq!(PageSize::new(200).map(PageSize::get), Ok(200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_size_should_reject_value_above_frozen_upper_bound() {
|
||||
assert!(PageSize::new(201).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_page_size_should_keep_the_full_nonzero_u16_contract() {
|
||||
assert!(ClientKeyPageSize::new(0).is_err());
|
||||
assert_eq!(
|
||||
ClientKeyPageSize::new(u16::MAX).map(ClientKeyPageSize::get),
|
||||
Ok(u16::MAX)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_outcome_filter_should_preserve_known_and_bounded_other_values() {
|
||||
let known = RequestOutcome::new("succeeded").expect("known outcome");
|
||||
let other = RequestOutcome::new("provider_future_state").expect("other outcome");
|
||||
|
||||
assert_eq!(known, RequestOutcome::Succeeded);
|
||||
assert!(matches!(&other, RequestOutcome::Other(_)));
|
||||
assert_eq!(other.as_str(), "provider_future_state");
|
||||
assert!(RequestOutcome::new("").is_err());
|
||||
assert!(RequestOutcome::new("a".repeat(RequestOutcome::MAX_BYTES + 1)).is_err());
|
||||
assert!(RequestOutcome::new("future\nstate").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range_should_accept_exactly_366_days() {
|
||||
let end = Utc::now();
|
||||
assert!(TimeRange::new(end - TimeDelta::days(366), end).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_range_should_reject_more_than_366_days() {
|
||||
let end = Utc::now();
|
||||
assert!(TimeRange::new(end - TimeDelta::days(366) - TimeDelta::seconds(1), end).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_amount_should_canonicalize_redundant_zeroes() {
|
||||
assert_eq!(
|
||||
DecimalAmount::from_str("00012.34000").map(|amount| amount.to_string()),
|
||||
Ok("12.34".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_api_key_debug_should_redact_plaintext() {
|
||||
let key = AdminApiKey::new("secret-admin-key");
|
||||
assert!(!format!("{key:?}").contains("secret-admin-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_command_debug_should_redact_password() {
|
||||
let command = LoginCommand {
|
||||
username: None,
|
||||
password: "secret-password".to_owned(),
|
||||
};
|
||||
assert!(!format!("{command:?}").contains("secret-password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_store_capability_traits_should_be_object_safe() {
|
||||
fn assert_object_safe<T: ?Sized + Send + Sync>() {}
|
||||
|
||||
assert_object_safe::<dyn AccountStore>();
|
||||
assert_object_safe::<dyn AuthStore>();
|
||||
assert_object_safe::<dyn ClientKeyStore>();
|
||||
assert_object_safe::<dyn ObservabilityStore>();
|
||||
assert_object_safe::<dyn SettingsStore>();
|
||||
|
||||
fn assert_send_object_safe<T: ?Sized + Send>() {}
|
||||
assert_send_object_safe::<dyn CredentialCommitGuard>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
let document = ProviderDocument::new(OpaqueProviderData::new(Default::default()));
|
||||
assert_eq!(
|
||||
format!("{document:?}"),
|
||||
"ProviderDocument([PROVIDER_OWNED])"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![
|
||||
quota_window("monthly", Some(2_592_000), Some(99.0)),
|
||||
quota_window("shortTerm", Some(604_800), Some(80.0)),
|
||||
quota_window("shortTerm", Some(18_000), Some(30.0)),
|
||||
quota_window("shortTerm", Some(14_400), Some(45.0)),
|
||||
],
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(45.0));
|
||||
}
|
||||
|
||||
fn quota_window(
|
||||
group: &str,
|
||||
window_seconds: Option<u64>,
|
||||
used_percent: Option<f64>,
|
||||
) -> ProviderQuotaWindow {
|
||||
ProviderQuotaWindow {
|
||||
key: group.to_owned(),
|
||||
group: group.to_owned(),
|
||||
label: group.to_owned(),
|
||||
source: None,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at: None,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
use std::{collections::BTreeMap, sync::Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
auth::{AdminAuditEvent, AdminSession, LoginCommand},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AdminStoreResult, AuthStore},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for MemoryAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(self.password_hash.lock().expect("password hash").clone())
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_: &str,
|
||||
password_hash: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut stored = self.password_hash.lock().expect("password hash");
|
||||
if stored.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
*stored = Some(password_hash.to_owned());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
.get(session_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
.insert(session_id.to_owned(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audits.lock().expect("audits").push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_login_should_create_expiring_session_and_audit() {
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new().auth(store.clone()).build().await;
|
||||
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("login");
|
||||
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
);
|
||||
assert_eq!(store.audits.lock().expect("audits").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_default_initialization_should_not_replace_password() {
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.default_password("first-strong-password")
|
||||
.build()
|
||||
.await;
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store)
|
||||
.default_password("second-strong-password")
|
||||
.build()
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_with_huge_session_ttl_should_clamp_expiry_instead_of_panicking() {
|
||||
let store = std::sync::Arc::new(MemoryAuthStore::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.auth(store.clone())
|
||||
.session_ttl_minutes(i64::MAX as u64)
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let before = Utc::now();
|
||||
let result = services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("login with huge session TTL");
|
||||
|
||||
assert!(result.expires_at > before);
|
||||
assert!(result.expires_at <= before + TimeDelta::days(366) + TimeDelta::minutes(1));
|
||||
assert!(
|
||||
services
|
||||
.auth()
|
||||
.validate_session(Some(&result.session_id))
|
||||
.await
|
||||
.expect("validate")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPage,
|
||||
ClientKeyPageSize, ClientKeyRecord, ClientKeySecret, ClientKeySort, ClientKeySortField,
|
||||
DeleteClientKey, NewClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, ClientKeyStore},
|
||||
};
|
||||
|
||||
struct UnusedClientKeyStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnusedClientKeyStore {
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
assert_eq!(query.page_size.get(), u16::MAX);
|
||||
Ok(ClientKeyPage {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
items: Vec::new(),
|
||||
total: 0,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn reveal_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeySecret>> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
&self,
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn set_client_key_enabled(
|
||||
&self,
|
||||
_: SetClientKeyEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn delete_client_key(
|
||||
&self,
|
||||
_: DeleteClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unused())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_key_cursor_should_reject_value_that_does_not_match_sort() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.build()
|
||||
.await;
|
||||
let sort = ClientKeySort {
|
||||
field: ClientKeySortField::Name,
|
||||
direction: SortDirection::Asc,
|
||||
};
|
||||
let error = services
|
||||
.client_keys()
|
||||
.list(ClientKeyListQuery {
|
||||
cursor: Some(ClientKeyCursor {
|
||||
sort,
|
||||
value: ClientKeyCursorValue::Enabled(true),
|
||||
id: ClientApiKeyId::new("key_cursor").expect("key ID"),
|
||||
}),
|
||||
page_size: ClientKeyPageSize::new(50).expect("page size"),
|
||||
search: None,
|
||||
sort,
|
||||
})
|
||||
.await
|
||||
.expect_err("mismatched cursor must fail");
|
||||
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_key_list_should_forward_the_full_nonzero_u16_page_size() {
|
||||
let services = super::AdminHarness::new()
|
||||
.client_keys(std::sync::Arc::new(UnusedClientKeyStore))
|
||||
.build()
|
||||
.await;
|
||||
let page = services
|
||||
.client_keys()
|
||||
.list(ClientKeyListQuery {
|
||||
cursor: None,
|
||||
page_size: ClientKeyPageSize::new(u16::MAX).expect("maximum page size"),
|
||||
search: None,
|
||||
sort: ClientKeySort {
|
||||
field: ClientKeySortField::CreatedAt,
|
||||
direction: SortDirection::Desc,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("maximum page size should reach store");
|
||||
|
||||
assert_eq!(page.total, 0);
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused in this test",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod client_keys;
|
||||
mod observability;
|
||||
mod openai;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod xai;
|
||||
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardDesktopRelease, DashboardObservation, DashboardWireAttribute,
|
||||
DashboardWireProfile, DashboardWireTarget, DesktopReleaseStatus, DiagnosticDimension,
|
||||
DiagnosticObservation, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange,
|
||||
UsageDetail, UsageFilter, UsageOverview, UsagePage, UsageQuery,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
|
||||
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
|
||||
ProviderModels, ProviderQuota,
|
||||
},
|
||||
settings::{AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RuntimeSettings},
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
|
||||
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
},
|
||||
system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations,
|
||||
SystemUpdateEventStream,
|
||||
},
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::ClientApiKeyId,
|
||||
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
|
||||
};
|
||||
|
||||
pub(super) struct AdminHarness {
|
||||
default_password: String,
|
||||
session_ttl_minutes: u64,
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
}
|
||||
|
||||
impl AdminHarness {
|
||||
pub(super) fn new() -> Self {
|
||||
let unavailable = Arc::new(UnavailableStore);
|
||||
Self {
|
||||
default_password: "strong-test-password".to_owned(),
|
||||
session_ttl_minutes: 60,
|
||||
accounts: unavailable.clone(),
|
||||
auth: Arc::new(BootstrapAuthStore::default()),
|
||||
client_keys: unavailable.clone(),
|
||||
observability: unavailable.clone(),
|
||||
settings: unavailable,
|
||||
providers: vec![
|
||||
Arc::new(UnavailableProvider::new("openai")),
|
||||
Arc::new(UnavailableProvider::new("xai")),
|
||||
],
|
||||
probe: Arc::new(UnavailableProbe),
|
||||
system: Arc::new(UnavailableSystem),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn default_password(mut self, password: &str) -> Self {
|
||||
self.default_password = password.to_owned();
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn session_ttl_minutes(mut self, minutes: u64) -> Self {
|
||||
self.session_ttl_minutes = minutes;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn accounts(mut self, store: Arc<dyn AccountStore>) -> Self {
|
||||
self.accounts = store;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn auth(mut self, store: Arc<dyn AuthStore>) -> Self {
|
||||
self.auth = store;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn client_keys(mut self, store: Arc<dyn ClientKeyStore>) -> Self {
|
||||
self.client_keys = store;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn observability(mut self, store: Arc<dyn ObservabilityStore>) -> Self {
|
||||
self.observability = store;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn settings(mut self, store: Arc<dyn SettingsStore>) -> Self {
|
||||
self.settings = store;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn provider(mut self, provider: Arc<dyn ProviderAdmin>) -> Self {
|
||||
self.providers
|
||||
.retain(|registered| registered.provider_kind() != provider.provider_kind());
|
||||
self.providers.push(provider);
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn probe(mut self, probe: Arc<dyn AccountProbe>) -> Self {
|
||||
self.probe = probe;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn system(mut self, system: Arc<dyn SystemOperations>) -> Self {
|
||||
self.system = system;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) async fn build(self) -> AdminServices {
|
||||
gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
session_ttl_minutes: self.session_ttl_minutes,
|
||||
default_username: "admin".to_owned(),
|
||||
default_password: InitialAdminPassword::new(self.default_password),
|
||||
},
|
||||
AdminStorePorts::new(
|
||||
self.accounts,
|
||||
self.auth,
|
||||
self.client_keys,
|
||||
self.observability,
|
||||
self.settings,
|
||||
),
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
self.probe,
|
||||
self.system,
|
||||
)
|
||||
.await
|
||||
.expect("initialize admin test harness")
|
||||
.services()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BootstrapAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for BootstrapAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(self.password_hash.lock().expect("password hash").clone())
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_: &str,
|
||||
password_hash: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut stored = self.password_hash.lock().expect("password hash");
|
||||
if stored.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
*stored = Some(password_hash.to_owned());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn store_session(&self, _: &str, _: &AdminSession) -> AdminStoreResult<()> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn delete_session(&self, _: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableStore;
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for UnavailableStore {
|
||||
async fn list_accounts(&self, _: AccountListQuery) -> AdminStoreResult<AccountPage> {
|
||||
Err(unavailable("accounts"))
|
||||
}
|
||||
|
||||
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: &[String],
|
||||
) -> AdminStoreResult<Vec<AccountUsage>> {
|
||||
Err(unavailable("account usage"))
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
_: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
Err(unavailable("account quota window usage"))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage> {
|
||||
Err(unavailable("credentials"))
|
||||
}
|
||||
|
||||
async fn credential_details(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>> {
|
||||
Err(unavailable("credential"))
|
||||
}
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: &[ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>> {
|
||||
Err(unavailable("credential export"))
|
||||
}
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
_: CredentialImportCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
Err(unavailable("credential import"))
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
&self,
|
||||
_: AuthorizationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("authorization"))
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
_: CredentialRotationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("credential rotation"))
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
&self,
|
||||
_: CredentialRotationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("credential refresh"))
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
&self,
|
||||
_: SetAccountEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("account enabled"))
|
||||
}
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
_: DeleteAccounts,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("account delete"))
|
||||
}
|
||||
|
||||
async fn record_credential_export(
|
||||
&self,
|
||||
_: &[ProviderAccountId],
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
Err(unavailable("credential export audit"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
Err(unavailable("client key list"))
|
||||
}
|
||||
|
||||
async fn reveal_client_key(
|
||||
&self,
|
||||
_: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeySecret>> {
|
||||
Err(unavailable("client key"))
|
||||
}
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key create"))
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
&self,
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key update"))
|
||||
}
|
||||
|
||||
async fn set_client_key_enabled(
|
||||
&self,
|
||||
_: SetClientKeyEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key enabled"))
|
||||
}
|
||||
|
||||
async fn delete_client_key(
|
||||
&self,
|
||||
_: DeleteClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("client key delete"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnavailableStore {
|
||||
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
Err(unavailable("dashboard"))
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Err(unavailable("dashboard trend"))
|
||||
}
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
Err(unavailable("usage records"))
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
Err(unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
Err(unavailable("usage summary"))
|
||||
}
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
Err(unavailable("ops errors"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for UnavailableStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unavailable("settings"))
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Err(unavailable("admin API key"))
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unavailable("settings"))
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unavailable("admin API key"))
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unavailable("admin API key"))
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableProvider {
|
||||
kind: ProviderKind,
|
||||
dashboard_profile: Option<DashboardWireProfile>,
|
||||
calculated_billing: Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
|
||||
}
|
||||
|
||||
impl UnavailableProvider {
|
||||
fn new(kind: &str) -> Self {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
dashboard_profile: None,
|
||||
calculated_billing: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for UnavailableProvider {
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn account_unavailable(&self, _: &ProviderAccountId) {}
|
||||
|
||||
fn connection_test_operation(
|
||||
&self,
|
||||
_: &gateway_core::routing::UpstreamModelId,
|
||||
_: &str,
|
||||
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
fn dashboard_wire_profile(&self) -> Option<DashboardWireProfile> {
|
||||
self.dashboard_profile.clone()
|
||||
}
|
||||
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
_: &gateway_admin::model::observability::ProviderBillingInput,
|
||||
) -> Result<
|
||||
Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
|
||||
ProviderAdminError,
|
||||
> {
|
||||
Ok(self.calculated_billing.clone())
|
||||
}
|
||||
|
||||
async fn prepare_import(
|
||||
&self,
|
||||
_: PrepareCredentialImport,
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
_: PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
_: CompleteAuthorization,
|
||||
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn prepare_rotation(
|
||||
&self,
|
||||
_: PrepareCredentialRotation,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn prepare_refresh(
|
||||
&self,
|
||||
_: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
_: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn export_credentials(
|
||||
&self,
|
||||
_: Vec<ProviderExportCredentialInput>,
|
||||
) -> Result<ProviderExport, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dashboard_profile_provider() -> Arc<dyn ProviderAdmin> {
|
||||
Arc::new(UnavailableProvider {
|
||||
kind: ProviderKind::new("openai").expect("provider kind"),
|
||||
dashboard_profile: Some(DashboardWireProfile {
|
||||
provider: "openai".to_owned(),
|
||||
product: "gateway-admin-test".to_owned(),
|
||||
version: "test".to_owned(),
|
||||
build: Some("test".to_owned()),
|
||||
target: DashboardWireTarget {
|
||||
os_type: "linux".to_owned(),
|
||||
os_version: "test".to_owned(),
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "test".to_owned(),
|
||||
},
|
||||
user_agent: "gateway-admin-test".to_owned(),
|
||||
attributes: vec![DashboardWireAttribute {
|
||||
label: "Core".to_owned(),
|
||||
value: "test".to_owned(),
|
||||
}],
|
||||
verified_at: Some(chrono::Utc::now()),
|
||||
release: Some(DashboardDesktopRelease {
|
||||
status: DesktopReleaseStatus::Unchecked,
|
||||
checked_at: None,
|
||||
latest_version: None,
|
||||
latest_build: None,
|
||||
published_at: None,
|
||||
minimum_system_version: None,
|
||||
hardware_requirements: None,
|
||||
download_url: None,
|
||||
download_size: None,
|
||||
signature_present: None,
|
||||
error: None,
|
||||
}),
|
||||
}),
|
||||
calculated_billing: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn calculated_billing_provider() -> Arc<dyn ProviderAdmin> {
|
||||
let amount = |value| gateway_admin::model::observability::CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str(value)
|
||||
.expect("test billing amount"),
|
||||
};
|
||||
Arc::new(UnavailableProvider {
|
||||
kind: ProviderKind::new("openai").expect("provider kind"),
|
||||
dashboard_profile: None,
|
||||
calculated_billing: Some(
|
||||
gateway_admin::model::observability::CalculatedBillingBreakdown {
|
||||
input_amount: amount("0.8"),
|
||||
output_amount: amount("0.2"),
|
||||
cache_read_amount: amount("0"),
|
||||
cache_write_amount: amount("0"),
|
||||
standard_amount: amount("1"),
|
||||
total_amount: amount("1.25"),
|
||||
input_price_per_million: amount("0"),
|
||||
output_price_per_million: amount("0"),
|
||||
cache_read_price_per_million: amount("0"),
|
||||
cache_write_price_per_million: amount("0"),
|
||||
service_tier: None,
|
||||
multiplier_percent: 125,
|
||||
},
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
struct NoopSnapshot;
|
||||
|
||||
impl SnapshotControl for NoopSnapshot {
|
||||
fn publish_committed(&self, _: ConfigRevision) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableProbe;
|
||||
|
||||
impl AccountProbe for UnavailableProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"test account probe is unavailable",
|
||||
)
|
||||
.into())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableSystem;
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for UnavailableSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(resource: &'static str) -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
resource,
|
||||
"unavailable in this test",
|
||||
)
|
||||
}
|
||||
|
||||
fn unsupported_provider() -> ProviderAdminError {
|
||||
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
|
||||
}
|
||||
|
||||
fn unavailable_system() -> SystemOperationError {
|
||||
SystemOperationError::new(
|
||||
SystemOperationErrorKind::Internal,
|
||||
"unavailable in this test",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
use std::{
|
||||
num::NonZeroU32,
|
||||
str::FromStr as _,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::{
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, OpsErrorPage, OpsErrorQuery, PageNumber, RequestMetricPoint,
|
||||
RequestMetrics, RequestOutcome, TimeRange, TrendKind, UsageBilling,
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageRecord, china_day_start,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreResult, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn external_observability_range_accepts_exactly_366_days() {
|
||||
let end = Utc::now();
|
||||
let range = TimeRange::new(end - Duration::days(366), end)
|
||||
.expect("366-day external range should be accepted");
|
||||
assert_eq!(range.end, end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_observability_range_rejects_over_366_days_and_reversed_range() {
|
||||
let end = Utc::now();
|
||||
assert!(TimeRange::new(end - Duration::days(367), end).is_err());
|
||||
assert!(TimeRange::new(end, end).is_err());
|
||||
assert!(TimeRange::new(end + Duration::seconds(1), end).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_timeline_should_keep_exactly_china_day_quarter_hour_slots() {
|
||||
let now = Utc::now();
|
||||
let day_start = china_day_start(now);
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
store.replace_trend(vec![
|
||||
health_metric_point(
|
||||
day_start - Duration::minutes(15),
|
||||
RequestMetrics {
|
||||
success_count: 100,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
),
|
||||
health_metric_point(
|
||||
current_slot,
|
||||
RequestMetrics {
|
||||
success_count: 2,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
),
|
||||
health_metric_point(
|
||||
current_slot + Duration::minutes(15),
|
||||
RequestMetrics {
|
||||
success_count: 100,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
),
|
||||
]);
|
||||
let services = observability_services(store).await;
|
||||
|
||||
let timeline = services
|
||||
.observability()
|
||||
.dashboard_summary(observation_range(now), TrendKind::Usage)
|
||||
.await
|
||||
.expect("dashboard summary")
|
||||
.health_timeline;
|
||||
|
||||
assert_eq!(timeline.points.len(), 96);
|
||||
assert_eq!(
|
||||
timeline.points.first().map(|point| point.bucket_start),
|
||||
Some(day_start)
|
||||
);
|
||||
assert_eq!(
|
||||
timeline.points.last().map(|point| point.bucket_start),
|
||||
Some(day_start + Duration::minutes(95 * 15))
|
||||
);
|
||||
assert_eq!(timeline.success_requests, 2);
|
||||
assert_eq!(
|
||||
timeline
|
||||
.points
|
||||
.iter()
|
||||
.find(|point| point.bucket_start == current_slot)
|
||||
.map(|point| point.status),
|
||||
Some(HealthStatus::LowSample)
|
||||
);
|
||||
assert!(timeline.points.iter().all(|point| {
|
||||
point.bucket_start <= quarter_hour_start(Utc::now())
|
||||
|| (point.status == HealthStatus::Future && point.success_requests == 0)
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn health_timeline_should_match_legacy_status_precedence_and_thresholds() {
|
||||
let now = Utc::now();
|
||||
let current_slot = quarter_hour_start(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
let services = observability_services(store.clone()).await;
|
||||
let fixtures = [
|
||||
(
|
||||
RequestMetrics {
|
||||
failure_count: 1,
|
||||
cancelled_count: 1,
|
||||
incomplete_count: 1,
|
||||
caller_error_count: 1,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
HealthStatus::NoData,
|
||||
None,
|
||||
),
|
||||
(
|
||||
RequestMetrics {
|
||||
failure_count: 3,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
HealthStatus::Unavailable,
|
||||
Some(0.0),
|
||||
),
|
||||
(
|
||||
RequestMetrics {
|
||||
success_count: 1,
|
||||
failure_count: 1,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
HealthStatus::LowSample,
|
||||
Some(50.0),
|
||||
),
|
||||
(
|
||||
RequestMetrics {
|
||||
success_count: 98,
|
||||
failure_count: 2,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
HealthStatus::Unstable,
|
||||
Some(98.0),
|
||||
),
|
||||
(
|
||||
RequestMetrics {
|
||||
success_count: 99,
|
||||
failure_count: 1,
|
||||
..RequestMetrics::default()
|
||||
},
|
||||
HealthStatus::Stable,
|
||||
Some(99.0),
|
||||
),
|
||||
];
|
||||
|
||||
for (metrics, expected_status, expected_reliability) in fixtures {
|
||||
store.replace_trend(vec![health_metric_point(current_slot, metrics)]);
|
||||
let timeline = services
|
||||
.observability()
|
||||
.dashboard_summary(observation_range(now), TrendKind::Usage)
|
||||
.await
|
||||
.expect("dashboard summary")
|
||||
.health_timeline;
|
||||
let point = timeline
|
||||
.points
|
||||
.iter()
|
||||
.find(|point| point.bucket_start == current_slot)
|
||||
.expect("current health slot");
|
||||
assert_eq!(point.status, expected_status);
|
||||
assert_eq!(point.reliability_percent, expected_reliability);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_project_rebuildable_runtime_slots() {
|
||||
let now = Utc::now();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
store.replace_runtime_slots(Some(DashboardRuntimeSlots {
|
||||
active_accounts: 3,
|
||||
used_slots: Some(2),
|
||||
}));
|
||||
let services = observability_services(store).await;
|
||||
|
||||
let capacity = services
|
||||
.observability()
|
||||
.dashboard_summary(observation_range(now), TrendKind::Usage)
|
||||
.await
|
||||
.expect("dashboard summary")
|
||||
.capacity;
|
||||
|
||||
assert_eq!(capacity.total_slots, 3);
|
||||
assert_eq!(capacity.used_slots, Some(2));
|
||||
assert_eq!(capacity.available_slots, Some(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observability_services_should_calculate_usage_insights_and_diagnostic_shares() {
|
||||
let now = Utc::now();
|
||||
let range = observation_range(now);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(range));
|
||||
let metrics = RequestMetrics {
|
||||
request_count: 10,
|
||||
success_count: 6,
|
||||
failure_count: 4,
|
||||
caller_error_count: 2,
|
||||
input_tokens: 800,
|
||||
output_tokens: 200,
|
||||
total_tokens: 1_000,
|
||||
latency_count: 5,
|
||||
..RequestMetrics::default()
|
||||
};
|
||||
store.replace_overview(UsageOverview {
|
||||
range,
|
||||
requests: metrics.clone(),
|
||||
attempts: AttemptMetrics {
|
||||
attempt_count: 12,
|
||||
cost_coverage: CostCoverage {
|
||||
calculated_count: 10,
|
||||
..CostCoverage::default()
|
||||
},
|
||||
costs: vec![CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
|
||||
.expect("USD cost"),
|
||||
}],
|
||||
..AttemptMetrics::default()
|
||||
},
|
||||
providers: Vec::new(),
|
||||
});
|
||||
store.replace_trend(vec![RequestMetricPoint {
|
||||
bucket_start: quarter_hour_start(now),
|
||||
granularity: Granularity::FifteenMinutes,
|
||||
metrics,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: vec![CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("0.25")
|
||||
.expect("bucket USD cost"),
|
||||
}],
|
||||
}]);
|
||||
store.replace_calculated_billing_facts(vec![UsageCalculatedBillingFact {
|
||||
bucket_start: quarter_hour_start(now),
|
||||
provider_kind: "openai".to_owned(),
|
||||
upstream_model_id: "gpt-5.5".to_owned(),
|
||||
service_tier: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
|
||||
.expect("calculated total"),
|
||||
},
|
||||
}]);
|
||||
store.replace_diagnostics(vec![diagnostic("openai", 3), diagnostic("xai", 1)]);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let insights = services
|
||||
.observability()
|
||||
.usage_insights(range, UsageFilter::default())
|
||||
.await
|
||||
.expect("usage insights");
|
||||
let diagnostics = services
|
||||
.observability()
|
||||
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Provider)
|
||||
.await
|
||||
.expect("usage diagnostics");
|
||||
|
||||
assert_eq!(insights.health.total_requests, 8);
|
||||
assert_eq!(insights.health.failed_requests, 2);
|
||||
assert_eq!(insights.granularity, Granularity::FifteenMinutes);
|
||||
assert_eq!(insights.performance.latency_coverage, 0.5);
|
||||
assert_eq!(insights.cost.tokens_per_request, 100.0);
|
||||
assert_eq!(
|
||||
insights
|
||||
.cost
|
||||
.estimated_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("1.25")
|
||||
);
|
||||
assert_eq!(
|
||||
insights
|
||||
.cost
|
||||
.cost_per_request
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("0.125")
|
||||
);
|
||||
assert_eq!(
|
||||
insights.cost.points[0]
|
||||
.estimated_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("0.25")
|
||||
);
|
||||
assert_eq!(
|
||||
insights
|
||||
.cost
|
||||
.standard_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(
|
||||
insights.cost.points[0]
|
||||
.standard_cost
|
||||
.as_ref()
|
||||
.map(gateway_admin::model::observability::DecimalAmount::as_str),
|
||||
Some("1")
|
||||
);
|
||||
assert_eq!(diagnostics.items[0].request_share, 0.75);
|
||||
assert_eq!(diagnostics.items[1].request_share, 0.25);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_tolerate_records_that_fail_billing_enrichment() {
|
||||
let now = Utc::now();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
let invalid_kind = "x".repeat(65);
|
||||
store.replace_usage_records(vec![
|
||||
total_record(
|
||||
"request_invalid_kind",
|
||||
Some(&invalid_kind),
|
||||
"calculated",
|
||||
now,
|
||||
),
|
||||
total_record(
|
||||
"request_unregistered_kind",
|
||||
Some("anthropic"),
|
||||
"calculated",
|
||||
now,
|
||||
),
|
||||
total_record("request_enriched", Some("openai"), "calculated", now),
|
||||
]);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let page = services
|
||||
.observability()
|
||||
.usage_records(usage_query(now))
|
||||
.await
|
||||
.expect("one bad record must not fail the whole usage list");
|
||||
|
||||
assert_eq!(page.items.len(), 3);
|
||||
assert!(
|
||||
matches!(page.items[0].billing, Some(UsageBilling::Total { .. })),
|
||||
"invalid Provider kind keeps the stored total"
|
||||
);
|
||||
assert!(
|
||||
matches!(page.items[1].billing, Some(UsageBilling::Total { .. })),
|
||||
"unregistered Provider kind keeps the stored total"
|
||||
);
|
||||
assert!(
|
||||
matches!(page.items[2].billing, Some(UsageBilling::Calculated(_))),
|
||||
"healthy record is still enriched"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_records_should_enrich_provider_reported_totals_when_pricing_matches() {
|
||||
let now = Utc::now();
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(now)));
|
||||
store.replace_usage_records(vec![total_record(
|
||||
"request_provider_reported",
|
||||
Some("openai"),
|
||||
"provider_reported",
|
||||
now,
|
||||
)]);
|
||||
let services = observability_services_with_calculated_billing(store).await;
|
||||
|
||||
let page = services
|
||||
.observability()
|
||||
.usage_records(usage_query(now))
|
||||
.await
|
||||
.expect("usage records");
|
||||
|
||||
assert!(matches!(
|
||||
page.items[0].billing,
|
||||
Some(UsageBilling::Calculated(_))
|
||||
));
|
||||
}
|
||||
|
||||
struct FixtureObservabilityStore {
|
||||
trend: Mutex<Vec<RequestMetricPoint>>,
|
||||
overview: Mutex<UsageOverview>,
|
||||
calculated_billing_facts: Mutex<Vec<UsageCalculatedBillingFact>>,
|
||||
diagnostics: Mutex<Vec<DiagnosticObservation>>,
|
||||
runtime_slots: Mutex<Option<DashboardRuntimeSlots>>,
|
||||
usage_records: Mutex<Vec<UsageRecord>>,
|
||||
}
|
||||
|
||||
impl FixtureObservabilityStore {
|
||||
fn new(range: TimeRange) -> Self {
|
||||
Self {
|
||||
trend: Mutex::new(Vec::new()),
|
||||
overview: Mutex::new(UsageOverview {
|
||||
range,
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
providers: Vec::new(),
|
||||
}),
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
usage_records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_trend(&self, trend: Vec<RequestMetricPoint>) {
|
||||
*self.trend.lock().expect("trend") = trend;
|
||||
}
|
||||
|
||||
fn replace_overview(&self, overview: UsageOverview) {
|
||||
*self.overview.lock().expect("overview") = overview;
|
||||
}
|
||||
|
||||
fn replace_calculated_billing_facts(&self, facts: Vec<UsageCalculatedBillingFact>) {
|
||||
*self
|
||||
.calculated_billing_facts
|
||||
.lock()
|
||||
.expect("calculated billing facts") = facts;
|
||||
}
|
||||
|
||||
fn replace_diagnostics(&self, diagnostics: Vec<DiagnosticObservation>) {
|
||||
*self.diagnostics.lock().expect("diagnostics") = diagnostics;
|
||||
}
|
||||
|
||||
fn replace_runtime_slots(&self, runtime_slots: Option<DashboardRuntimeSlots>) {
|
||||
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
|
||||
}
|
||||
|
||||
fn replace_usage_records(&self, records: Vec<UsageRecord>) {
|
||||
*self.usage_records.lock().expect("usage records") = records;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for FixtureObservabilityStore {
|
||||
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
Ok(DashboardObservation {
|
||||
range,
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: self.trend.lock().expect("trend").clone(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn dashboard_runtime_slots(
|
||||
&self,
|
||||
_: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
|
||||
Ok(*self.runtime_slots.lock().expect("runtime slots"))
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Ok(self.trend.lock().expect("trend").clone())
|
||||
}
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Ok(self.trend.lock().expect("trend").clone())
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<UsageCalculatedBillingFact>> {
|
||||
Ok(self
|
||||
.calculated_billing_facts
|
||||
.lock()
|
||||
.expect("calculated billing facts")
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
|
||||
Ok(UsagePage {
|
||||
items,
|
||||
total,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
Err(super::unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
Ok(self.overview.lock().expect("overview").clone())
|
||||
}
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
Err(super::unavailable("ops errors"))
|
||||
}
|
||||
}
|
||||
|
||||
struct FixtureSettingsStore;
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for FixtureSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 300,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
updated_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(super::unavailable("settings"))
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn observability_services(store: Arc<FixtureObservabilityStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore))
|
||||
.provider(super::dashboard_profile_provider())
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn observability_services_with_calculated_billing(
|
||||
store: Arc<FixtureObservabilityStore>,
|
||||
) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.observability(store)
|
||||
.settings(Arc::new(FixtureSettingsStore))
|
||||
.provider(super::calculated_billing_provider())
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn observation_range(end: DateTime<Utc>) -> TimeRange {
|
||||
TimeRange::new(end - Duration::hours(24), end).expect("observation range")
|
||||
}
|
||||
|
||||
fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
cursor: None,
|
||||
page: PageNumber::new(NonZeroU32::new(1).expect("page number")),
|
||||
page_size: PageSize::new(50).expect("page size"),
|
||||
}
|
||||
}
|
||||
|
||||
fn total_record(
|
||||
id: &str,
|
||||
provider_kind: Option<&str>,
|
||||
source: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> UsageRecord {
|
||||
UsageRecord {
|
||||
id: id.to_owned(),
|
||||
client_api_key_ref: "key_billing".to_owned(),
|
||||
config_revision: 1,
|
||||
protocol: "openai".to_owned(),
|
||||
operation: "generate".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "gpt-5.5".to_owned(),
|
||||
provider_kind: provider_kind.map(str::to_owned),
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
http_version: None,
|
||||
websocket_pool: None,
|
||||
service_tier: None,
|
||||
provider_metadata_json: None,
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: None,
|
||||
outcome: RequestOutcome::Succeeded,
|
||||
client_status_code: Some(200),
|
||||
upstream_status_code: Some(200),
|
||||
client_response_id: None,
|
||||
upstream_request_id: None,
|
||||
upstream_response_id: None,
|
||||
error_kind: None,
|
||||
provider_error_code: None,
|
||||
error_message: None,
|
||||
retry_after_ms: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: Some(1_000),
|
||||
cost_source: source.to_owned(),
|
||||
cost_amount: None,
|
||||
cost_currency: None,
|
||||
billing: Some(UsageBilling::Total {
|
||||
source: source.to_owned(),
|
||||
total: CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: gateway_admin::model::observability::DecimalAmount::from_str("1.25")
|
||||
.expect("billing total"),
|
||||
},
|
||||
}),
|
||||
transport_decision_wait_ms: None,
|
||||
connect_ms: None,
|
||||
headers_ms: None,
|
||||
first_event_ms: None,
|
||||
first_reasoning_ms: None,
|
||||
first_text_ms: None,
|
||||
first_token_ms: None,
|
||||
provider_processing_ms: None,
|
||||
latency_ms: Some(100),
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
reasoning_effort: None,
|
||||
reasoning_preset: None,
|
||||
request_kind: None,
|
||||
subagent_kind: None,
|
||||
compact: false,
|
||||
image_generation_requested: false,
|
||||
image_generation_succeeded: None,
|
||||
started_at: now,
|
||||
deadline_at: now,
|
||||
completed_at: Some(now),
|
||||
}
|
||||
}
|
||||
|
||||
fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) -> RequestMetricPoint {
|
||||
RequestMetricPoint {
|
||||
bucket_start,
|
||||
granularity: Granularity::FifteenMinutes,
|
||||
metrics,
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
DiagnosticObservation {
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
success_count: request_count,
|
||||
failure_count: 0,
|
||||
attempt_count: request_count,
|
||||
total_tokens: request_count.saturating_mul(100),
|
||||
average_latency_ms: Some(100),
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value.timestamp().rem_euclid(15 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::provider_credentials::{
|
||||
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, CredentialMutation,
|
||||
ImportCredentials, ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_enable_should_use_scoped_store_without_provider_mutation() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.enable(mutation("acct_test"))
|
||||
.await
|
||||
.expect("enable credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"store.set_enabled",
|
||||
"provider.account_facts_changed",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_disable_should_commit_then_release_provider_resources() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.disable(mutation("acct_test"))
|
||||
.await
|
||||
.expect("disable credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"store.set_enabled",
|
||||
"provider.account_unavailable",
|
||||
"provider.account_facts_changed",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_delete_should_commit_then_release_provider_resources() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.delete(deletion("acct_test"))
|
||||
.await
|
||||
.expect("delete credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"store.delete",
|
||||
"provider.account_unavailable",
|
||||
"provider.account_facts_changed",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_disable_store_failure_should_not_release_provider_resources() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.fail_next_commit();
|
||||
let services = service(provider, store).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.disable(mutation("acct_test"))
|
||||
.await
|
||||
.expect_err("failed Store transaction");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.credential_details", "store.set_enabled"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.prepare_import",
|
||||
"store.commit_import",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.set_import_account_ids(&["acct_first", "acct_second"]);
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-batch"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import credentials");
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
[
|
||||
ProviderAccountId::new("acct_first").expect("first account ID"),
|
||||
ProviderAccountId::new("acct_second").expect("second account ID"),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[
|
||||
ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_first").expect("first account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
},
|
||||
ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_second").expect("second account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-quota-failure"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("committed import remains successful");
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
[ProviderAccountId::new("acct_prepared").expect("account ID")]
|
||||
);
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_authorization_create_should_observe_initial_quota() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai-create"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
})
|
||||
.await
|
||||
.expect("start authorization");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
context: context("oauth-start-openai-create"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("complete authorization");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.start_authorization",
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["oauth-start-openai-create"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_authorization_create_should_remain_successful_when_initial_quota_fails() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
|
||||
let store = FakeAccountStore::new("openai", events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
})
|
||||
.await
|
||||
.expect("start authorization");
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
context: context("oauth-start-openai-quota-failure"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("quota observation must not fail authorization");
|
||||
|
||||
assert_eq!(result.account_id.as_str(), "acct_prepared");
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_authorization_store_failure_should_release_claim_for_retry() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.retry_authorization_after_abort();
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
store.fail_next_commit();
|
||||
let services = service(provider.clone(), store).await;
|
||||
let command = || CompleteAuthorization {
|
||||
context: context("oauth-store-retry"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
};
|
||||
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-store-retry"),
|
||||
name: "new OpenAI credential".to_owned(),
|
||||
reauthorization: None,
|
||||
})
|
||||
.await
|
||||
.expect("start authorization");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(command())
|
||||
.await
|
||||
.expect_err("Store commit must fail");
|
||||
services
|
||||
.openai()
|
||||
.complete_authorization(command())
|
||||
.await
|
||||
.expect("released claim must permit retry");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.start_authorization",
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"authorization_guard.abort",
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"authorization_guard.commit",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert!(provider.pending().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
provider.fail_next(ProviderAdminErrorKind::Invalid);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider, store).await;
|
||||
|
||||
services
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-error"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect_err("invalid Provider document");
|
||||
|
||||
assert_eq!(recorded(&events), ["provider.prepare_import"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_reauthorization_should_commit_after_credential_revision_advances() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
services
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai"),
|
||||
name: "reauthorize".to_owned(),
|
||||
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
|
||||
let pending = provider.pending().expect("pending envelope");
|
||||
assert_eq!(pending.provider_kind().as_str(), "openai");
|
||||
assert_eq!(
|
||||
pending.owner_binding().started_request_id(),
|
||||
"oauth-start-openai"
|
||||
);
|
||||
assert!(matches!(
|
||||
pending.target(),
|
||||
AuthorizationMutationTarget::Reauthorize { account_id }
|
||||
if account_id.as_str() == "acct_test"
|
||||
));
|
||||
assert!(!format!("{pending:?}").contains("admin-test"));
|
||||
|
||||
provider.set_current_credential_revision(revision(2));
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
context: context("oauth-complete-openai"),
|
||||
flow_id: "flow-test".to_owned(),
|
||||
callback_url: "http://localhost/callback?code=test&state=test".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
|
||||
assert_eq!(result.credential_revision, Some(revision(3)));
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"provider.start_authorization",
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"guard.finish",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
|
||||
}
|
||||
|
||||
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-openai"),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
|
||||
fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
CredentialDeletion {
|
||||
context: context("request-openai"),
|
||||
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext,
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, SettingsStore},
|
||||
};
|
||||
|
||||
struct UnusedSettingsStore;
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for UnusedSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
_: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
let services = super::AdminHarness::new()
|
||||
.settings(std::sync::Arc::new(UnusedSettingsStore))
|
||||
.build()
|
||||
.await;
|
||||
let error = services
|
||||
.settings()
|
||||
.replace(
|
||||
&MutationContext {
|
||||
actor: gateway_admin::model::MutationActor::System,
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 0,
|
||||
refresh_concurrency: 1,
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("invalid settings");
|
||||
|
||||
assert_eq!(error.kind(), AdminErrorKind::Invalid);
|
||||
}
|
||||
|
||||
fn unused() -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
"settings",
|
||||
"unused in this test",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::system::{
|
||||
SystemOperationAccepted, SystemOperationState, SystemOperationStatus, SystemUpdateDetail,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingSystemOperations {
|
||||
target: Mutex<Option<Option<String>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for RecordingSystemOperations {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Ok(SystemVersion {
|
||||
version: "1.0.0".to_owned(),
|
||||
git_sha: "unknown".to_owned(),
|
||||
build_time: "unknown".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
update_channel: "stable".to_owned(),
|
||||
latest_version: "1.0.0".to_owned(),
|
||||
has_update: false,
|
||||
update_cached: false,
|
||||
update_warning: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Ok(SystemUpdateDetail {
|
||||
current_version: "1.0.0".to_owned(),
|
||||
latest_version: "1.0.0".to_owned(),
|
||||
has_update: false,
|
||||
deployment_mode: "source".to_owned(),
|
||||
build_type: "source".to_owned(),
|
||||
release_url: None,
|
||||
notes: None,
|
||||
cached: false,
|
||||
update_supported: false,
|
||||
unsupported_reason: Some("source build".to_owned()),
|
||||
warning: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
target_version: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
*self.target.lock().expect("target") = Some(target_version.clone());
|
||||
Ok(SystemOperationAccepted::Update {
|
||||
operation_id: "operation-update".to_owned(),
|
||||
deployment_mode: "source".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
need_restart: true,
|
||||
target_version: target_version.unwrap_or_else(|| "latest".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Ok(SystemUpdateStatus {
|
||||
previous_version: None,
|
||||
current_version: Some("1.0.0".to_owned()),
|
||||
operation: SystemOperationState {
|
||||
operation_id: None,
|
||||
kind: None,
|
||||
status: SystemOperationStatus::Idle,
|
||||
target_version: None,
|
||||
message: None,
|
||||
error: None,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Ok(SystemOperationAccepted::Rollback {
|
||||
operation_id: "operation-rollback".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
need_restart: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Ok(SystemOperationAccepted::Restart {
|
||||
operation_id: "operation-restart".to_owned(),
|
||||
message: "accepted".to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn system_update_should_normalize_blank_target_to_latest() {
|
||||
let operations = std::sync::Arc::new(RecordingSystemOperations::default());
|
||||
let services = super::AdminHarness::new()
|
||||
.system(operations.clone())
|
||||
.build()
|
||||
.await;
|
||||
services
|
||||
.system()
|
||||
.perform_update(Some(" ".to_owned()))
|
||||
.await
|
||||
.expect("perform update");
|
||||
|
||||
assert_eq!(*operations.target.lock().expect("target"), Some(None));
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::provider_credentials::{CredentialMutation, ImportCredentials, ProviderQuotaRequest},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_disable_should_commit_then_notify_the_stateless_provider() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.disable(mutation("acct_test"))
|
||||
.await
|
||||
.expect("disable credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"store.credential_details",
|
||||
"store.set_enabled",
|
||||
"provider.account_unavailable",
|
||||
"provider.account_facts_changed",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-xai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
let store = FakeAccountStore::new("xai", events.clone());
|
||||
let services = service(provider.clone(), store.clone()).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import xAI credential");
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.prepare_import",
|
||||
"store.commit_import",
|
||||
"provider.quota"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_prepared").expect("account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
}]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["import-xai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
provider.set_import_account_ids(&["acct_first", "acct_second"]);
|
||||
let store = FakeAccountStore::new("xai", events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-batch"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import credentials");
|
||||
|
||||
assert_eq!(
|
||||
provider.quota_requests(),
|
||||
[
|
||||
ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_first").expect("first account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
},
|
||||
ProviderQuotaRequest {
|
||||
account_id: ProviderAccountId::new("acct_second").expect("second account ID"),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("xai", events.clone());
|
||||
provider.fail_next_quota(ProviderAdminErrorKind::Unavailable);
|
||||
let store = FakeAccountStore::new("xai", events);
|
||||
let services = service(provider.clone(), store).await;
|
||||
|
||||
let result = services
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-quota-failure"),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("committed import remains successful");
|
||||
|
||||
assert_eq!(result.credential_ids.len(), 1);
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
}
|
||||
|
||||
async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>) -> AdminServices {
|
||||
super::AdminHarness::new()
|
||||
.provider(provider)
|
||||
.accounts(store)
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-xai"),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
[package]
|
||||
name = "gateway-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
rust-version = "1.97"
|
||||
autotests = false
|
||||
|
||||
[[test]]
|
||||
name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] }
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
futures = "0.3.32"
|
||||
gateway-admin = { path = "../gateway-admin" }
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
gateway-protocol = { path = "../gateway-protocol" }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
thiserror = "2.0.18"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "time"] }
|
||||
tower-http = { version = "0.7.0", features = ["cors", "fs", "request-id", "timeout", "trace"] }
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "net", "rt-multi-thread"] }
|
||||
tokio-tungstenite = "0.29.0"
|
||||
tower = { version = "0.5.3", features = ["util"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
||||
//! 管理员登录、会话与管理请求鉴权接线。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{FromRequestParts, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::request_id::RequestId;
|
||||
|
||||
use super::{AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
const REQUEST_ID_HEADER: &str = "x-request-id";
|
||||
const ADMIN_SESSION_COOKIE: &str = "cpr_admin_session";
|
||||
const ADMIN_SESSION_COOKIE_ATTRS: &str = "Path=/; Secure; HttpOnly; SameSite=Lax";
|
||||
|
||||
/// 所有管理 HTTP 模块从 state 消费同一个认证用例端口。
|
||||
pub trait AdminSessionState {
|
||||
fn admin_services(&self) -> &AdminServices;
|
||||
}
|
||||
|
||||
/// 已通过管理员会话或部署级管理 API Key 鉴权的请求。
|
||||
pub struct AdminAuth {
|
||||
context: AdminRequestContext,
|
||||
}
|
||||
|
||||
impl AdminAuth {
|
||||
#[must_use]
|
||||
pub const fn context(&self) -> &AdminRequestContext {
|
||||
&self.context
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_context(self) -> AdminRequestContext {
|
||||
self.context
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for AdminAuth
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
type Rejection = AdminError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let principal = require_admin_auth(state, &parts.headers).await?;
|
||||
let request_id = admin_request_id(parts)
|
||||
.ok_or_else(|| AdminError::internal("Missing admin request context"))?;
|
||||
Ok(Self {
|
||||
context: AdminRequestContext {
|
||||
principal,
|
||||
request_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// request-id 层按配置的 header 名注入,同时写入与名字无关的扩展;
|
||||
/// 优先读扩展,使自定义 header 名不会让管理请求失去请求上下文。
|
||||
/// header 回退覆盖未装配该层的调用方。
|
||||
fn admin_request_id(parts: &Parts) -> Option<String> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<RequestId>()
|
||||
.map(RequestId::header_value)
|
||||
.or_else(|| parts.headers.get(REQUEST_ID_HEADER))
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
pub async fn require_admin_session<S>(state: &S, headers: &HeaderMap) -> Result<String, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
match state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.resolve_admin_user_id(admin_session_cookie(headers).as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(admin_user_id)) => Ok(admin_user_id),
|
||||
Ok(None) => Err(AdminError::admin_session_required()),
|
||||
Err(_) => Err(AdminError::internal("Failed to validate admin session")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_admin_auth<S>(state: &S, headers: &HeaderMap) -> Result<AdminPrincipal, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
if let Some(api_key) = admin_api_key_header(headers) {
|
||||
return match state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.verify_admin_api_key(&api_key)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Ok(AdminPrincipal::ApiKey),
|
||||
Ok(false) => Err(AdminError::invalid_admin_api_key()),
|
||||
Err(_) => Err(AdminError::internal("Failed to validate admin API key")),
|
||||
};
|
||||
}
|
||||
|
||||
require_admin_session(state, headers)
|
||||
.await
|
||||
.map(|admin_user_id| AdminPrincipal::Session { admin_user_id })
|
||||
}
|
||||
|
||||
fn admin_api_key_header(headers: &HeaderMap) -> Option<String> {
|
||||
let value = headers.get("x-api-key")?.to_str().ok()?.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
}
|
||||
|
||||
/// 管理员登录请求;密码不得进入 Debug。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AdminLoginRequest {
|
||||
username: Option<String>,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl AdminLoginRequest {
|
||||
/// 取出认证用例所需字段。
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Option<String>, String) {
|
||||
(self.username, self.password)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AdminLoginRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("AdminLoginRequest")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录成功响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminLoginData {
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
impl AdminLoginData {
|
||||
#[must_use]
|
||||
pub fn new(expires_at: String) -> Self {
|
||||
Self { expires_at }
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理员会话状态响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminSessionStatusData {
|
||||
authenticated: bool,
|
||||
}
|
||||
|
||||
impl AdminSessionStatusData {
|
||||
#[must_use]
|
||||
pub const fn new(authenticated: bool) -> Self {
|
||||
Self { authenticated }
|
||||
}
|
||||
}
|
||||
|
||||
/// 登出成功响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct AdminLogoutData {
|
||||
message: &'static str,
|
||||
}
|
||||
|
||||
impl AdminLogoutData {
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
message: "Logged out successfully",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AdminLogoutData {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 管理员认证路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/auth/login", post(login::<S>))
|
||||
.route("/api/admin/auth/status", get(session_status::<S>))
|
||||
.route("/api/admin/auth/logout", post(logout::<S>))
|
||||
}
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<AdminLoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (username, password) = payload.into_parts();
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(LoginCommand { username, password })
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminLoginData::new(session.expires_at.to_rfc3339())),
|
||||
)
|
||||
.into_response();
|
||||
let cookie = format!(
|
||||
"{ADMIN_SESSION_COOKIE}={}; {ADMIN_SESSION_COOKIE_ATTRS}",
|
||||
session.session_id
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie)
|
||||
.map_err(|_| AdminError::internal("Failed to create admin session cookie"))?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn session_status<S>(
|
||||
State(state): State<S>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let authenticated = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.validate_session(admin_session_cookie(&headers).as_deref())
|
||||
.await
|
||||
.map_err(|_| AdminError::internal("Failed to validate admin session"))?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminSessionStatusData::new(authenticated)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn logout<S>(State(state): State<S>, headers: HeaderMap) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
if let Some(session_id) = admin_session_cookie(&headers) {
|
||||
let _ = state.admin_services().auth().logout(&session_id).await;
|
||||
}
|
||||
let mut response =
|
||||
AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(AdminLogoutData::new()))
|
||||
.into_response();
|
||||
let cookie = format!("{ADMIN_SESSION_COOKIE}=; {ADMIN_SESSION_COOKIE_ATTRS}; Max-Age=0");
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie)
|
||||
.map_err(|_| AdminError::internal("Failed to clear admin session cookie"))?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
let cookie = headers.get("cookie")?.to_str().ok()?;
|
||||
cookie.split(';').find_map(|part| {
|
||||
let (name, value) = part.trim().split_once('=')?;
|
||||
(name == ADMIN_SESSION_COOKIE).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
|
||||
LoginError::Unavailable => AdminError::internal("Failed to create admin session"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,806 @@
|
||||
//! Client API Key 管理 wire contract。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::client_keys::{
|
||||
ClientKeyCursor, ClientKeyCursorValue as DomainCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyPageSize, ClientKeyRecord, ClientKeySecret,
|
||||
ClientKeySort as DomainSort, ClientKeySortField as DomainSortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, SetClientKeyEnabled, SortDirection, UpdateClientKey,
|
||||
};
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::ProviderKind,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
const MAX_CURSOR_BYTES: usize = 512;
|
||||
const MAX_SEARCH_BYTES: usize = 256;
|
||||
const DEFAULT_PAGE_SIZE: u16 = 50;
|
||||
|
||||
/// Client Key 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ListClientKeysQuery {
|
||||
cursor: Option<String>,
|
||||
limit: Option<u16>,
|
||||
search: Option<String>,
|
||||
sort_by: Option<String>,
|
||||
sort_direction: Option<String>,
|
||||
}
|
||||
|
||||
impl ListClientKeysQuery {
|
||||
/// 校验 wire 边界并直接构造管理用例查询。
|
||||
pub fn into_command(self) -> Result<ClientKeyListQuery, WireValidationError> {
|
||||
if self
|
||||
.cursor
|
||||
.as_deref()
|
||||
.is_some_and(|cursor| cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES)
|
||||
{
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
if self.limit == Some(0) {
|
||||
return Err(WireValidationError::new("limit"));
|
||||
}
|
||||
let search = self.search.map(|search| search.trim().to_owned());
|
||||
if search.as_deref().is_some_and(|search| {
|
||||
search.len() > MAX_SEARCH_BYTES
|
||||
|| search.chars().any(char::is_control)
|
||||
|| contains_client_key_material(search)
|
||||
}) {
|
||||
return Err(WireValidationError::new("search"));
|
||||
}
|
||||
let sort = ClientKeySort::parse(
|
||||
self.sort_by.as_deref().unwrap_or("createdAt"),
|
||||
self.sort_direction.as_deref().unwrap_or("desc"),
|
||||
)?;
|
||||
let cursor = self
|
||||
.cursor
|
||||
.as_deref()
|
||||
.map(decode_client_key_cursor)
|
||||
.transpose()?
|
||||
.map(domain_cursor)
|
||||
.transpose()?;
|
||||
let page_size = ClientKeyPageSize::new(self.limit.unwrap_or(DEFAULT_PAGE_SIZE))
|
||||
.map_err(|_| WireValidationError::new("limit"))?;
|
||||
Ok(ClientKeyListQuery {
|
||||
cursor,
|
||||
page_size,
|
||||
search: search.filter(|search| !search.is_empty()),
|
||||
sort: domain_sort(sort),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 数据库排序字段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ClientKeySortField {
|
||||
Name,
|
||||
Enabled,
|
||||
CreatedAt,
|
||||
LastUsedAt,
|
||||
}
|
||||
|
||||
/// Client Key 数据库排序方向。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ClientKeySortDirection {
|
||||
Asc,
|
||||
Desc,
|
||||
}
|
||||
|
||||
/// 已校验且会写入自描述游标的排序组合。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClientKeySort {
|
||||
pub field: ClientKeySortField,
|
||||
pub direction: ClientKeySortDirection,
|
||||
}
|
||||
|
||||
impl ClientKeySort {
|
||||
fn parse(field: &str, direction: &str) -> Result<Self, WireValidationError> {
|
||||
let field = match field {
|
||||
"name" => ClientKeySortField::Name,
|
||||
"enabled" => ClientKeySortField::Enabled,
|
||||
"createdAt" => ClientKeySortField::CreatedAt,
|
||||
"lastUsedAt" => ClientKeySortField::LastUsedAt,
|
||||
_ => return Err(WireValidationError::new("sortBy")),
|
||||
};
|
||||
let direction = match direction {
|
||||
"asc" => ClientKeySortDirection::Asc,
|
||||
"desc" => ClientKeySortDirection::Desc,
|
||||
_ => return Err(WireValidationError::new("sortDirection")),
|
||||
};
|
||||
Ok(Self { field, direction })
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 Client Key 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
}
|
||||
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
provider_kind: ProviderKind::new(self.provider_kind)
|
||||
.map_err(|_| WireValidationError::new("providerKind"))?,
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 Client Key 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateClientKeyRequest {
|
||||
id: String,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
}
|
||||
|
||||
impl UpdateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(UpdateClientKey {
|
||||
id: client_key_id(self.id, "clientKeyMutationNotFound")?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
provider_kind: ProviderKind::new(self.provider_kind)
|
||||
.map_err(|_| WireValidationError::new("providerKind"))?,
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 只携带 ID 的 Client Key mutation 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClientKeyMutationRequest {
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// 读取一次完整 Key 的 ID query。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientKeyIdQuery {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ClientKeyIdQuery {
|
||||
pub fn into_id(self) -> Result<String, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
client_key_id(self.into_id()?, "clientKeyRevealNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyMutationRequest {
|
||||
/// 校验请求并取出 ID。
|
||||
pub fn into_id(self) -> Result<String, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
/// 不含完整 Key 的管理端安全视图。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyView {
|
||||
id: String,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
prefix: String,
|
||||
enabled: bool,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<ClientKeyRecord> for ClientKeyView {
|
||||
fn from(record: ClientKeyRecord) -> Self {
|
||||
Self {
|
||||
id: record.id.to_string(),
|
||||
name: record.name,
|
||||
label: record.label,
|
||||
provider_kind: record.provider_kind.to_string(),
|
||||
prefix: record.prefix,
|
||||
enabled: record.enabled,
|
||||
max_concurrency: record.limits.max_concurrency,
|
||||
requests_per_minute: record.limits.requests_per_minute,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
last_used_at: record.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 列表响应数据。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyListData {
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl ClientKeyListData {
|
||||
/// 构造 Client Key 列表响应。
|
||||
#[must_use]
|
||||
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
|
||||
Self {
|
||||
items,
|
||||
next_cursor,
|
||||
total,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
type Error = WireValidationError;
|
||||
|
||||
fn try_from(page: ClientKeyPage) -> Result<Self, Self::Error> {
|
||||
let next_cursor = page
|
||||
.next_cursor
|
||||
.map(wire_cursor)
|
||||
.transpose()?
|
||||
.as_ref()
|
||||
.map(encode_client_key_cursor)
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
page.items.into_iter().map(Into::into).collect(),
|
||||
next_cursor,
|
||||
page.total,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key 创建响应;完整值只允许出现在本次序列化结果中。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientKeyData {
|
||||
id: String,
|
||||
prefix: String,
|
||||
plaintext_key: String,
|
||||
}
|
||||
|
||||
impl CreatedClientKeyData {
|
||||
/// 构造一次性创建响应。
|
||||
#[must_use]
|
||||
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
prefix,
|
||||
plaintext_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreatedClientKey> for CreatedClientKeyData {
|
||||
fn from(created: CreatedClientKey) -> Self {
|
||||
Self::new(
|
||||
created.secret.record.id.to_string(),
|
||||
created.secret.record.prefix.clone(),
|
||||
created.secret.expose_for_response().to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CreatedClientKeyData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreatedClientKeyData")
|
||||
.field("id", &self.id)
|
||||
.field("prefix", &self.prefix)
|
||||
.field("plaintext_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅由显式 reveal 返回一次的完整明文 Key。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RevealedClientKeyData {
|
||||
id: String,
|
||||
plaintext_key: String,
|
||||
}
|
||||
|
||||
impl RevealedClientKeyData {
|
||||
#[must_use]
|
||||
pub fn new(id: String, plaintext_key: String) -> Self {
|
||||
Self { id, plaintext_key }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientKeySecret> for RevealedClientKeyData {
|
||||
fn from(secret: ClientKeySecret) -> Self {
|
||||
Self::new(
|
||||
secret.record.id.to_string(),
|
||||
secret.expose_for_response().to_owned(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RevealedClientKeyData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RevealedClientKeyData")
|
||||
.field("id", &self.id)
|
||||
.field("plaintext_key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Client Key mutation 响应数据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MutatedClientKeyData {
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl MutatedClientKeyData {
|
||||
/// 构造 mutation 响应。
|
||||
#[must_use]
|
||||
pub fn new(id: String) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientKeyMutation> for MutatedClientKeyData {
|
||||
fn from(mutation: ClientKeyMutation) -> Self {
|
||||
Self::new(mutation.id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 解码后的 Client Key 游标字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyCursorData {
|
||||
pub sort: ClientKeySort,
|
||||
pub value: ClientKeyCursorValue,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// 游标中与排序字段严格对应的最后一行值。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
|
||||
pub enum ClientKeyCursorValue {
|
||||
Name(String),
|
||||
Enabled(bool),
|
||||
CreatedAt(DateTime<Utc>),
|
||||
LastUsedAt(Option<DateTime<Utc>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CursorWire {
|
||||
sort: ClientKeySort,
|
||||
value: ClientKeyCursorValue,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// 把 owner 游标编码为不透明 wire 值。
|
||||
pub fn encode_client_key_cursor(
|
||||
cursor: &ClientKeyCursorData,
|
||||
) -> Result<String, WireValidationError> {
|
||||
validate_client_key_cursor(cursor)?;
|
||||
let bytes = serde_json::to_vec(&CursorWire {
|
||||
sort: cursor.sort,
|
||||
value: cursor.value.clone(),
|
||||
id: cursor.id.clone(),
|
||||
})
|
||||
.map_err(|_| WireValidationError::new("cursor"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
/// 解码并严格校验 Client Key 游标。
|
||||
pub fn decode_client_key_cursor(encoded: &str) -> Result<ClientKeyCursorData, WireValidationError> {
|
||||
if encoded.is_empty() || encoded.len() > MAX_CURSOR_BYTES {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(encoded)
|
||||
.map_err(|_| WireValidationError::new("cursor"))?;
|
||||
let cursor: CursorWire =
|
||||
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
|
||||
let cursor = ClientKeyCursorData {
|
||||
sort: cursor.sort,
|
||||
value: cursor.value,
|
||||
id: cursor.id,
|
||||
};
|
||||
validate_client_key_cursor(&cursor)?;
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
fn validate_client_key_cursor(cursor: &ClientKeyCursorData) -> Result<(), WireValidationError> {
|
||||
validate_required_text(&cursor.id, "cursor")?;
|
||||
let matching = matches!(
|
||||
(cursor.sort.field, &cursor.value),
|
||||
(ClientKeySortField::Name, ClientKeyCursorValue::Name(value)) if !value.trim().is_empty()
|
||||
) || matches!(
|
||||
(cursor.sort.field, &cursor.value),
|
||||
(
|
||||
ClientKeySortField::Enabled,
|
||||
ClientKeyCursorValue::Enabled(_)
|
||||
) | (
|
||||
ClientKeySortField::CreatedAt,
|
||||
ClientKeyCursorValue::CreatedAt(_)
|
||||
) | (
|
||||
ClientKeySortField::LastUsedAt,
|
||||
ClientKeyCursorValue::LastUsedAt(_)
|
||||
)
|
||||
);
|
||||
if matching {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WireValidationError::new("cursor"))
|
||||
}
|
||||
}
|
||||
|
||||
const fn domain_sort(sort: ClientKeySort) -> DomainSort {
|
||||
DomainSort {
|
||||
field: match sort.field {
|
||||
ClientKeySortField::Name => DomainSortField::Name,
|
||||
ClientKeySortField::Enabled => DomainSortField::Enabled,
|
||||
ClientKeySortField::CreatedAt => DomainSortField::CreatedAt,
|
||||
ClientKeySortField::LastUsedAt => DomainSortField::LastUsedAt,
|
||||
},
|
||||
direction: match sort.direction {
|
||||
ClientKeySortDirection::Asc => SortDirection::Asc,
|
||||
ClientKeySortDirection::Desc => SortDirection::Desc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fn wire_sort(sort: DomainSort) -> ClientKeySort {
|
||||
ClientKeySort {
|
||||
field: match sort.field {
|
||||
DomainSortField::Name => ClientKeySortField::Name,
|
||||
DomainSortField::Enabled => ClientKeySortField::Enabled,
|
||||
DomainSortField::CreatedAt => ClientKeySortField::CreatedAt,
|
||||
DomainSortField::LastUsedAt => ClientKeySortField::LastUsedAt,
|
||||
},
|
||||
direction: match sort.direction {
|
||||
SortDirection::Asc => ClientKeySortDirection::Asc,
|
||||
SortDirection::Desc => ClientKeySortDirection::Desc,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn domain_cursor(cursor: ClientKeyCursorData) -> Result<ClientKeyCursor, WireValidationError> {
|
||||
let value = match cursor.value {
|
||||
ClientKeyCursorValue::Name(value) => DomainCursorValue::Name(value),
|
||||
ClientKeyCursorValue::Enabled(value) => DomainCursorValue::Enabled(value),
|
||||
ClientKeyCursorValue::CreatedAt(value) => DomainCursorValue::CreatedAt(value),
|
||||
ClientKeyCursorValue::LastUsedAt(value) => DomainCursorValue::LastUsedAt(value),
|
||||
};
|
||||
Ok(ClientKeyCursor {
|
||||
sort: domain_sort(cursor.sort),
|
||||
value,
|
||||
id: client_key_id(cursor.id, "cursor")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValidationError> {
|
||||
let value = match cursor.value {
|
||||
DomainCursorValue::Name(value) => ClientKeyCursorValue::Name(value),
|
||||
DomainCursorValue::Enabled(value) => ClientKeyCursorValue::Enabled(value),
|
||||
DomainCursorValue::CreatedAt(value) => ClientKeyCursorValue::CreatedAt(value),
|
||||
DomainCursorValue::LastUsedAt(value) => ClientKeyCursorValue::LastUsedAt(value),
|
||||
};
|
||||
let cursor = ClientKeyCursorData {
|
||||
sort: wire_sort(cursor.sort),
|
||||
value,
|
||||
id: cursor.id.to_string(),
|
||||
};
|
||||
validate_client_key_cursor(&cursor)?;
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
fn client_key_id(
|
||||
value: String,
|
||||
field: &'static str,
|
||||
) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
ClientApiKeyId::new(value).map_err(|_| WireValidationError::new(field))
|
||||
}
|
||||
|
||||
fn validate_limit(value: u64, field: &'static str) -> Result<(), WireValidationError> {
|
||||
if i64::try_from(value).is_err() {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireValidationError> {
|
||||
if value.trim().is_empty() {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_provider_kind(value: &str) -> Result<(), WireValidationError> {
|
||||
let valid = !value.is_empty()
|
||||
&& value.len() <= 64
|
||||
&& value.bytes().enumerate().all(|(index, byte)| {
|
||||
byte.is_ascii_lowercase()
|
||||
|| byte.is_ascii_digit()
|
||||
|| (byte == b'-' && index > 0 && index + 1 < value.len())
|
||||
})
|
||||
&& !value.starts_with('-')
|
||||
&& !value.ends_with('-');
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(WireValidationError::new("providerKind"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_optional_text(
|
||||
value: Option<&str>,
|
||||
field: &'static str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if value.is_some_and(|value| value.trim().is_empty()) {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contains_client_key_material(value: &str) -> bool {
|
||||
value.as_bytes().windows(46).any(|window| {
|
||||
&window[..3] == b"sk_"
|
||||
&& window[3..]
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
})
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 且 ID 仅位于 query/body 的 Client API Key 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/client-keys", get(list_client_keys::<S>))
|
||||
.route(
|
||||
"/api/admin/client-keys/create",
|
||||
post(create_client_key::<S>),
|
||||
)
|
||||
.route("/api/admin/client-keys/reveal", get(reveal_client_key::<S>))
|
||||
.route(
|
||||
"/api/admin/client-keys/update",
|
||||
post(update_client_key::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/client-keys/disable",
|
||||
post(disable_client_key::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/client-keys/enable",
|
||||
post(enable_client_key::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/client-keys/delete",
|
||||
post(delete_client_key::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_client_keys<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<ListClientKeysQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.list(query.into_command().map_err(map_wire_error)?)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ClientKeyListData::try_from(result)
|
||||
.map_err(|_| AdminError::internal("Failed to encode client key cursor"))?;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn create_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<CreateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.create(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(CreatedClientKeyData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn reveal_client_key<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<ClientKeyIdQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let id = query.into_domain_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.reveal(&id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let mut response = AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RevealedClientKeyData::from(result)),
|
||||
)
|
||||
.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn update_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<UpdateClientKeyRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = payload.into_command().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.update(&auth.context().mutation_context(), command)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn disable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled { id, enabled: false },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn enable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetClientKeyEnabled { id, enabled: true },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn delete_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
fn mutation_response(
|
||||
result: Result<ClientKeyMutation, gateway_admin::model::AdminError>,
|
||||
) -> Result<AdminResponse<AdminEnvelope<MutatedClientKeyData>>, AdminError> {
|
||||
let data = result.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(MutatedClientKeyData::from(data)),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
match error.field() {
|
||||
"cursor" => AdminError::bad_request("Invalid client key cursor"),
|
||||
"clientKeyRevealNotFound" => AdminError::not_found("Client API key was not found"),
|
||||
"clientKeyMutationNotFound" => AdminError::not_found("client API key was not found"),
|
||||
_ => AdminError::bad_request("Invalid client API key request"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
map_admin_service_error(error, "Configuration repository unavailable")
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! 管理端 HTTP adapter、wire contract 与固定路由。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
http::{HeaderValue, header},
|
||||
middleware,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod wire;
|
||||
|
||||
pub use auth::{AdminAuth, AdminSessionState};
|
||||
pub use wire::{
|
||||
ADMIN_OK_CODE, ADMIN_OK_MESSAGE, AdminEnvelope, AdminError, AdminErrorBody, AdminErrorCode,
|
||||
AdminPageData, AdminResponse, PageMeta, WireValidationError,
|
||||
};
|
||||
|
||||
/// 构造完整且固定的 `/api/admin` 路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(client_keys::router::<S>())
|
||||
.merge(observability::router::<S>())
|
||||
.merge(settings::router::<S>())
|
||||
.merge(system::router::<S>())
|
||||
.layer(middleware::map_response(no_store))
|
||||
}
|
||||
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,361 @@
|
||||
//! Runtime settings、旧设置页聚合投影与明文 Admin API Key wire。
|
||||
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, WireValidationError,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
pub type ModelMappings = BTreeMap<String, String>;
|
||||
|
||||
/// 运行配置投影与设置页字段的聚合响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 原子替换全局运行参数的请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
}
|
||||
|
||||
impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
(self.refresh_concurrency, "refreshConcurrency"),
|
||||
(self.max_concurrent_per_account, "maxConcurrentPerAccount"),
|
||||
(self.usage_retention_days, "usageRetentionDays"),
|
||||
(self.ops_event_retention_days, "opsEventRetentionDays"),
|
||||
(self.audit_retention_days, "auditRetentionDays"),
|
||||
] {
|
||||
require_positive_i64(value, field)?;
|
||||
}
|
||||
if i64::try_from(self.request_interval_ms).is_err() {
|
||||
return Err(WireValidationError::new("requestIntervalMs"));
|
||||
}
|
||||
if !matches!(
|
||||
self.rotation_strategy.as_str(),
|
||||
"smart" | "quota_reset_priority" | "round_robin" | "sticky"
|
||||
) {
|
||||
return Err(WireValidationError::new("rotationStrategy"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ReplaceRuntimeSettings {
|
||||
model_mappings: domain_model_mappings(self.model_mappings)?,
|
||||
refresh_margin_seconds: self.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(self.refresh_concurrency)
|
||||
.map_err(|_| WireValidationError::new("settingsRefreshConcurrencyOverflow"))?,
|
||||
max_concurrent_per_account: u32::try_from(self.max_concurrent_per_account)
|
||||
.map_err(|_| WireValidationError::new("settingsMaxConcurrencyOverflow"))?,
|
||||
request_interval_ms: self.request_interval_ms,
|
||||
rotation_strategy: parse_rotation_strategy(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
usage_retention_days: u32::try_from(self.usage_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
|
||||
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsOpsRetentionOverflow"))?,
|
||||
audit_retention_days: u32::try_from(self.audit_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
refresh_margin_seconds: settings.refresh_margin_seconds,
|
||||
refresh_concurrency: u64::from(settings.refresh_concurrency),
|
||||
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
rotation_strategy: rotation_strategy_name(settings.rotation_strategy).to_owned(),
|
||||
usage_retention_days: u64::from(settings.usage_retention_days),
|
||||
ops_event_retention_days: u64::from(settings.ops_event_retention_days),
|
||||
audit_retention_days: u64::from(settings.audit_retention_days),
|
||||
updated_at: settings.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理 API Key 状态;状态读取不回显完整值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminApiKeyStatus {
|
||||
pub exists: bool,
|
||||
}
|
||||
|
||||
/// 管理 API Key 重新生成响应。
|
||||
#[derive(Serialize)]
|
||||
pub struct RegeneratedAdminApiKey {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RegeneratedAdminApiKey {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RegeneratedAdminApiKey")
|
||||
.field("key", &"[REDACTED]")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理 API Key 删除响应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct DeletedAdminApiKey {
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
impl Default for DeletedAdminApiKey {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
message: "Admin API key deleted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 设置路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
.route("/api/admin/settings/update", post(update_settings::<S>))
|
||||
.route(
|
||||
"/api/admin/settings/admin-api-key",
|
||||
get(admin_api_key_status::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/admin-api-key/delete",
|
||||
post(delete_admin_api_key::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/admin-api-key/regenerate",
|
||||
post(regenerate_admin_api_key::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
async fn settings<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.load()
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_settings<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<UpdateRuntimeSettingsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.replace(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RuntimeSettingsView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn admin_api_key_status<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let exists = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.admin_api_key_exists()
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminApiKeyStatus { exists }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn regenerate_admin_api_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.regenerate_admin_api_key(&auth.context().mutation_context())
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RegeneratedAdminApiKey {
|
||||
key: result.key.expose_for_response().to_owned(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.delete_admin_api_key(&auth.context().mutation_context())
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DeletedAdminApiKey::default()),
|
||||
))
|
||||
}
|
||||
|
||||
fn require_positive_i64(value: u64, field: &'static str) -> Result<(), WireValidationError> {
|
||||
if value == 0 || i64::try_from(value).is_err() {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_model_mappings(mappings: &ModelMappings) -> Result<(), WireValidationError> {
|
||||
if mappings.len() > 512 {
|
||||
return Err(WireValidationError::new("modelMappings"));
|
||||
}
|
||||
for (requested, upstream) in mappings {
|
||||
if !valid_model_name(requested, 256) || !valid_model_name(upstream, 256) {
|
||||
return Err(WireValidationError::new("modelMappings"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn domain_model_mappings(
|
||||
mappings: ModelMappings,
|
||||
) -> Result<DomainModelMappings, WireValidationError> {
|
||||
mappings
|
||||
.into_iter()
|
||||
.map(|(requested, upstream)| {
|
||||
Ok((
|
||||
PublicModelId::new(requested)
|
||||
.map_err(|_| WireValidationError::new("modelMappings"))?,
|
||||
UpstreamModelId::new(upstream)
|
||||
.map_err(|_| WireValidationError::new("modelMappings"))?,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
|
||||
mappings
|
||||
.into_iter()
|
||||
.map(|(requested, upstream)| (requested.to_string(), upstream.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_rotation_strategy(value: &str) -> Option<RotationStrategy> {
|
||||
match value {
|
||||
"smart" => Some(RotationStrategy::Smart),
|
||||
"quota_reset_priority" => Some(RotationStrategy::QuotaResetPriority),
|
||||
"round_robin" => Some(RotationStrategy::RoundRobin),
|
||||
"sticky" => Some(RotationStrategy::Sticky),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn rotation_strategy_name(strategy: RotationStrategy) -> &'static str {
|
||||
match strategy {
|
||||
RotationStrategy::Smart => "smart",
|
||||
RotationStrategy::QuotaResetPriority => "quota_reset_priority",
|
||||
RotationStrategy::RoundRobin => "round_robin",
|
||||
RotationStrategy::Sticky => "sticky",
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_model_name(value: &str, max_len: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= max_len
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
}
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
|
||||
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
|
||||
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
|
||||
"settingsOpsRetentionOverflow" => "Invalid opsEventRetentionDays".to_owned(),
|
||||
"settingsAuditRetentionOverflow" => "Invalid auditRetentionDays".to_owned(),
|
||||
field => format!("Invalid field: {field}"),
|
||||
};
|
||||
AdminError::bad_request(message)
|
||||
}
|
||||
|
||||
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
map_admin_service_error(error, "Settings repository unavailable")
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
//! 系统管理接口的查询与请求 wire contract。
|
||||
//!
|
||||
//! 这里不依赖更新服务或进程控制;应用层通过窄端口提供系统操作事实。
|
||||
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{
|
||||
IntoResponse,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use futures::{Stream, StreamExt};
|
||||
use gateway_admin::model::system::{
|
||||
SystemOperationAccepted, SystemOperationKind, SystemOperationState, SystemOperationStatus,
|
||||
SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel, SystemUpdateStatus,
|
||||
SystemVersion,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState,
|
||||
wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
/// 更新详情查询参数。
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateDetailQuery {
|
||||
refresh: Option<bool>,
|
||||
}
|
||||
|
||||
impl UpdateDetailQuery {
|
||||
/// 是否强制从发布源刷新。
|
||||
#[must_use]
|
||||
pub fn refresh(&self) -> bool {
|
||||
self.refresh.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行更新请求。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRequest {
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
impl UpdateRequest {
|
||||
/// 取出确认的目标版本原值,由更新领域负责版本规范化和校验。
|
||||
#[must_use]
|
||||
pub fn into_target_version(self) -> String {
|
||||
self.target_version
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemVersionView {
|
||||
version: String,
|
||||
git_sha: String,
|
||||
build_time: String,
|
||||
deployment_mode: String,
|
||||
deployment_mode_label: String,
|
||||
update_channel: String,
|
||||
latest_version: String,
|
||||
has_update: bool,
|
||||
update_cached: bool,
|
||||
update_warning: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemVersion> for SystemVersionView {
|
||||
fn from(version: SystemVersion) -> Self {
|
||||
Self {
|
||||
deployment_mode_label: deployment_mode_label(&version.deployment_mode).to_owned(),
|
||||
version: version.version,
|
||||
git_sha: version.git_sha,
|
||||
build_time: version.build_time,
|
||||
deployment_mode: version.deployment_mode,
|
||||
update_channel: version.update_channel,
|
||||
latest_version: version.latest_version,
|
||||
has_update: version.has_update,
|
||||
update_cached: version.update_cached,
|
||||
update_warning: version.update_warning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemUpdateDetailView {
|
||||
current_version: String,
|
||||
latest_version: String,
|
||||
has_update: bool,
|
||||
deployment_mode: String,
|
||||
deployment_mode_label: String,
|
||||
build_type: String,
|
||||
build_type_label: String,
|
||||
release_url: Option<String>,
|
||||
notes: Option<String>,
|
||||
cached: bool,
|
||||
update_supported: bool,
|
||||
unsupported_reason: Option<String>,
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemUpdateDetail> for SystemUpdateDetailView {
|
||||
fn from(detail: SystemUpdateDetail) -> Self {
|
||||
Self {
|
||||
deployment_mode_label: deployment_mode_label(&detail.deployment_mode).to_owned(),
|
||||
build_type_label: build_type_label(&detail.build_type),
|
||||
current_version: detail.current_version,
|
||||
latest_version: detail.latest_version,
|
||||
has_update: detail.has_update,
|
||||
deployment_mode: detail.deployment_mode,
|
||||
build_type: detail.build_type,
|
||||
release_url: detail.release_url,
|
||||
notes: detail.notes,
|
||||
cached: detail.cached,
|
||||
update_supported: detail.update_supported,
|
||||
unsupported_reason: detail.unsupported_reason,
|
||||
warning: detail.warning,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemOperationStateView {
|
||||
operation_id: Option<String>,
|
||||
kind: Option<&'static str>,
|
||||
status: &'static str,
|
||||
target_version: Option<String>,
|
||||
message: Option<String>,
|
||||
error: Option<String>,
|
||||
started_at: Option<String>,
|
||||
finished_at: Option<String>,
|
||||
}
|
||||
|
||||
impl From<SystemOperationState> for SystemOperationStateView {
|
||||
fn from(operation: SystemOperationState) -> Self {
|
||||
Self {
|
||||
operation_id: operation.operation_id,
|
||||
kind: operation.kind.map(operation_kind_name),
|
||||
status: operation_status_name(operation.status),
|
||||
target_version: operation.target_version,
|
||||
message: operation.message,
|
||||
error: operation.error,
|
||||
started_at: operation.started_at.map(|value| value.to_rfc3339()),
|
||||
finished_at: operation.finished_at.map(|value| value.to_rfc3339()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemUpdateStatusView {
|
||||
previous_version: Option<String>,
|
||||
current_version: Option<String>,
|
||||
operation: SystemOperationStateView,
|
||||
}
|
||||
|
||||
impl From<SystemUpdateStatus> for SystemUpdateStatusView {
|
||||
fn from(status: SystemUpdateStatus) -> Self {
|
||||
Self {
|
||||
previous_version: status.previous_version,
|
||||
current_version: status.current_version,
|
||||
operation: status.operation.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct UpdateAcceptedView {
|
||||
operation_id: String,
|
||||
deployment_mode: String,
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
target_version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RollbackAcceptedView {
|
||||
message: String,
|
||||
need_restart: bool,
|
||||
operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RestartAcceptedView {
|
||||
message: String,
|
||||
operation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SystemUpdateEventView {
|
||||
id: String,
|
||||
operation_id: Option<String>,
|
||||
level: &'static str,
|
||||
step: Option<String>,
|
||||
message: String,
|
||||
terminal: bool,
|
||||
progress_percent: Option<u8>,
|
||||
at: String,
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 系统管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/system/version", get(version::<S>))
|
||||
.route("/api/admin/system/update/detail", get(update_detail::<S>))
|
||||
.route(
|
||||
"/api/admin/system/update/events",
|
||||
get(update_event_stream::<S>),
|
||||
)
|
||||
.route("/api/admin/system/update", post(perform_update::<S>))
|
||||
.route("/api/admin/system/update/status", get(update_status::<S>))
|
||||
.route("/api/admin/system/rollback", post(rollback::<S>))
|
||||
.route("/api/admin/system/restart", post(restart::<S>))
|
||||
}
|
||||
|
||||
async fn version<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let version = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.version()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemVersionView::from(version)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_detail<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<UpdateDetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let detail = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_detail(query.refresh())
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemUpdateDetailView::from(detail)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_event_stream<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let stream = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_events()
|
||||
.map(|message| {
|
||||
let id = message.id.clone();
|
||||
let data = SystemUpdateEventView::from(message).into_json().to_string();
|
||||
Ok(Event::default().event("update").id(id).data(data))
|
||||
});
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
||||
async fn perform_update<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
payload: Option<Json<UpdateRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.perform_update(payload.map(|Json(value)| value.into_target_version()))
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
let SystemOperationAccepted::Update {
|
||||
operation_id,
|
||||
deployment_mode,
|
||||
message,
|
||||
need_restart,
|
||||
target_version,
|
||||
} = result
|
||||
else {
|
||||
return Err(AdminError::internal("Invalid system update result"));
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(UpdateAcceptedView {
|
||||
operation_id,
|
||||
deployment_mode,
|
||||
message,
|
||||
need_restart,
|
||||
target_version,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_status<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let status = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.update_status()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(SystemUpdateStatusView::from(status)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn rollback<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.rollback()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
let SystemOperationAccepted::Rollback {
|
||||
operation_id,
|
||||
message,
|
||||
need_restart,
|
||||
} = result
|
||||
else {
|
||||
return Err(AdminError::internal("Invalid system rollback result"));
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RollbackAcceptedView {
|
||||
message,
|
||||
need_restart,
|
||||
operation_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn restart<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.system()
|
||||
.restart()
|
||||
.await
|
||||
.map_err(map_system_error)?;
|
||||
let SystemOperationAccepted::Restart {
|
||||
operation_id,
|
||||
message,
|
||||
} = result
|
||||
else {
|
||||
return Err(AdminError::internal("Invalid system restart result"));
|
||||
};
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(RestartAcceptedView {
|
||||
message,
|
||||
operation_id,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
impl From<SystemUpdateEvent> for SystemUpdateEventView {
|
||||
fn from(event: SystemUpdateEvent) -> Self {
|
||||
Self {
|
||||
id: event.id,
|
||||
operation_id: event.operation_id,
|
||||
level: update_event_level_name(event.level),
|
||||
step: event.step,
|
||||
message: event.message,
|
||||
terminal: event.terminal,
|
||||
progress_percent: event.progress_percent,
|
||||
at: event.occurred_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemUpdateEventView {
|
||||
fn into_json(self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id,
|
||||
"operationId": self.operation_id,
|
||||
"level": self.level,
|
||||
"step": self.step,
|
||||
"message": self.message,
|
||||
"terminal": self.terminal,
|
||||
"progressPercent": self.progress_percent,
|
||||
"at": self.at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn deployment_mode_label(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"docker" => "Docker",
|
||||
"binary" => "二进制",
|
||||
_ => "源码运行",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_type_label(build_type: &str) -> String {
|
||||
match build_type {
|
||||
"release" => "正式构建",
|
||||
"source" => "源码构建",
|
||||
"dev" => "开发构建",
|
||||
value => value,
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
const fn operation_kind_name(kind: SystemOperationKind) -> &'static str {
|
||||
match kind {
|
||||
SystemOperationKind::Update => "update",
|
||||
SystemOperationKind::Rollback => "rollback",
|
||||
SystemOperationKind::Restart => "restart",
|
||||
}
|
||||
}
|
||||
|
||||
const fn operation_status_name(status: SystemOperationStatus) -> &'static str {
|
||||
match status {
|
||||
SystemOperationStatus::Idle => "idle",
|
||||
SystemOperationStatus::Running => "running",
|
||||
SystemOperationStatus::Succeeded => "succeeded",
|
||||
SystemOperationStatus::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
const fn update_event_level_name(level: SystemUpdateEventLevel) -> &'static str {
|
||||
match level {
|
||||
SystemUpdateEventLevel::Info => "info",
|
||||
SystemUpdateEventLevel::Warning => "warning",
|
||||
SystemUpdateEventLevel::Success => "success",
|
||||
SystemUpdateEventLevel::Error => "error",
|
||||
}
|
||||
}
|
||||
|
||||
fn map_system_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
map_admin_service_error(error, "System operation unavailable")
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//! 管理端公共 wire、响应信封与脱敏错误。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 管理端 wire 字段校验错误;只携带稳定字段名,不回显输入值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WireValidationError {
|
||||
field: &'static str,
|
||||
}
|
||||
|
||||
impl WireValidationError {
|
||||
/// 构造字段校验错误。
|
||||
#[must_use]
|
||||
pub const fn new(field: &'static str) -> Self {
|
||||
Self { field }
|
||||
}
|
||||
|
||||
/// 返回未通过校验的字段名。
|
||||
#[must_use]
|
||||
pub const fn field(self) -> &'static str {
|
||||
self.field
|
||||
}
|
||||
}
|
||||
|
||||
/// 成功响应的稳定业务码。
|
||||
pub const ADMIN_OK_CODE: u32 = 200;
|
||||
|
||||
/// 成功响应的稳定消息。
|
||||
pub const ADMIN_OK_MESSAGE: &str = "OK";
|
||||
|
||||
/// 页码分页元数据。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageMeta {
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
total: u64,
|
||||
total_pages: u32,
|
||||
}
|
||||
|
||||
impl PageMeta {
|
||||
/// 由分页 owner 已经校验并计算的事实构造 wire metadata。
|
||||
#[must_use]
|
||||
pub const fn new(page: u32, page_size: u32, total: u64, total_pages: u32) -> Self {
|
||||
Self {
|
||||
page,
|
||||
page_size,
|
||||
total,
|
||||
total_pages,
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前页,从 1 开始。
|
||||
#[must_use]
|
||||
pub const fn page(self) -> u32 {
|
||||
self.page
|
||||
}
|
||||
|
||||
/// 每页数量。
|
||||
#[must_use]
|
||||
pub const fn page_size(self) -> u32 {
|
||||
self.page_size
|
||||
}
|
||||
|
||||
/// 全部记录数量。
|
||||
#[must_use]
|
||||
pub const fn total(self) -> u64 {
|
||||
self.total
|
||||
}
|
||||
|
||||
/// 总页数。
|
||||
#[must_use]
|
||||
pub const fn total_pages(self) -> u32 {
|
||||
self.total_pages
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端稳定业务错误码。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AdminErrorCode(u32);
|
||||
|
||||
impl AdminErrorCode {
|
||||
/// JSON 解析失败。
|
||||
pub const MALFORMED_JSON: Self = Self(40000);
|
||||
/// 请求参数或状态不合法。
|
||||
pub const BAD_REQUEST: Self = Self(40001);
|
||||
/// 时间范围不合法。
|
||||
pub const INVALID_TIME_RANGE: Self = Self(40002);
|
||||
/// 模型来源不合法。
|
||||
pub const INVALID_MODEL_SOURCE: Self = Self(40003);
|
||||
/// 缺少管理员会话。
|
||||
pub const SESSION_REQUIRED: Self = Self(40101);
|
||||
/// 管理员登录凭据错误。
|
||||
pub const INVALID_CREDENTIALS: Self = Self(40102);
|
||||
/// 管理 API Key 错误。
|
||||
pub const INVALID_API_KEY: Self = Self(40103);
|
||||
/// 资源不存在。
|
||||
pub const NOT_FOUND: Self = Self(40401);
|
||||
/// 配置 revision 或资源状态冲突。
|
||||
pub const CONFLICT: Self = Self(40901);
|
||||
/// 管理员登录尝试过多。
|
||||
pub const TOO_MANY_LOGIN_ATTEMPTS: Self = Self(42901);
|
||||
/// 设置持久化失败。
|
||||
pub const SETTINGS_PERSIST: Self = Self(50000);
|
||||
/// 未分类内部错误。
|
||||
pub const INTERNAL: Self = Self(50001);
|
||||
/// 用量记录的账号投影失败。
|
||||
pub const USAGE_RECORD_ACCOUNTS: Self = Self(50002);
|
||||
/// 上游网关失败。
|
||||
pub const BAD_GATEWAY: Self = Self(50201);
|
||||
/// 依赖的服务暂不可用。
|
||||
pub const SERVICE_UNAVAILABLE: Self = Self(50301);
|
||||
|
||||
/// 返回用于 JSON wire contract 的数值。
|
||||
#[must_use]
|
||||
pub const fn value(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端错误响应正文。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminErrorBody {
|
||||
code: AdminErrorCode,
|
||||
message: String,
|
||||
data: (),
|
||||
}
|
||||
|
||||
impl AdminErrorBody {
|
||||
/// 构造不携带业务数据的管理端错误正文。
|
||||
#[must_use]
|
||||
pub fn new(code: AdminErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
data: (),
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定业务错误码。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> AdminErrorCode {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 安全的客户端错误消息。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端 HTTP 错误。
|
||||
pub struct AdminError {
|
||||
status: StatusCode,
|
||||
body: AdminErrorBody,
|
||||
}
|
||||
|
||||
impl AdminError {
|
||||
fn new(status: StatusCode, code: AdminErrorCode, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: AdminErrorBody::new(code, message),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
AdminErrorCode::BAD_REQUEST,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn admin_session_required() -> Self {
|
||||
Self::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::SESSION_REQUIRED,
|
||||
"Admin session required",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn invalid_admin_credentials() -> Self {
|
||||
Self::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::INVALID_CREDENTIALS,
|
||||
"Invalid admin credentials",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn invalid_admin_api_key() -> Self {
|
||||
Self::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
AdminErrorCode::INVALID_API_KEY,
|
||||
"Invalid admin API key",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn too_many_login_attempts() -> Self {
|
||||
Self::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
|
||||
"Too many admin login attempts",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn conflict(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::CONFLICT, AdminErrorCode::CONFLICT, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, AdminErrorCode::NOT_FOUND, message)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AdminErrorCode::INTERNAL,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
AdminErrorCode::BAD_GATEWAY,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn service_unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
AdminErrorCode::SERVICE_UNAVAILABLE,
|
||||
message,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把管理用例的稳定错误分类映射到既有 HTTP 错误 contract。
|
||||
pub(crate) fn map_admin_service_error(
|
||||
error: gateway_admin::model::AdminError,
|
||||
unavailable_message: &'static str,
|
||||
) -> AdminError {
|
||||
use gateway_admin::model::AdminErrorKind;
|
||||
|
||||
match error.kind() {
|
||||
AdminErrorKind::Invalid => AdminError::bad_request(error.to_string()),
|
||||
AdminErrorKind::Unauthorized => AdminError::admin_session_required(),
|
||||
AdminErrorKind::NotFound => AdminError::not_found(error.to_string()),
|
||||
AdminErrorKind::Conflict => AdminError::conflict(error.to_string()),
|
||||
AdminErrorKind::RateLimited => AdminError::too_many_login_attempts(),
|
||||
AdminErrorKind::BadGateway => AdminError::bad_gateway(error.to_string()),
|
||||
AdminErrorKind::Unavailable => AdminError::service_unavailable(unavailable_message),
|
||||
AdminErrorKind::Internal => AdminError::internal(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminError {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for AdminError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.body.message())
|
||||
}
|
||||
}
|
||||
|
||||
/// 带独立 HTTP 状态的管理端响应。
|
||||
pub struct AdminResponse<T: Serialize> {
|
||||
status: StatusCode,
|
||||
body: T,
|
||||
}
|
||||
|
||||
impl<T: Serialize> AdminResponse<T> {
|
||||
#[must_use]
|
||||
pub fn new(status: StatusCode, body: T) -> Self {
|
||||
Self { status, body }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize> IntoResponse for AdminResponse<T> {
|
||||
fn into_response(self) -> Response {
|
||||
(self.status, Json(self.body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// 管理端响应信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminEnvelope<T> {
|
||||
code: u32,
|
||||
message: String,
|
||||
data: T,
|
||||
}
|
||||
|
||||
impl<T> AdminEnvelope<T> {
|
||||
/// 构造稳定的成功响应。
|
||||
#[must_use]
|
||||
pub fn ok(data: T) -> Self {
|
||||
Self {
|
||||
code: ADMIN_OK_CODE,
|
||||
message: ADMIN_OK_MESSAGE.to_owned(),
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
/// 稳定业务码。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> u32 {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 稳定业务消息。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
/// 响应数据。
|
||||
#[must_use]
|
||||
pub const fn data(&self) -> &T {
|
||||
&self.data
|
||||
}
|
||||
|
||||
/// 取出响应数据。
|
||||
#[must_use]
|
||||
pub fn into_data(self) -> T {
|
||||
self.data
|
||||
}
|
||||
}
|
||||
|
||||
/// 页码分页响应数据。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminPageData<T> {
|
||||
items: Vec<T>,
|
||||
page: PageMeta,
|
||||
}
|
||||
|
||||
impl<T> AdminPageData<T> {
|
||||
/// 组合查询 owner 返回的当前页数据与分页事实。
|
||||
#[must_use]
|
||||
pub const fn new(items: Vec<T>, page: PageMeta) -> Self {
|
||||
Self { items, page }
|
||||
}
|
||||
|
||||
/// 当前页记录。
|
||||
#[must_use]
|
||||
pub fn items(&self) -> &[T] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// 分页元数据。
|
||||
#[must_use]
|
||||
pub const fn page(&self) -> PageMeta {
|
||||
self.page
|
||||
}
|
||||
|
||||
/// 拆分分页数据。
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Vec<T>, PageMeta) {
|
||||
(self.items, self.page)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! `/healthz` 对 Core、Store 与 Host worker 健康事实的聚合。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{extract::State, http::StatusCode};
|
||||
use futures::future::join_all;
|
||||
use gateway_core::health::{
|
||||
HealthProbe, HealthState, WorkerHealthSnapshot, WorkerHealthSource, WorkerRuntimeState,
|
||||
};
|
||||
|
||||
use crate::ApiState;
|
||||
|
||||
const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HealthStatus {
|
||||
probes: Arc<[Arc<dyn HealthProbe>]>,
|
||||
workers: Arc<dyn WorkerHealthSource>,
|
||||
}
|
||||
|
||||
impl HealthStatus {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
probes: Vec<Arc<dyn HealthProbe>>,
|
||||
workers: Arc<dyn WorkerHealthSource>,
|
||||
) -> Self {
|
||||
Self {
|
||||
probes: probes.into(),
|
||||
workers,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn healthy(&self) -> bool {
|
||||
if !self.workers.snapshot().iter().all(worker_is_healthy) {
|
||||
return false;
|
||||
}
|
||||
let checks = self.probes.iter().map(|probe| probe.check());
|
||||
tokio::time::timeout(HEALTH_CHECK_TIMEOUT, join_all(checks))
|
||||
.await
|
||||
.is_ok_and(|states| {
|
||||
states
|
||||
.into_iter()
|
||||
.all(|state| matches!(state, HealthState::Healthy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn healthz(State(state): State<ApiState>) -> StatusCode {
|
||||
if state.health().healthy().await {
|
||||
StatusCode::NO_CONTENT
|
||||
} else {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_is_healthy(worker: &WorkerHealthSnapshot) -> bool {
|
||||
match worker.state {
|
||||
WorkerRuntimeState::Disabled | WorkerRuntimeState::Standby => true,
|
||||
WorkerRuntimeState::Running => worker.consecutive_failures == 0,
|
||||
WorkerRuntimeState::AcquiringLease | WorkerRuntimeState::Idle => {
|
||||
worker.consecutive_failures == 0 && worker.last_success_at.is_some()
|
||||
}
|
||||
WorkerRuntimeState::Starting
|
||||
| WorkerRuntimeState::BackingOff
|
||||
| WorkerRuntimeState::Stopped => false,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! 客户端与管理端 HTTP 协议 adapter。
|
||||
//!
|
||||
//! 本 crate 只负责请求解码、Core/Admin 调用和 HTTP/WS/SSE delivery。
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr as _;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
|
||||
use axum::routing::get;
|
||||
use gateway_admin::AdminServices;
|
||||
use gateway_core::engine::execution::ExecutionService;
|
||||
use gateway_core::health::{HealthProbe, WorkerHealthSource};
|
||||
use gateway_core::lifecycle::ConnectionLifecycle;
|
||||
use serde::Deserialize;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use url::Url;
|
||||
|
||||
const WEB_DIST_ENV: &str = "CPR_WEB_DIST_DIR";
|
||||
|
||||
use crate::health::HealthStatus;
|
||||
use crate::openai::service::OpenAiService;
|
||||
|
||||
pub mod admin;
|
||||
mod health;
|
||||
pub mod openai;
|
||||
|
||||
/// API-owned HTTP 与静态资源配置。
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ApiConfig {
|
||||
pub asset_directory: PathBuf,
|
||||
pub cors_allowed_origins: Vec<String>,
|
||||
pub request_timeout_seconds: Option<u64>,
|
||||
pub request_id_header: String,
|
||||
}
|
||||
|
||||
impl ApiConfig {
|
||||
/// 解析静态资源相对路径并校验全部 HTTP 配置。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 路径为空、origin/header 非法或 timeout 为零时返回脱敏错误。
|
||||
pub fn resolve_and_validate(&mut self, source_dir: &Path) -> Result<(), ApiConfigError> {
|
||||
match std::env::var(WEB_DIST_ENV) {
|
||||
Ok(value) if value.trim().is_empty() => {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
Ok(value) => self.asset_directory = PathBuf::from(value),
|
||||
Err(std::env::VarError::NotPresent) => {}
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
}
|
||||
if self.asset_directory.as_os_str().is_empty() {
|
||||
return Err(ApiConfigError::InvalidAssetDirectory);
|
||||
}
|
||||
if self.asset_directory.is_relative() {
|
||||
self.asset_directory = source_dir.join(&self.asset_directory);
|
||||
}
|
||||
if self.request_timeout_seconds == Some(0) {
|
||||
return Err(ApiConfigError::InvalidRequestTimeout);
|
||||
}
|
||||
let header = HeaderName::from_str(&self.request_id_header)
|
||||
.map_err(|_| ApiConfigError::InvalidRequestIdHeader)?;
|
||||
self.request_id_header = header.as_str().to_owned();
|
||||
|
||||
let mut origins = Vec::with_capacity(self.cors_allowed_origins.len());
|
||||
let mut unique = BTreeSet::new();
|
||||
for origin in &self.cors_allowed_origins {
|
||||
let origin = validate_origin(origin)?;
|
||||
if !unique.insert(origin.clone()) {
|
||||
return Err(ApiConfigError::DuplicateCorsOrigin);
|
||||
}
|
||||
origins.push(origin);
|
||||
}
|
||||
self.cors_allowed_origins = origins;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// API 配置非法的稳定分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ApiConfigError {
|
||||
#[error("API asset directory is invalid")]
|
||||
InvalidAssetDirectory,
|
||||
#[error("API CORS origin is invalid")]
|
||||
InvalidCorsOrigin,
|
||||
#[error("API CORS origin is duplicated")]
|
||||
DuplicateCorsOrigin,
|
||||
#[error("API request timeout is invalid")]
|
||||
InvalidRequestTimeout,
|
||||
#[error("API request ID header is invalid")]
|
||||
InvalidRequestIdHeader,
|
||||
}
|
||||
|
||||
/// 完成组装的唯一 API router。
|
||||
pub struct ApiBundle {
|
||||
router: Router,
|
||||
}
|
||||
|
||||
impl ApiBundle {
|
||||
pub fn router(self) -> Router {
|
||||
self.router
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装客户端、管理端、健康检查和静态资源路由。
|
||||
pub fn initialize(
|
||||
mut config: ApiConfig,
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
admin: AdminServices,
|
||||
probes: Vec<Arc<dyn HealthProbe>>,
|
||||
worker_health: Arc<dyn WorkerHealthSource>,
|
||||
lifecycle: Arc<dyn ConnectionLifecycle>,
|
||||
) -> Result<ApiBundle, ApiError> {
|
||||
config
|
||||
.resolve_and_validate(Path::new("."))
|
||||
.map_err(ApiError::Config)?;
|
||||
let request_id_header = HeaderName::from_str(&config.request_id_header)
|
||||
.map_err(|_| ApiError::Config(ApiConfigError::InvalidRequestIdHeader))?;
|
||||
let state = ApiState {
|
||||
admin,
|
||||
openai: OpenAiService::new(execution, lifecycle),
|
||||
health: HealthStatus::new(probes, worker_health),
|
||||
};
|
||||
let index = config.asset_directory.join("index.html");
|
||||
let mut router = Router::new()
|
||||
.route("/healthz", get(health::healthz))
|
||||
.merge(openai::router::router())
|
||||
.merge(admin::router::<ApiState>())
|
||||
.fallback_service(ServeDir::new(config.asset_directory).fallback(ServeFile::new(index)));
|
||||
if !config.cors_allowed_origins.is_empty() {
|
||||
let origins = config
|
||||
.cors_allowed_origins
|
||||
.iter()
|
||||
.map(|origin| {
|
||||
HeaderValue::from_str(origin)
|
||||
.map_err(|_| ApiError::Config(ApiConfigError::InvalidCorsOrigin))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
// credentials 模式禁止通配 allow_headers;显式列出鉴权与内容协商头。
|
||||
router = router.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderName::from_static("x-api-key"),
|
||||
request_id_header.clone(),
|
||||
])
|
||||
.allow_credentials(true),
|
||||
);
|
||||
}
|
||||
if let Some(seconds) = config.request_timeout_seconds {
|
||||
router = router.layer(TimeoutLayer::with_status_code(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
Duration::from_secs(seconds),
|
||||
));
|
||||
}
|
||||
let router = router
|
||||
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
|
||||
.layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
Ok(ApiBundle { router })
|
||||
}
|
||||
|
||||
/// API 初始化失败的脱敏分类。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error(transparent)]
|
||||
Config(ApiConfigError),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApiState {
|
||||
admin: AdminServices,
|
||||
openai: OpenAiService,
|
||||
health: HealthStatus,
|
||||
}
|
||||
|
||||
impl ApiState {
|
||||
#[must_use]
|
||||
pub(crate) const fn openai(&self) -> &OpenAiService {
|
||||
&self.openai
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) const fn health(&self) -> &HealthStatus {
|
||||
&self.health
|
||||
}
|
||||
}
|
||||
|
||||
impl admin::AdminSessionState for ApiState {
|
||||
fn admin_services(&self) -> &AdminServices {
|
||||
&self.admin
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_origin(raw: &str) -> Result<String, ApiConfigError> {
|
||||
let url = Url::parse(raw).map_err(|_| ApiConfigError::InvalidCorsOrigin)?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.path() != "/"
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err(ApiConfigError::InvalidCorsOrigin);
|
||||
}
|
||||
let origin = url.origin().ascii_serialization();
|
||||
HeaderValue::from_str(&origin).map_err(|_| ApiConfigError::InvalidCorsOrigin)?;
|
||||
Ok(origin)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! OpenAI 客户端协议的 Bearer API key 认证。
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, header::AUTHORIZATION},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::engine::execution::AuthenticatedClient;
|
||||
|
||||
use super::{
|
||||
error::{missing_client_api_key_response, runtime_unavailable_response},
|
||||
service::OpenAiService,
|
||||
};
|
||||
|
||||
/// Client API key 鉴权失败原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientApiKeyAuthError {
|
||||
/// 缺失 Authorization 头。
|
||||
MissingAuthorization,
|
||||
/// Authorization 头不是合法的 Bearer token。
|
||||
MalformedAuthorization,
|
||||
/// Bearer token 不是 client API key 格式。
|
||||
InvalidKeyFormat,
|
||||
/// Key 不存在、已禁用或 wire 格式无效。
|
||||
InvalidKey,
|
||||
/// RuntimeSnapshot 一致性保护暂停接收新请求。
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
impl ClientApiKeyAuthError {
|
||||
/// 返回可用于日志和指标的稳定失败原因。
|
||||
#[must_use]
|
||||
pub const fn reason(self) -> &'static str {
|
||||
match self {
|
||||
Self::MissingAuthorization => "missing_authorization",
|
||||
Self::MalformedAuthorization => "malformed_authorization",
|
||||
Self::InvalidKeyFormat => "invalid_key_format",
|
||||
Self::InvalidKey => "invalid_key",
|
||||
Self::RuntimeUnavailable => "runtime_unavailable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求头提取 Bearer Client API key。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Header 缺失、Bearer 语法错误或不是网关 Client Key 前缀时返回稳定错误。
|
||||
pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAuthError> {
|
||||
let raw = headers
|
||||
.get(AUTHORIZATION)
|
||||
.ok_or(ClientApiKeyAuthError::MissingAuthorization)?
|
||||
.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
|
||||
let token = raw
|
||||
.strip_prefix("Bearer ")
|
||||
.ok_or(ClientApiKeyAuthError::MalformedAuthorization)?
|
||||
.trim();
|
||||
if token.is_empty() {
|
||||
return Err(ClientApiKeyAuthError::MalformedAuthorization);
|
||||
}
|
||||
if !token.starts_with("sk_") {
|
||||
return Err(ClientApiKeyAuthError::InvalidKeyFormat);
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub(crate) fn authenticate_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
|
||||
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
|
||||
}
|
||||
|
||||
pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response {
|
||||
log_client_api_key_auth_failure(error);
|
||||
if error == ClientApiKeyAuthError::RuntimeUnavailable {
|
||||
runtime_unavailable_response().into_response()
|
||||
} else {
|
||||
missing_client_api_key_response().into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
|
||||
match error {
|
||||
ClientApiKeyAuthError::RuntimeUnavailable => {
|
||||
tracing::warn!(
|
||||
auth_failure = error.reason(),
|
||||
"Client API key authorization failed"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
auth_failure = error.reason(),
|
||||
"Client API key authorization failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! OpenAI 客户端协议的稳定错误响应。
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{EngineError, UpstreamSendState},
|
||||
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::responses::ProtocolErrorBody;
|
||||
|
||||
/// OpenAI 风格错误响应。
|
||||
pub fn openai_error_response(
|
||||
status: StatusCode,
|
||||
message: &str,
|
||||
error_type: &str,
|
||||
code: &str,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
(
|
||||
status,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": error_type,
|
||||
"code": code
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 严格协议 decoder/encoder 返回的安全错误 body。
|
||||
pub fn protocol_error_response(status: StatusCode, body: ProtocolErrorBody) -> Response {
|
||||
(status, Json(body.into_value())).into_response()
|
||||
}
|
||||
|
||||
/// 下游 Client API Key 无效。
|
||||
pub fn missing_client_api_key_response() -> (StatusCode, Json<Value>) {
|
||||
openai_error_response(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Missing or invalid API key",
|
||||
"invalid_request_error",
|
||||
"invalid_api_key",
|
||||
)
|
||||
}
|
||||
|
||||
/// RuntimeSnapshot 当前不允许接收新的配置依赖请求。
|
||||
pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
|
||||
openai_error_response(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"Gateway runtime configuration is temporarily unavailable",
|
||||
"server_error",
|
||||
"runtime_configuration_unavailable",
|
||||
)
|
||||
}
|
||||
|
||||
/// 对外模型不存在。
|
||||
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
|
||||
openai_error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"The requested model does not exist or is not available",
|
||||
"invalid_request_error",
|
||||
"model_not_found",
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 core Engine 错误收敛为稳定 Gateway 分类。
|
||||
#[must_use]
|
||||
pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
match error {
|
||||
EngineError::Provider(error)
|
||||
if error.kind() == ProviderErrorKind::Unavailable
|
||||
&& error.send_state() == UpstreamSendState::NotSent =>
|
||||
{
|
||||
let gateway = GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
);
|
||||
match error.client_visible_upstream_error() {
|
||||
Some(upstream) => gateway.with_client_visible_upstream_error(upstream.clone()),
|
||||
None => gateway,
|
||||
}
|
||||
}
|
||||
EngineError::Provider(error) => GatewayError::from_provider(error),
|
||||
EngineError::Cancelled => {
|
||||
GatewayError::new(GatewayErrorKind::Cancelled, "request was cancelled")
|
||||
}
|
||||
EngineError::Deadline => {
|
||||
GatewayError::new(GatewayErrorKind::Timeout, "gateway request timed out")
|
||||
}
|
||||
EngineError::ProviderNotRegistered { .. } | EngineError::EmptyRoutingPlan => {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
)
|
||||
}
|
||||
EngineError::Store(_)
|
||||
| EngineError::ProviderMetadataMismatch
|
||||
| EngineError::ContinuationPinMismatch
|
||||
| EngineError::RequiredAccountMismatch
|
||||
| EngineError::DownstreamCommitRequired
|
||||
| EngineError::NoActiveAttempt
|
||||
| EngineError::InvalidDeliveryState => {
|
||||
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gateway 错误的 OpenAI HTTP 表达。
|
||||
pub fn gateway_error_response(error: &GatewayError) -> Response {
|
||||
let (status, default_type, default_code) = gateway_error_contract(error.kind());
|
||||
openai_error_response(
|
||||
status,
|
||||
error.client_message(),
|
||||
error.client_error_type().unwrap_or(default_type),
|
||||
error.client_error_code().unwrap_or(default_code),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// 在下游尚未提交时优先交付 Provider 的原始 HTTP 失败响应。
|
||||
pub fn engine_error_response(error: &EngineError) -> Response {
|
||||
if let EngineError::Provider(error) = error
|
||||
&& let Some(upstream) = error.client_visible_upstream_response()
|
||||
&& let Ok(status) = StatusCode::from_u16(upstream.status())
|
||||
{
|
||||
let mut response = Response::new(Body::from(upstream.body().clone()));
|
||||
*response.status_mut() = status;
|
||||
if let Some(content_type) = upstream.content_type()
|
||||
&& let Ok(content_type) = HeaderValue::from_bytes(content_type)
|
||||
{
|
||||
response.headers_mut().insert(CONTENT_TYPE, content_type);
|
||||
}
|
||||
for header in upstream.headers() {
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
gateway_error_response(&gateway_error_from_engine(error))
|
||||
}
|
||||
|
||||
/// Gateway 错误稳定映射,供 HTTP、SSE 和 WebSocket 共用。
|
||||
#[must_use]
|
||||
pub const fn gateway_error_contract(
|
||||
kind: GatewayErrorKind,
|
||||
) -> (StatusCode, &'static str, &'static str) {
|
||||
match kind {
|
||||
GatewayErrorKind::InvalidRequest => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
"invalid_request",
|
||||
),
|
||||
GatewayErrorKind::Unsupported => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
"unsupported_capability",
|
||||
),
|
||||
GatewayErrorKind::Unauthorized => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid_request_error",
|
||||
"invalid_api_key",
|
||||
),
|
||||
GatewayErrorKind::PolicyDenied => (
|
||||
StatusCode::FORBIDDEN,
|
||||
"invalid_request_error",
|
||||
"policy_denied",
|
||||
),
|
||||
GatewayErrorKind::ModelNotFound => (
|
||||
StatusCode::NOT_FOUND,
|
||||
"invalid_request_error",
|
||||
"model_not_found",
|
||||
),
|
||||
GatewayErrorKind::NoAvailableProvider => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"no_available_provider",
|
||||
),
|
||||
GatewayErrorKind::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"rate_limit_exceeded",
|
||||
),
|
||||
GatewayErrorKind::UpstreamUnavailable => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"server_error",
|
||||
"upstream_unavailable",
|
||||
),
|
||||
GatewayErrorKind::Timeout => (
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"server_error",
|
||||
"request_timeout",
|
||||
),
|
||||
GatewayErrorKind::Cancelled => (
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
"server_error",
|
||||
"request_cancelled",
|
||||
),
|
||||
GatewayErrorKind::Internal => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"internal_error",
|
||||
),
|
||||
_ => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"server_error",
|
||||
"internal_error",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! OpenAI 客户端协议 adapter。
|
||||
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
pub mod models;
|
||||
pub mod responses;
|
||||
pub mod router;
|
||||
pub(crate) mod service;
|
||||
@@ -0,0 +1,256 @@
|
||||
//! OpenAI 模型目录 HTTP adapter。
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::ApiState;
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::model_not_found_response,
|
||||
};
|
||||
|
||||
const MODEL_CREATED_TIMESTAMP: i64 = 1_700_000_000;
|
||||
const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the user's instructions and use the available tools to complete software engineering tasks. Inspect relevant files before editing, preserve unrelated changes, and verify the result.";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsQuery {
|
||||
client_version: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /v1/models`。Codex 携带 `client_version` 时返回其专用目录合同。
|
||||
pub(crate) async fn models(
|
||||
State(state): State<ApiState>,
|
||||
Query(query): Query<ModelsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
|
||||
if query
|
||||
.client_version
|
||||
.as_deref()
|
||||
.is_some_and(|version| !version.trim().is_empty())
|
||||
{
|
||||
let models = service
|
||||
.public_model_profiles(&client)
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"models": models,
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let data = service
|
||||
.public_models(&client)
|
||||
.into_iter()
|
||||
.map(|model| openai_model_json(&model))
|
||||
.collect::<Vec<_>>();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"object": "list",
|
||||
"data": data,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/{model_id}`。
|
||||
pub(crate) async fn model_detail(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Path(model_id): Path<String>,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
return model_not_found_response().into_response();
|
||||
};
|
||||
if !service.contains_public_model(&client, &public_model) {
|
||||
return model_not_found_response().into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(openai_model_json(public_model.as_str())),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/catalog`。
|
||||
///
|
||||
/// 新架构的目录由 core 按客户端 Provider 范围裁剪;这里不读取或重建任一
|
||||
/// Provider 的 wire catalog,只恢复 Codex 客户端依赖的稳定展示合同。
|
||||
pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let models = service
|
||||
.public_models(&client)
|
||||
.into_iter()
|
||||
.map(|model| catalog_model_json(model.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(StatusCode::OK, Json(models)).into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/{model_id}/info`。
|
||||
pub(crate) async fn model_info(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Path(model_id): Path<String>,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
return model_not_found_response().into_response();
|
||||
};
|
||||
if !service.contains_public_model(&client, &public_model) {
|
||||
return model_not_found_response().into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(catalog_model_json(public_model.as_str())),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn openai_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"object": "model",
|
||||
"created": MODEL_CREATED_TIMESTAMP,
|
||||
"owned_by": "gateway",
|
||||
})
|
||||
}
|
||||
|
||||
fn catalog_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"displayName": id,
|
||||
"description": "",
|
||||
"isDefault": false,
|
||||
"supportedReasoningEfforts": [],
|
||||
"defaultReasoningEffort": "",
|
||||
"inputModalities": ["text"],
|
||||
"outputModalities": ["text"],
|
||||
"supportsPersonality": false,
|
||||
"upgrade": Value::Null,
|
||||
"source": "gateway",
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
let id = profile.model().as_str();
|
||||
let presentation = profile.presentation();
|
||||
let reasoning_levels = presentation
|
||||
.supported_reasoning_efforts()
|
||||
.iter()
|
||||
.map(|effort| {
|
||||
json!({
|
||||
"effort": effort,
|
||||
"description": reasoning_effort_description(effort),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reasoning_supported = presentation
|
||||
.supported_reasoning_efforts()
|
||||
.iter()
|
||||
.any(|effort| effort != "none");
|
||||
let context_window = presentation.context_window_tokens();
|
||||
let input_modalities = if presentation.image_input() {
|
||||
vec!["text", "image"]
|
||||
} else {
|
||||
vec!["text"]
|
||||
};
|
||||
let additional_speed_tiers = presentation
|
||||
.service_tiers()
|
||||
.iter()
|
||||
.filter_map(|tier| tier.speed_tier())
|
||||
.collect::<Vec<_>>();
|
||||
let service_tiers = presentation
|
||||
.service_tiers()
|
||||
.iter()
|
||||
.map(|tier| {
|
||||
json!({
|
||||
"id": tier.id(),
|
||||
"name": tier.name(),
|
||||
"description": tier.description(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
json!({
|
||||
"slug": id,
|
||||
"display_name": presentation.display_name().unwrap_or(id),
|
||||
"description": presentation.description(),
|
||||
"default_reasoning_level": presentation.default_reasoning_effort(),
|
||||
"supported_reasoning_levels": reasoning_levels,
|
||||
"shell_type": "shell_command",
|
||||
"visibility": if presentation.hidden() { "hide" } else { "list" },
|
||||
"supported_in_api": true,
|
||||
"priority": index.saturating_add(1),
|
||||
"additional_speed_tiers": additional_speed_tiers,
|
||||
"service_tiers": service_tiers,
|
||||
"availability_nux": Value::Null,
|
||||
"upgrade": Value::Null,
|
||||
"base_instructions": CODEX_BASE_INSTRUCTIONS,
|
||||
"model_messages": Value::Null,
|
||||
"include_skills_usage_instructions": false,
|
||||
"supports_reasoning_summary_parameter": reasoning_supported,
|
||||
"default_reasoning_summary": "auto",
|
||||
"support_verbosity": presentation.verbosity(),
|
||||
"default_verbosity": Value::Null,
|
||||
"apply_patch_tool_type": presentation.agent_tools().then_some("freeform"),
|
||||
"web_search_tool_type": "text",
|
||||
"truncation_policy": { "mode": "tokens", "limit": 10_000 },
|
||||
"supports_parallel_tool_calls": presentation.parallel_tool_calls(),
|
||||
"supports_image_detail_original": presentation.image_detail_original(),
|
||||
"context_window": context_window,
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": input_modalities,
|
||||
"supports_search_tool": presentation.search_tool(),
|
||||
"use_responses_lite": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn reasoning_effort_description(effort: &str) -> &'static str {
|
||||
match effort {
|
||||
"none" => "No reasoning",
|
||||
"minimal" => "Minimal reasoning",
|
||||
"low" => "Fast responses with lighter reasoning",
|
||||
"medium" => "Balances speed and reasoning depth for everyday tasks",
|
||||
"high" => "Greater reasoning depth for complex problems",
|
||||
"xhigh" => "Extra high reasoning depth for complex problems",
|
||||
"max" => "Maximum reasoning depth for the hardest problems",
|
||||
_ => "Provider-supported reasoning level",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! OpenAI Responses adapter 的稳定错误 contract。
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// OpenAI 风格的协议错误 JSON。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ProtocolErrorBody {
|
||||
/// 错误对象。
|
||||
pub error: ProtocolError,
|
||||
}
|
||||
|
||||
/// 不包含请求正文或 prompt 的错误对象。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ProtocolError {
|
||||
/// OpenAI 错误类别。
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
/// 稳定机器码。
|
||||
pub code: &'static str,
|
||||
/// 安全的可读信息。
|
||||
pub message: String,
|
||||
/// 出错字段;不包含字段值。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub param: Option<String>,
|
||||
}
|
||||
|
||||
impl ProtocolErrorBody {
|
||||
/// 序列化为 JSON value。
|
||||
#[must_use]
|
||||
pub fn into_value(self) -> Value {
|
||||
serde_json::json!({ "error": self.error })
|
||||
}
|
||||
}
|
||||
|
||||
/// Responses 请求解码错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum RequestDecodeError {
|
||||
/// 请求不是合法 JSON。
|
||||
#[error("request body must be valid JSON")]
|
||||
MalformedJson,
|
||||
/// 顶层不是 object。
|
||||
#[error("request body must be a JSON object")]
|
||||
ExpectedObject,
|
||||
/// 缺少必填字段。
|
||||
#[error("required field `{field}` is missing")]
|
||||
MissingField {
|
||||
/// 字段路径。
|
||||
field: String,
|
||||
},
|
||||
/// 字段类型错误。
|
||||
#[error("field `{field}` has an invalid type; expected {expected}")]
|
||||
InvalidType {
|
||||
/// 字段路径。
|
||||
field: String,
|
||||
/// 安全的期望类型。
|
||||
expected: &'static str,
|
||||
},
|
||||
/// 字段为空。
|
||||
#[error("field `{field}` must not be empty")]
|
||||
EmptyField {
|
||||
/// 字段路径。
|
||||
field: String,
|
||||
},
|
||||
/// 字段值不满足稳定约束。
|
||||
#[error("field `{field}` has an invalid value")]
|
||||
InvalidValue {
|
||||
/// 字段路径;不保存原始值。
|
||||
field: String,
|
||||
},
|
||||
/// 未知字段。
|
||||
#[error("unknown field `{field}`")]
|
||||
UnknownField {
|
||||
/// 字段路径;不保存字段值。
|
||||
field: String,
|
||||
},
|
||||
/// 已知但尚未实现的语义。
|
||||
#[error("field `{field}` is not supported by this gateway")]
|
||||
UnsupportedField {
|
||||
/// 字段路径。
|
||||
field: String,
|
||||
},
|
||||
/// Core operation 拒绝规范化后的字段。
|
||||
#[error("field `{field}` violates the canonical operation contract")]
|
||||
CanonicalContract {
|
||||
/// 字段路径。
|
||||
field: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RequestDecodeError {
|
||||
/// 转换为 OpenAI 风格且不泄露正文的错误 payload。
|
||||
#[must_use]
|
||||
pub fn protocol_body(&self) -> ProtocolErrorBody {
|
||||
let (code, message, param) = match self {
|
||||
Self::MalformedJson => (
|
||||
"invalid_json",
|
||||
"Request body must be valid JSON.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::ExpectedObject => (
|
||||
"invalid_request_body",
|
||||
"Request body must be a JSON object.".to_owned(),
|
||||
None,
|
||||
),
|
||||
Self::MissingField { field } => (
|
||||
"missing_required_parameter",
|
||||
format!("Required parameter `{field}` is missing."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
Self::InvalidType { field, expected } => (
|
||||
"invalid_type",
|
||||
format!("Parameter `{field}` must be {expected}."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
Self::EmptyField { field } => (
|
||||
"invalid_value",
|
||||
format!("Parameter `{field}` must not be empty."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
Self::InvalidValue { field } | Self::CanonicalContract { field } => (
|
||||
"invalid_value",
|
||||
format!("Parameter `{field}` has an invalid value."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
Self::UnknownField { field } => (
|
||||
"unknown_parameter",
|
||||
format!("Unknown parameter `{field}`."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
Self::UnsupportedField { field } => (
|
||||
"unsupported_parameter",
|
||||
format!("Parameter `{field}` is not supported by this gateway."),
|
||||
Some(field.clone()),
|
||||
),
|
||||
};
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "invalid_request_error",
|
||||
code,
|
||||
message,
|
||||
param,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenAI Responses 原生 wire 转发错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ResponseEncodeError {
|
||||
/// 协议原生流缺少可返回的终态 response object。
|
||||
#[error("responses wire stream has no terminal response")]
|
||||
MissingWireTerminal,
|
||||
/// 终态 response object 无法序列化为完整 HTTP JSON 响应。
|
||||
#[error("responses wire terminal serialization failed")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl ResponseEncodeError {
|
||||
/// 转换为不包含生成内容的 OpenAI 风格错误 payload。
|
||||
#[must_use]
|
||||
pub fn protocol_body(&self) -> ProtocolErrorBody {
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "server_error",
|
||||
code: "invalid_upstream_response",
|
||||
message: "The gateway could not forward the upstream response.".to_owned(),
|
||||
param: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//! OpenAI Responses HTTP 与 SSE adapter。
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{
|
||||
HeaderMap, HeaderName, HeaderValue, StatusCode,
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, USER_AGENT},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::stream;
|
||||
use gateway_core::engine::CommitRequirement;
|
||||
use gateway_core::engine::execution::{ClientTransport, ExecutionSession, StartedExecution};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::lifecycle::ConnectionGuard;
|
||||
use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_with_id};
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{
|
||||
engine_error_response, gateway_error_contract, gateway_error_from_engine,
|
||||
gateway_error_response, protocol_error_response, runtime_unavailable_response,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
|
||||
request::{decode_request_with_headers, decode_review_request_with_headers},
|
||||
};
|
||||
|
||||
/// `POST /v1/responses`。
|
||||
pub(crate) async fn responses(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_responses(state, connect_info, headers, body, false).await
|
||||
}
|
||||
|
||||
/// `POST /v1/responses/review`。
|
||||
pub(crate) async fn review_responses(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_responses(state, connect_info, headers, body, true).await
|
||||
}
|
||||
|
||||
async fn handle_responses(
|
||||
state: ApiState,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
review: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let decoded = match if review {
|
||||
decode_review_request_with_headers(&body, &headers)
|
||||
} else {
|
||||
decode_request_with_headers(&body, &headers)
|
||||
} {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => {
|
||||
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
|
||||
}
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
);
|
||||
let decoded = decoded.with_client_context(client_ip, user_agent);
|
||||
let streaming = decoded.metadata().stream();
|
||||
let connection_guard = if streaming {
|
||||
match service.try_register_connection() {
|
||||
Ok(guard) => Some(guard),
|
||||
Err(_) => return runtime_unavailable_response().into_response(),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let started = match service
|
||||
.start_response(
|
||||
client,
|
||||
decoded,
|
||||
if streaming {
|
||||
ClientTransport::HttpSse
|
||||
} else {
|
||||
ClientTransport::HttpJson
|
||||
},
|
||||
if review {
|
||||
"/v1/responses/review"
|
||||
} else {
|
||||
"/v1/responses"
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let StartedExecution {
|
||||
stream, session, ..
|
||||
} = started;
|
||||
if stream {
|
||||
stream_execution_response(session, connection_guard).await
|
||||
} else {
|
||||
drop(connection_guard);
|
||||
collect_execution_response(session).await
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 socket 与标准转发头提取旧 Usage 页面使用的诊断事实。
|
||||
pub(super) fn request_client_context(
|
||||
headers: &HeaderMap,
|
||||
peer_address: Option<SocketAddr>,
|
||||
) -> (Option<IpAddr>, Option<String>) {
|
||||
let client_ip = ["cf-connecting-ip", "x-real-ip"]
|
||||
.into_iter()
|
||||
.find_map(|name| header_ip(headers, name))
|
||||
.or_else(|| forwarded_client_ip(headers))
|
||||
.or_else(|| peer_address.map(|address| address.ip()));
|
||||
let user_agent = headers
|
||||
.get(USER_AGENT)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
(client_ip, user_agent)
|
||||
}
|
||||
|
||||
fn header_ip(headers: &HeaderMap, name: &str) -> Option<IpAddr> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.and_then(|value| value.parse().ok())
|
||||
}
|
||||
|
||||
fn forwarded_client_ip(headers: &HeaderMap) -> Option<IpAddr> {
|
||||
let addresses = headers
|
||||
.get("x-forwarded-for")?
|
||||
.to_str()
|
||||
.ok()?
|
||||
.split(',')
|
||||
.filter_map(|value| value.trim().parse::<IpAddr>().ok())
|
||||
.collect::<Vec<_>>();
|
||||
addresses
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|address| !is_private_or_loopback(*address))
|
||||
.or_else(|| addresses.first().copied())
|
||||
}
|
||||
|
||||
const fn is_private_or_loopback(address: IpAddr) -> bool {
|
||||
match address {
|
||||
IpAddr::V4(address) => address.is_private() || address.is_loopback(),
|
||||
IpAddr::V6(address) => address.is_unique_local() || address.is_loopback(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 编码完整 canonical event 集合,并在完整 JSON 成功后提交下游。
|
||||
pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> Response {
|
||||
let mut execution = PendingExecution::new(session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
|
||||
let encoded = match encode_collected_events(&events) {
|
||||
Ok(encoded) => encoded,
|
||||
Err(error) => {
|
||||
let response =
|
||||
protocol_error_response(StatusCode::INTERNAL_SERVER_ERROR, error.protocol_body());
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = json_body_response(encoded, &response_headers);
|
||||
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
if let Err(error) = session
|
||||
.commit_downstream(Some(StatusCode::OK.as_u16()))
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
execution.cancel_and_finalize().await;
|
||||
return internal_gateway_response("gateway response was not finalized after commit");
|
||||
}
|
||||
execution.disarm();
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum BufferedResponseEncodeError {
|
||||
Canonical(ResponseEncodeError),
|
||||
Json,
|
||||
}
|
||||
|
||||
impl BufferedResponseEncodeError {
|
||||
fn protocol_body(&self) -> ProtocolErrorBody {
|
||||
match self {
|
||||
Self::Canonical(error) => error.protocol_body(),
|
||||
Self::Json => ResponseEncodeError::Serialization.protocol_body(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_collected_events(
|
||||
events: &[ProviderEvent],
|
||||
) -> Result<Vec<u8>, BufferedResponseEncodeError> {
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
for event in events {
|
||||
encoder.push_sse(event);
|
||||
}
|
||||
let response = encoder
|
||||
.finish()
|
||||
.map_err(BufferedResponseEncodeError::Canonical)?;
|
||||
serde_json::to_vec(&response).map_err(|_| BufferedResponseEncodeError::Json)
|
||||
}
|
||||
|
||||
fn json_body_response(encoded: Vec<u8>, response_headers: &[ProviderResponseHeader]) -> Response {
|
||||
let mut response = Response::new(Body::from(encoded));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn apply_response_headers(
|
||||
mut response: Response,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let connection_options = super::response_connection_options(response_headers);
|
||||
for header in response_headers {
|
||||
if !super::response_header_is_forwardable(header.name(), &connection_options) {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
/// 编码首个 SSE frame 后提交下游,再持续驱动同一执行会话。
|
||||
pub async fn stream_execution_response(
|
||||
session: Box<dyn ExecutionSession>,
|
||||
connection_guard: Option<Box<dyn ConnectionGuard>>,
|
||||
) -> Response {
|
||||
let mut execution = PendingExecution::new(session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
let first = match session.next_event().await {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => {
|
||||
let response = gateway_error_response(&GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended before its first event",
|
||||
));
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
let first_requirement = first.commit_requirement();
|
||||
let first_events = first.into_provider_events();
|
||||
if first_requirement != CommitRequirement::CommitBeforeDelivery {
|
||||
let response = internal_gateway_response("gateway first event did not require commit");
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let mut frames = Vec::new();
|
||||
for event in &first_events {
|
||||
frames.extend(encoder.push_sse(event));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
let response = internal_gateway_response("gateway commit batch encoded no output");
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
if let Err(error) = session
|
||||
.commit_downstream(Some(StatusCode::OK.as_u16()))
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
let Some(session) = execution.into_session() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
let mut state = ResponsesStreamState::new(session, encoder, frames, connection_guard);
|
||||
if state.encoder.is_completed() {
|
||||
state.finish_completed(Vec::new()).await;
|
||||
}
|
||||
let body = Body::from_stream(stream::unfold(state, |mut state| async move {
|
||||
loop {
|
||||
if let Some(chunk) = state.pending.pop_front() {
|
||||
return Some((Ok::<Bytes, Infallible>(chunk), state));
|
||||
}
|
||||
if state.output_finished {
|
||||
return None;
|
||||
}
|
||||
state.advance().await;
|
||||
}
|
||||
}));
|
||||
event_stream_response(body, &response_headers)
|
||||
}
|
||||
|
||||
fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]) -> Response {
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn internal_gateway_response(message: &'static str) -> Response {
|
||||
gateway_error_response(&GatewayError::new(GatewayErrorKind::Internal, message))
|
||||
}
|
||||
|
||||
pub(super) struct PendingExecution {
|
||||
session: Option<Box<dyn ExecutionSession>>,
|
||||
}
|
||||
|
||||
impl PendingExecution {
|
||||
pub(super) fn new(session: Box<dyn ExecutionSession>) -> Self {
|
||||
Self {
|
||||
session: Some(session),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn session_mut(&mut self) -> Option<&mut (dyn ExecutionSession + 'static)> {
|
||||
self.session.as_deref_mut()
|
||||
}
|
||||
|
||||
async fn cancel_and_finalize(&mut self) {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.cancel();
|
||||
if !session.is_finalized() {
|
||||
let _ = session.next_event().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_response_status(&mut self, response: Response) -> Response {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
let _ = session
|
||||
.record_client_status(response.status().as_u16())
|
||||
.await;
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.session = None;
|
||||
}
|
||||
|
||||
fn into_session(mut self) -> Option<Box<dyn ExecutionSession>> {
|
||||
self.session.take()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PendingExecution {
|
||||
fn drop(&mut self) {
|
||||
let Some(session) = self.session.take() else {
|
||||
return;
|
||||
};
|
||||
if session.is_finalized() {
|
||||
return;
|
||||
}
|
||||
session.cancel();
|
||||
detach_finalize(session);
|
||||
}
|
||||
}
|
||||
|
||||
struct ResponsesStreamState {
|
||||
session: Option<Box<dyn ExecutionSession>>,
|
||||
encoder: OpenAiResponsesEncoder,
|
||||
pending: VecDeque<Bytes>,
|
||||
output_finished: bool,
|
||||
execution_terminal: bool,
|
||||
_connection_guard: Option<Box<dyn ConnectionGuard>>,
|
||||
}
|
||||
|
||||
impl ResponsesStreamState {
|
||||
fn new(
|
||||
session: Box<dyn ExecutionSession>,
|
||||
encoder: OpenAiResponsesEncoder,
|
||||
initial_frames: Vec<Bytes>,
|
||||
connection_guard: Option<Box<dyn ConnectionGuard>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
session: Some(session),
|
||||
encoder,
|
||||
pending: initial_frames.into_iter().collect(),
|
||||
output_finished: false,
|
||||
execution_terminal: false,
|
||||
_connection_guard: connection_guard,
|
||||
}
|
||||
}
|
||||
|
||||
async fn advance(&mut self) {
|
||||
let next = match self.session.as_mut() {
|
||||
Some(session) => session.next_event().await,
|
||||
None => {
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response session is unavailable",
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match next {
|
||||
Ok(Some(event)) => {
|
||||
let requirement = event.commit_requirement();
|
||||
let events = event.into_provider_events();
|
||||
if requirement != CommitRequirement::AlreadyCommitted {
|
||||
self.cancel_execution().await;
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway requested another downstream commit",
|
||||
));
|
||||
} else {
|
||||
for event in events {
|
||||
self.push_event(event).await;
|
||||
if self.output_finished {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
self.execution_terminal = self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.is_finalized());
|
||||
if self.execution_terminal {
|
||||
self.pending
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
} else {
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended without finalizing the execution",
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.execution_terminal = self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.is_finalized());
|
||||
if self.encoder.has_wire_failure() {
|
||||
self.pending
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
} else {
|
||||
self.finish_with_gateway_error(gateway_error_from_engine(&error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn push_event(&mut self, event: ProviderEvent) {
|
||||
let frames = self.encoder.push_sse(&event);
|
||||
if self.encoder.is_completed() {
|
||||
self.finish_completed(frames).await;
|
||||
} else {
|
||||
self.pending.extend(frames);
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_completed(&mut self, frames: Vec<Bytes>) {
|
||||
let next = match self.session.as_mut() {
|
||||
Some(session) => session.next_event().await,
|
||||
None => {
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response session is unavailable",
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match next {
|
||||
Ok(None)
|
||||
if self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.is_finalized()) =>
|
||||
{
|
||||
self.pending.extend(frames);
|
||||
self.execution_terminal = true;
|
||||
self.pending
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
}
|
||||
Ok(None) => {
|
||||
self.cancel_execution().await;
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response was not finalized after its terminal event",
|
||||
));
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
self.cancel_execution().await;
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response continued after its terminal event",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
self.execution_terminal = self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.is_finalized());
|
||||
self.finish_with_gateway_error(gateway_error_from_engine(&error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_execution(&mut self) {
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
session.cancel();
|
||||
if !session.is_finalized() {
|
||||
let _ = session.next_event().await;
|
||||
}
|
||||
self.execution_terminal = session.is_finalized();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_with_gateway_error(&mut self, error: GatewayError) {
|
||||
let (_, default_type, default_code) = gateway_error_contract(error.kind());
|
||||
self.pending
|
||||
.push_back(Bytes::from(response_failed_sse_event_with_id(
|
||||
self.encoder.response_id(),
|
||||
error.client_error_type().unwrap_or(default_type),
|
||||
error.client_error_code().unwrap_or(default_code),
|
||||
error.client_message(),
|
||||
)));
|
||||
self.pending
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResponsesStreamState {
|
||||
fn drop(&mut self) {
|
||||
if self.execution_terminal {
|
||||
return;
|
||||
}
|
||||
let Some(session) = self.session.take() else {
|
||||
return;
|
||||
};
|
||||
session.cancel();
|
||||
// Body drop 后由 Core 继续状态机清理,HTTP body 生命周期不等待它。
|
||||
detach_finalize(session);
|
||||
}
|
||||
}
|
||||
|
||||
fn detach_finalize(session: Box<dyn ExecutionSession>) {
|
||||
let finalize = session.detach_finalize();
|
||||
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
|
||||
drop(runtime.spawn(finalize));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! OpenAI Responses 的透明 wire adapter 与 Core canonical facts 投影。
|
||||
|
||||
mod error;
|
||||
mod http;
|
||||
mod request;
|
||||
mod response;
|
||||
mod websocket;
|
||||
|
||||
pub use error::{ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
|
||||
pub use http::{collect_execution_response, stream_execution_response};
|
||||
pub(crate) use http::{responses, review_responses};
|
||||
pub use request::{
|
||||
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
|
||||
decode_request_with_headers,
|
||||
};
|
||||
pub use response::OpenAiResponsesEncoder;
|
||||
pub(crate) use websocket::responses_websocket;
|
||||
pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context};
|
||||
|
||||
use gateway_core::event::ProviderResponseHeader;
|
||||
|
||||
pub(super) fn response_connection_options(headers: &[ProviderResponseHeader]) -> Vec<String> {
|
||||
headers
|
||||
.iter()
|
||||
.filter(|header| header.name().trim().eq_ignore_ascii_case("connection"))
|
||||
.filter_map(|header| std::str::from_utf8(header.value()).ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[String]) -> bool {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if connection_options
|
||||
.iter()
|
||||
.any(|option| option.eq_ignore_ascii_case(&name))
|
||||
|| name.starts_with("sec-websocket-")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(
|
||||
name.as_str(),
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "content-length"
|
||||
| "content-type"
|
||||
| "content-encoding"
|
||||
| "authorization"
|
||||
| "x-api-key"
|
||||
| "www-authenticate"
|
||||
| "authentication-info"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "proxy-authentication-info"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "set-cookie"
|
||||
| "set-cookie2"
|
||||
| "chatgpt-account-id"
|
||||
| "chatgpt-organization-id"
|
||||
| "chatgpt-org-id"
|
||||
| "chatgpt-project-id"
|
||||
| "openai-organization"
|
||||
| "openai-project"
|
||||
| "x-openai-organization"
|
||||
| "x-openai-project"
|
||||
| "x-codex-installation-id"
|
||||
| "x-codex-turn-state"
|
||||
| "x-codex-turn-metadata"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界。
|
||||
|
||||
use std::{fmt, net::IpAddr};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
|
||||
use gateway_protocol::openai::{
|
||||
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::error::RequestDecodeError;
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const REVIEW_SUBAGENT: &str = "review";
|
||||
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
|
||||
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OpenAiRequestHeaders {
|
||||
turn_state: Option<String>,
|
||||
turn_metadata: Option<String>,
|
||||
beta_features: Option<String>,
|
||||
version: Option<String>,
|
||||
include_timing_metrics: Option<String>,
|
||||
codex_window_id: Option<String>,
|
||||
parent_thread_id: Option<String>,
|
||||
|
||||
conversation_id: Option<String>,
|
||||
session_id: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
client_request_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
|
||||
responses_lite: Option<String>,
|
||||
memgen_request: Option<String>,
|
||||
subagent: Option<String>,
|
||||
passthrough_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
impl OpenAiRequestHeaders {
|
||||
/// 提取 OpenAI/Codex 连接级请求头上下文。
|
||||
#[must_use]
|
||||
pub fn from_headers(headers: &HeaderMap) -> Self {
|
||||
Self {
|
||||
turn_state: header_string(headers, "x-codex-turn-state"),
|
||||
turn_metadata: header_string(headers, "x-codex-turn-metadata"),
|
||||
beta_features: header_string(headers, "x-codex-beta-features"),
|
||||
version: header_string(headers, "version"),
|
||||
include_timing_metrics: header_string(headers, "x-responsesapi-include-timing-metrics"),
|
||||
codex_window_id: header_string(headers, "x-codex-window-id"),
|
||||
parent_thread_id: header_string(headers, "x-codex-parent-thread-id"),
|
||||
conversation_id: header_string(headers, "conversation-id")
|
||||
.or_else(|| header_string(headers, "conversation_id")),
|
||||
session_id: header_string(headers, "session-id")
|
||||
.or_else(|| header_string(headers, "session_id")),
|
||||
thread_id: header_string(headers, "thread-id"),
|
||||
client_request_id: header_string(headers, "x-client-request-id"),
|
||||
turn_id: header_string(headers, "x-codex-turn-id"),
|
||||
responses_lite: header_string(headers, X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER),
|
||||
memgen_request: header_string(headers, X_OPENAI_MEMGEN_REQUEST_HEADER),
|
||||
subagent: header_string(headers, OPENAI_SUBAGENT_KEY),
|
||||
passthrough_headers: passthrough_headers(headers),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
|
||||
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
|
||||
inject_subagent_metadata(body, subagent);
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_context(&self, use_websocket: Option<bool>) -> Map<String, Value> {
|
||||
let mut context = Map::new();
|
||||
insert_protocol_context(&mut context, "turn_state", self.turn_state.as_ref());
|
||||
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
|
||||
insert_protocol_context(&mut context, "beta_features", self.beta_features.as_ref());
|
||||
insert_protocol_context(&mut context, "version", self.version.as_ref());
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"include_timing_metrics",
|
||||
self.include_timing_metrics.as_ref(),
|
||||
);
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"codex_window_id",
|
||||
self.codex_window_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"parent_thread_id",
|
||||
self.parent_thread_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"conversation_id",
|
||||
self.conversation_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(&mut context, "session_id", self.session_id.as_ref());
|
||||
insert_protocol_context(&mut context, "thread_id", self.thread_id.as_ref());
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"client_request_id",
|
||||
self.client_request_id.as_ref(),
|
||||
);
|
||||
insert_protocol_context(&mut context, "turn_id", self.turn_id.as_ref());
|
||||
insert_protocol_context(&mut context, "responses_lite", self.responses_lite.as_ref());
|
||||
insert_protocol_context(&mut context, "memgen_request", self.memgen_request.as_ref());
|
||||
if !self.passthrough_headers.is_empty() {
|
||||
context.insert(
|
||||
PASSTHROUGH_HEADERS_CONTEXT_KEY.to_owned(),
|
||||
Value::Array(self.passthrough_headers.clone()),
|
||||
);
|
||||
}
|
||||
if let Some(use_websocket) = use_websocket {
|
||||
context.insert("use_websocket".to_owned(), Value::Bool(use_websocket));
|
||||
}
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_protocol_context(context: &mut Map<String, Value>, field: &str, value: Option<&String>) {
|
||||
if let Some(value) = value {
|
||||
context.insert(field.to_owned(), Value::String(value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端声明的 continuation 意图。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ContinuationIntent {
|
||||
/// 不使用先前响应。
|
||||
None,
|
||||
/// 使用当前调用方可见的 OpenAI response ID。
|
||||
PreviousResponseId(String),
|
||||
}
|
||||
|
||||
impl ContinuationIntent {
|
||||
/// 返回是否请求了对话延续。
|
||||
#[must_use]
|
||||
pub const fn is_continuation(&self) -> bool {
|
||||
matches!(self, Self::PreviousResponseId(_))
|
||||
}
|
||||
|
||||
/// 返回待 history owner 解析的 response ID。
|
||||
#[must_use]
|
||||
pub fn previous_response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::PreviousResponseId(value) => Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContinuationIntent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::None => formatter.write_str("None"),
|
||||
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler、Router 和 history owner 使用的请求元数据。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ResponsesRequestMetadata {
|
||||
requested_model: String,
|
||||
public_model: String,
|
||||
stream: bool,
|
||||
store: bool,
|
||||
continuation: ContinuationIntent,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl ResponsesRequestMetadata {
|
||||
/// 返回客户端原始模型名。
|
||||
#[must_use]
|
||||
pub fn requested_model(&self) -> &str {
|
||||
&self.requested_model
|
||||
}
|
||||
|
||||
/// 返回协议层公开模型候选。
|
||||
#[must_use]
|
||||
pub fn public_model(&self) -> &str {
|
||||
&self.public_model
|
||||
}
|
||||
|
||||
/// 返回客户端是否请求 SSE。
|
||||
#[must_use]
|
||||
pub const fn stream(&self) -> bool {
|
||||
self.stream
|
||||
}
|
||||
|
||||
/// 返回客户端 storage intent。
|
||||
#[must_use]
|
||||
pub const fn store(&self) -> bool {
|
||||
self.store
|
||||
}
|
||||
|
||||
/// 返回 continuation intent。
|
||||
#[must_use]
|
||||
pub const fn continuation(&self) -> &ContinuationIntent {
|
||||
&self.continuation
|
||||
}
|
||||
|
||||
/// 返回从 HTTP 连接边界解析出的客户端地址。
|
||||
#[must_use]
|
||||
pub const fn client_ip(&self) -> Option<IpAddr> {
|
||||
self.client_ip
|
||||
}
|
||||
|
||||
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent。
|
||||
#[must_use]
|
||||
pub fn user_agent(&self) -> Option<&str> {
|
||||
self.user_agent.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ResponsesRequestMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ResponsesRequestMetadata")
|
||||
.field("requested_model", &self.requested_model)
|
||||
.field("public_model", &self.public_model)
|
||||
.field("stream", &self.stream)
|
||||
.field("store", &self.store)
|
||||
.field("continuation", &self.continuation)
|
||||
.field("client_ip", &self.client_ip)
|
||||
.field("has_user_agent", &self.user_agent.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次成功解码的 Responses 请求。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DecodedResponsesRequest {
|
||||
operation: Operation,
|
||||
metadata: ResponsesRequestMetadata,
|
||||
}
|
||||
|
||||
impl DecodedResponsesRequest {
|
||||
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态。
|
||||
#[must_use]
|
||||
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
|
||||
self.operation = self.operation.with_provider_session_state(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回协议无关 operation。
|
||||
#[must_use]
|
||||
pub const fn operation(&self) -> &Operation {
|
||||
&self.operation
|
||||
}
|
||||
|
||||
/// 返回 handler 元数据。
|
||||
#[must_use]
|
||||
pub const fn metadata(&self) -> &ResponsesRequestMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// 拆分为 operation 与元数据。
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Operation, ResponsesRequestMetadata) {
|
||||
(self.operation, self.metadata)
|
||||
}
|
||||
|
||||
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实。
|
||||
#[must_use]
|
||||
pub fn with_client_context(
|
||||
mut self,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
) -> Self {
|
||||
self.metadata.client_ip = client_ip;
|
||||
self.metadata.user_agent = user_agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DecodedResponsesRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DecodedResponsesRequest")
|
||||
.field("operation", &self.operation)
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用下游 OpenAI/Codex 请求头解码 `POST /v1/responses`。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// JSON 非法、顶层不是 object,或网关必须解释的路由字段无效时返回安全错误。
|
||||
pub fn decode_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
/// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。
|
||||
pub(super) fn decode_review_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
pub(super) fn decode_request_inner(
|
||||
body: &[u8],
|
||||
review: bool,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
let value =
|
||||
serde_json::from_slice::<Value>(body).map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
let Value::Object(object) = value else {
|
||||
return Err(RequestDecodeError::ExpectedObject);
|
||||
};
|
||||
decode_request_object(object, review, request_headers)
|
||||
}
|
||||
|
||||
/// 解码已解析的顶层 object;WebSocket 帧解码复用此内核,避免序列化往返。
|
||||
pub(super) fn decode_request_object(
|
||||
mut object: Map<String, Value>,
|
||||
review: bool,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议。
|
||||
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
|
||||
if use_websocket.is_some() {
|
||||
object.remove("use_websocket");
|
||||
}
|
||||
|
||||
let model = required_non_empty_string(&object, "model", "model")?;
|
||||
if model.trim().is_empty() {
|
||||
return Err(RequestDecodeError::EmptyField {
|
||||
field: "model".to_owned(),
|
||||
});
|
||||
}
|
||||
let model = model.to_owned();
|
||||
let stream = object
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true);
|
||||
let store = object
|
||||
.get("store")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let continuation = object
|
||||
.get("previous_response_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
|
||||
.unwrap_or(ContinuationIntent::None);
|
||||
|
||||
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
|
||||
let protocol_context = request_headers.protocol_context(use_websocket);
|
||||
|
||||
let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object)
|
||||
.map_err(|_| RequestDecodeError::CanonicalContract {
|
||||
field: "request".to_owned(),
|
||||
})?
|
||||
.with_context(protocol_context);
|
||||
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
|
||||
Ok(DecodedResponsesRequest {
|
||||
operation,
|
||||
metadata: ResponsesRequestMetadata {
|
||||
requested_model: model.clone(),
|
||||
public_model: model,
|
||||
stream,
|
||||
store,
|
||||
continuation,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn inject_subagent_metadata(body: &mut Map<String, Value>, subagent: &str) {
|
||||
let metadata = body
|
||||
.entry("client_metadata".to_owned())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
let Some(metadata) = metadata.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
metadata.insert(
|
||||
OPENAI_SUBAGENT_KEY.to_owned(),
|
||||
Value::String(subagent.to_owned()),
|
||||
);
|
||||
}
|
||||
|
||||
fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
|
||||
headers
|
||||
.get(name)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn passthrough_headers(headers: &HeaderMap) -> Vec<Value> {
|
||||
let connection_headers = headers
|
||||
.get_all("connection")
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.flat_map(|value| value.split(','))
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
headers
|
||||
.iter()
|
||||
.filter(|(name, _)| passthrough_header_name(name.as_str(), &connection_headers))
|
||||
.map(|(name, value)| {
|
||||
Value::Array(vec![
|
||||
Value::String(name.as_str().to_owned()),
|
||||
Value::String(STANDARD.encode(value.as_bytes())),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
if connection_headers
|
||||
.iter()
|
||||
.any(|connection_header| connection_header.eq_ignore_ascii_case(name))
|
||||
|| name.starts_with("sec-websocket-")
|
||||
|| name.starts_with("x-grok-")
|
||||
|| name.starts_with("x-xai-")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
!matches!(
|
||||
name,
|
||||
// 逐跳头、上游 authority 和重序列化后的实体长度由 transport 重建。
|
||||
"connection"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "host"
|
||||
| "content-length"
|
||||
// 下游连接诊断事实只用于本地观测,不能伪装成 OpenAI 请求事实。
|
||||
| "forwarded"
|
||||
| "x-forwarded-for"
|
||||
| "x-forwarded-host"
|
||||
| "x-forwarded-proto"
|
||||
| "x-forwarded-port"
|
||||
| "x-real-ip"
|
||||
| "true-client-ip"
|
||||
| "cf-connecting-ip"
|
||||
| "x-request-id"
|
||||
// 下游鉴权和账号 cookie 绝不能成为上游账号身份。
|
||||
| "authorization"
|
||||
| "x-api-key"
|
||||
| "cookie"
|
||||
| "cookie2"
|
||||
| "chatgpt-account-id"
|
||||
| "chatgpt-organization-id"
|
||||
| "chatgpt-org-id"
|
||||
| "chatgpt-project-id"
|
||||
| "openai-organization"
|
||||
| "openai-project"
|
||||
| "x-openai-organization"
|
||||
| "x-openai-project"
|
||||
// installation ID 始终由当前 lease 重建。
|
||||
| "x-codex-installation-id"
|
||||
)
|
||||
}
|
||||
|
||||
fn required_non_empty_string<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
field: &str,
|
||||
) -> Result<&'a str, RequestDecodeError> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.ok_or_else(|| RequestDecodeError::MissingField {
|
||||
field: field.to_owned(),
|
||||
})?
|
||||
.as_str()
|
||||
.ok_or_else(|| RequestDecodeError::InvalidType {
|
||||
field: field.to_owned(),
|
||||
expected: "a string",
|
||||
})?;
|
||||
if value.is_empty() {
|
||||
return Err(RequestDecodeError::EmptyField {
|
||||
field: field.to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Provider OpenAI Responses wire 到客户端 transport 的透明转发边界。
|
||||
|
||||
use bytes::Bytes;
|
||||
use gateway_core::event::{GatewayEvent, ProtocolWireEvent, ProviderEvent};
|
||||
use gateway_protocol::openai::sse::encode_sse_event_with_metadata;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::error::ResponseEncodeError;
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
|
||||
/// OpenAI Responses wire 的转发器。
|
||||
///
|
||||
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。wire 是客户端交付
|
||||
/// 与终态判断的事实来源;canonical facts 只在 wire 暂未携带 ID 时提供旁路观测,
|
||||
/// 不得反过来拒绝或改写上游事件。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OpenAiResponsesEncoder {
|
||||
response_id: Option<String>,
|
||||
wire_terminal: Option<Value>,
|
||||
wire_failure: bool,
|
||||
}
|
||||
|
||||
impl OpenAiResponsesEncoder {
|
||||
/// 创建响应 wire 转发器。
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
response_id: None,
|
||||
wire_terminal: None,
|
||||
wire_failure: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费一个 Provider event,并返回 SSE frames。
|
||||
pub fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
|
||||
self.observe_canonical_identity(event);
|
||||
let Some(wire) = openai_wire(event) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
|
||||
return vec![raw_sse_frame.clone()];
|
||||
}
|
||||
vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
wire.event_type().unwrap_or_default(),
|
||||
&wire.data().to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))]
|
||||
}
|
||||
|
||||
/// 消费一个 Provider event,并返回 WebSocket JSON messages。
|
||||
pub fn push_websocket(&mut self, event: &ProviderEvent) -> Vec<String> {
|
||||
self.observe_canonical_identity(event);
|
||||
let Some(wire) = openai_wire(event).filter(|wire| wire.has_json_data()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire(wire);
|
||||
vec![wire.data().to_string()]
|
||||
}
|
||||
|
||||
/// 返回是否已经看到客户端可见的 wire 终态。
|
||||
#[must_use]
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.wire_terminal.is_some()
|
||||
}
|
||||
|
||||
/// 返回是否已把 Provider 原生失败 event 交付给客户端。
|
||||
#[must_use]
|
||||
pub const fn has_wire_failure(&self) -> bool {
|
||||
self.wire_failure
|
||||
}
|
||||
|
||||
/// 返回 Core 已观察到的客户端可见 Provider 原生响应 ID。
|
||||
#[must_use]
|
||||
pub fn response_id(&self) -> Option<&str> {
|
||||
self.response_id.as_deref()
|
||||
}
|
||||
|
||||
/// 校验完整响应并返回原生终态 response object。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 缺少可转换为非流式响应的 wire 终态时返回错误。
|
||||
pub fn finish(self) -> Result<Value, ResponseEncodeError> {
|
||||
self.wire_terminal
|
||||
.ok_or(ResponseEncodeError::MissingWireTerminal)
|
||||
}
|
||||
|
||||
fn observe_canonical_identity(&mut self, event: &ProviderEvent) {
|
||||
for fact in event.canonical_facts() {
|
||||
let metadata = match fact {
|
||||
GatewayEvent::Started(metadata) | GatewayEvent::Completed(metadata) => metadata,
|
||||
_ => continue,
|
||||
};
|
||||
self.response_id = Some(metadata.response_id().to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_wire(&mut self, wire: &ProtocolWireEvent) {
|
||||
if let Some(response_id) = wire
|
||||
.data()
|
||||
.pointer("/response/id")
|
||||
.or_else(|| wire.data().get("response_id"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
self.response_id = Some(response_id.to_owned());
|
||||
}
|
||||
let effective_type = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
if matches!(
|
||||
effective_type,
|
||||
Some("response.completed" | "response.incomplete")
|
||||
) {
|
||||
self.wire_terminal = wire.data().get("response").cloned();
|
||||
} else if matches!(effective_type, Some("response.failed" | "error")) {
|
||||
self.wire_failure = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_wire(event: &ProviderEvent) -> Option<&ProtocolWireEvent> {
|
||||
event
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == OPENAI_PROTOCOL)
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
//! OpenAI Responses WebSocket adapter。
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{
|
||||
Extension, State,
|
||||
connect_info::ConnectInfo,
|
||||
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
||||
},
|
||||
http::{HeaderMap, HeaderName, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use gateway_core::engine::execution::{AuthenticatedClient, ClientTransport, StartedExecution};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::ProviderResponseHeader;
|
||||
use gateway_core::lifecycle::{ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::operation::ProviderSessionState;
|
||||
use serde_json::{Map, Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{gateway_error_contract, gateway_error_from_engine, runtime_unavailable_response},
|
||||
service::OpenAiService,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{
|
||||
DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody, RequestDecodeError,
|
||||
http::{PendingExecution, request_client_context},
|
||||
request::OpenAiRequestHeaders,
|
||||
};
|
||||
|
||||
const ACTIVE_RESPONSE_VIOLATION: &str = "Only one response.create may be active per connection";
|
||||
const TEXT_FRAMES_ONLY: &str = "Responses WebSocket accepts text frames only";
|
||||
|
||||
/// 将已认证的 `GET /v1/responses` 升级为 Responses WebSocket。
|
||||
pub(crate) async fn responses_websocket(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
websocket: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
);
|
||||
ResponsesWebSocketAdapter::new(service).upgrade_with_client_context(
|
||||
websocket,
|
||||
client,
|
||||
client_ip,
|
||||
user_agent,
|
||||
OpenAiRequestHeaders::from_headers(&headers),
|
||||
)
|
||||
}
|
||||
|
||||
/// 已鉴权 Responses WebSocket 升级边界。
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ResponsesWebSocketAdapter {
|
||||
service: OpenAiService,
|
||||
}
|
||||
|
||||
impl ResponsesWebSocketAdapter {
|
||||
/// 绑定应用提供的唯一 OpenAI 客户端服务端口。
|
||||
#[must_use]
|
||||
pub const fn new(service: OpenAiService) -> Self {
|
||||
Self { service }
|
||||
}
|
||||
|
||||
fn upgrade_with_client_context(
|
||||
&self,
|
||||
websocket: WebSocketUpgrade,
|
||||
client: AuthenticatedClient,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
request_headers: OpenAiRequestHeaders,
|
||||
) -> Response {
|
||||
let connection_guard = match self.service.try_register_connection() {
|
||||
Ok(guard) => guard,
|
||||
Err(_) => return runtime_unavailable_response().into_response(),
|
||||
};
|
||||
let session = ResponsesWebSocketSession {
|
||||
service: self.service.clone(),
|
||||
client,
|
||||
connection_id: self.service.next_request_id().replacen("req_", "ws_", 1),
|
||||
client_ip,
|
||||
user_agent,
|
||||
request_headers,
|
||||
lifecycle: self.service.lifecycle(),
|
||||
_connection_guard: connection_guard,
|
||||
};
|
||||
websocket
|
||||
// 覆盖 axum/tungstenite 的私有 64 MiB message 与 16 MiB frame 默认值。
|
||||
// Responses JSON 的协议可接受性由上游决定,代理不另设 wire 长度上限。
|
||||
.max_message_size(usize::MAX)
|
||||
.max_frame_size(usize::MAX)
|
||||
.on_upgrade(move |socket| async move {
|
||||
serve_responses_websocket(socket, session).await;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ResponsesWebSocketSession {
|
||||
service: OpenAiService,
|
||||
client: AuthenticatedClient,
|
||||
connection_id: String,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
request_headers: OpenAiRequestHeaders,
|
||||
lifecycle: Arc<dyn ConnectionLifecycle>,
|
||||
_connection_guard: Box<dyn ConnectionGuard>,
|
||||
}
|
||||
|
||||
async fn serve_responses_websocket(mut socket: WebSocket, session: ResponsesWebSocketSession) {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %session.connection_id,
|
||||
"Responses WebSocket connected"
|
||||
);
|
||||
let mut request_count = 0_u64;
|
||||
let mut replay = ConnectionReplaySnapshot::default();
|
||||
|
||||
let cancellation = session.lifecycle.cancellation();
|
||||
loop {
|
||||
let message = tokio::select! {
|
||||
() = cancellation.cancelled() => break,
|
||||
message = socket.next() => message,
|
||||
};
|
||||
let Some(message) = message else {
|
||||
break;
|
||||
};
|
||||
let payload = match message {
|
||||
Ok(Message::Text(payload)) => payload,
|
||||
Ok(Message::Ping(_) | Message::Pong(_)) => continue,
|
||||
Ok(Message::Close(_)) => break,
|
||||
Ok(Message::Binary(_)) => {
|
||||
close_for_protocol_violation(&mut socket, TEXT_FRAMES_ONLY).await;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %session.connection_id,
|
||||
error = %error,
|
||||
"Responses WebSocket receive failed"
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
request_count = request_count.saturating_add(1);
|
||||
let correlation_id = session.service.next_request_id();
|
||||
let decoded =
|
||||
match decode_response_create_with_context(payload.as_str(), &session.request_headers) {
|
||||
Ok(decoded) => {
|
||||
decoded.with_client_context(session.client_ip, session.user_agent.clone())
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::info!(
|
||||
websocket_connection_id = %session.connection_id,
|
||||
request_id = %correlation_id,
|
||||
error = %error,
|
||||
"Responses WebSocket request rejected"
|
||||
);
|
||||
if send_protocol_error(
|
||||
&mut socket,
|
||||
StatusCode::BAD_REQUEST,
|
||||
error.protocol_body(),
|
||||
&correlation_id,
|
||||
)
|
||||
.await
|
||||
== ForwardOutcome::Disconnect
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let decoded = replay.prepare(decoded);
|
||||
let started = match session
|
||||
.service
|
||||
.start_response(
|
||||
session.client.clone(),
|
||||
decoded,
|
||||
ClientTransport::WebSocket,
|
||||
"/v1/responses",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
if send_gateway_error(&mut socket, &error, &correlation_id).await
|
||||
== ForwardOutcome::Disconnect
|
||||
{
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if forward_execution(&mut socket, started, &mut replay).await == ForwardOutcome::Disconnect
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
websocket_connection_id = %session.connection_id,
|
||||
request_count,
|
||||
"Responses WebSocket disconnected"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ForwardOutcome {
|
||||
Continue,
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ConnectionReplaySnapshot {
|
||||
last_response_id: Option<String>,
|
||||
provider_state: Option<ProviderSessionState>,
|
||||
}
|
||||
|
||||
impl ConnectionReplaySnapshot {
|
||||
fn prepare(&self, request: DecodedResponsesRequest) -> DecodedResponsesRequest {
|
||||
match (
|
||||
request.metadata().continuation().previous_response_id(),
|
||||
self.last_response_id.as_deref(),
|
||||
self.provider_state.as_ref(),
|
||||
) {
|
||||
(Some(previous), Some(last), Some(state)) if previous == last => {
|
||||
request.with_provider_session_state(state.clone())
|
||||
}
|
||||
_ => request,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(&mut self, response_id: String, provider_state: Option<ProviderSessionState>) {
|
||||
self.last_response_id = Some(response_id);
|
||||
self.provider_state = provider_state;
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_execution(
|
||||
socket: &mut WebSocket,
|
||||
started: StartedExecution,
|
||||
replay: &mut ConnectionReplaySnapshot,
|
||||
) -> ForwardOutcome {
|
||||
let request_id = started.request_id.to_string();
|
||||
let streaming = started.stream;
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
if !streaming {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"WebSocket execution was not initialized as a stream",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
let first = loop {
|
||||
match next_active_input(socket, &mut execution).await {
|
||||
ActiveInput::Event(Ok(Some(event))) => break event,
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended before its first event",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Control => continue,
|
||||
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
|
||||
ActiveInput::ProtocolViolation => {
|
||||
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
}
|
||||
};
|
||||
let requirement = first.commit_requirement();
|
||||
let mut first = first.into_provider_events();
|
||||
if requirement != CommitRequirement::CommitBeforeDelivery {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway first event did not require commit",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let mut provider_state = None;
|
||||
let mut first_messages = Vec::new();
|
||||
for event in &mut first {
|
||||
if let Some(update) = event.take_session_update() {
|
||||
provider_state = Some(update);
|
||||
}
|
||||
first_messages.extend(encoder.push_websocket(event));
|
||||
}
|
||||
if first_messages.is_empty() {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway commit batch encoded no output",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
let Some(response_session) = execution.session_mut() else {
|
||||
return ForwardOutcome::Disconnect;
|
||||
};
|
||||
let response_headers = response_session.response_headers().to_vec();
|
||||
if let Err(error) = response_session.commit_downstream(None).await {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
if encoder.is_completed() {
|
||||
if let Err(outcome) = confirm_completed_execution(socket, &mut execution, &request_id).await
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
commit_connection_replay(replay, &encoder, provider_state.take());
|
||||
if !send_text(
|
||||
socket,
|
||||
response_metadata_event(&request_id, &response_headers),
|
||||
)
|
||||
.await
|
||||
|| !send_messages(socket, first_messages).await
|
||||
{
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
execution.disarm();
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
if !send_text(
|
||||
socket,
|
||||
response_metadata_event(&request_id, &response_headers),
|
||||
)
|
||||
.await
|
||||
|| !send_messages(socket, first_messages).await
|
||||
{
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
|
||||
loop {
|
||||
match next_active_input(socket, &mut execution).await {
|
||||
ActiveInput::Event(Ok(Some(delivery))) => {
|
||||
let requirement = delivery.commit_requirement();
|
||||
let mut events = delivery.into_provider_events();
|
||||
if requirement != CommitRequirement::AlreadyCommitted {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway requested another downstream commit",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
let mut messages = Vec::new();
|
||||
for event in &mut events {
|
||||
if let Some(update) = event.take_session_update() {
|
||||
provider_state = Some(update);
|
||||
}
|
||||
messages.extend(encoder.push_websocket(event));
|
||||
}
|
||||
if encoder.is_completed() {
|
||||
if let Err(outcome) =
|
||||
confirm_completed_execution(socket, &mut execution, &request_id).await
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
commit_connection_replay(replay, &encoder, provider_state.take());
|
||||
if !send_messages(socket, messages).await {
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
execution.disarm();
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
if !send_messages(socket, messages).await {
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
}
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
if execution
|
||||
.session_mut()
|
||||
.is_some_and(|session| session.is_finalized())
|
||||
{
|
||||
commit_connection_replay(replay, &encoder, provider_state.take());
|
||||
execution.disarm();
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended without finalizing the execution",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
if encoder.has_wire_failure() {
|
||||
execution.disarm();
|
||||
return ForwardOutcome::Continue;
|
||||
}
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
ActiveInput::Control => {}
|
||||
ActiveInput::Disconnect => return ForwardOutcome::Disconnect,
|
||||
ActiveInput::ProtocolViolation => {
|
||||
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_connection_replay(
|
||||
replay: &mut ConnectionReplaySnapshot,
|
||||
encoder: &OpenAiResponsesEncoder,
|
||||
provider_state: Option<ProviderSessionState>,
|
||||
) {
|
||||
if let Some(response_id) = encoder.response_id() {
|
||||
replay.commit(response_id.to_owned(), provider_state);
|
||||
}
|
||||
}
|
||||
|
||||
async fn confirm_completed_execution(
|
||||
socket: &mut WebSocket,
|
||||
execution: &mut PendingExecution,
|
||||
request_id: &str,
|
||||
) -> Result<(), ForwardOutcome> {
|
||||
loop {
|
||||
match next_active_input(socket, execution).await {
|
||||
ActiveInput::Event(Ok(None))
|
||||
if execution
|
||||
.session_mut()
|
||||
.is_some_and(|session| session.is_finalized()) =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
ActiveInput::Event(Ok(None)) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response was not finalized after its terminal event",
|
||||
);
|
||||
return Err(send_gateway_error(socket, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Event(Ok(Some(_))) => {
|
||||
let error = GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response continued after its terminal event",
|
||||
);
|
||||
return Err(send_gateway_error(socket, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Event(Err(error)) => {
|
||||
let error = gateway_error_from_engine(&error);
|
||||
return Err(send_gateway_error(socket, &error, request_id).await);
|
||||
}
|
||||
ActiveInput::Control => {}
|
||||
ActiveInput::Disconnect => return Err(ForwardOutcome::Disconnect),
|
||||
ActiveInput::ProtocolViolation => {
|
||||
close_for_protocol_violation(socket, ACTIVE_RESPONSE_VIOLATION).await;
|
||||
return Err(ForwardOutcome::Disconnect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ActiveInput {
|
||||
Event(Result<Option<CoordinatedEvent>, EngineError>),
|
||||
Control,
|
||||
Disconnect,
|
||||
ProtocolViolation,
|
||||
}
|
||||
|
||||
async fn next_active_input(
|
||||
socket: &mut WebSocket,
|
||||
execution: &mut PendingExecution,
|
||||
) -> ActiveInput {
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return ActiveInput::Disconnect;
|
||||
};
|
||||
tokio::select! {
|
||||
event = session.next_event() => ActiveInput::Event(event),
|
||||
message = socket.next() => match message {
|
||||
Some(Ok(Message::Ping(_) | Message::Pong(_))) => ActiveInput::Control,
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => ActiveInput::Disconnect,
|
||||
Some(Ok(Message::Text(_) | Message::Binary(_))) => ActiveInput::ProtocolViolation,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用连接级请求头和 Provider 上下文解码官方 `response.create` 文本帧。
|
||||
///
|
||||
/// 缺省 `stream` 等价于 WebSocket 固有的流式语义;显式 `false` 会被拒绝。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 帧不是合法 JSON object、消息类型错误、显式关闭 stream,或 Responses 请求
|
||||
/// 无法映射到 canonical operation 时返回不包含正文内容的稳定错误。
|
||||
pub fn decode_response_create_with_context(
|
||||
payload: &str,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
|
||||
decode_response_create_inner(payload, request_headers)
|
||||
}
|
||||
|
||||
fn decode_response_create_inner(
|
||||
payload: &str,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
|
||||
let Value::Object(mut body) = serde_json::from_str::<Value>(payload)
|
||||
.map_err(|_| ResponseCreateFrameError::InvalidJson)?
|
||||
else {
|
||||
return Err(ResponseCreateFrameError::ExpectedObject);
|
||||
};
|
||||
match body.remove("type") {
|
||||
Some(Value::String(message_type)) if message_type == "response.create" => {}
|
||||
_ => return Err(ResponseCreateFrameError::UnsupportedType),
|
||||
}
|
||||
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
|
||||
return Err(ResponseCreateFrameError::StreamingRequired);
|
||||
}
|
||||
super::request::decode_request_object(body, false, request_headers)
|
||||
.map_err(ResponseCreateFrameError::Request)
|
||||
}
|
||||
|
||||
/// `response.create` 帧的稳定安全错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum ResponseCreateFrameError {
|
||||
/// 文本不是合法 JSON。
|
||||
#[error("response.create frame must be valid JSON")]
|
||||
InvalidJson,
|
||||
/// 顶层不是 object。
|
||||
#[error("response.create frame must be a JSON object")]
|
||||
ExpectedObject,
|
||||
/// `type` 缺失或不是 `response.create`。
|
||||
#[error("unsupported Responses WebSocket message type")]
|
||||
UnsupportedType,
|
||||
/// WebSocket 请求显式声明 `stream=false`。
|
||||
#[error("Responses WebSocket requests require stream=true")]
|
||||
StreamingRequired,
|
||||
/// 内层 Responses 请求无法映射到 canonical operation。
|
||||
#[error(transparent)]
|
||||
Request(RequestDecodeError),
|
||||
}
|
||||
|
||||
impl ResponseCreateFrameError {
|
||||
fn protocol_body(&self) -> ProtocolErrorBody {
|
||||
match self {
|
||||
Self::Request(error) => error.protocol_body(),
|
||||
Self::InvalidJson => RequestDecodeError::MalformedJson.protocol_body(),
|
||||
Self::ExpectedObject => RequestDecodeError::ExpectedObject.protocol_body(),
|
||||
Self::UnsupportedType => RequestDecodeError::InvalidValue {
|
||||
field: "type".to_owned(),
|
||||
}
|
||||
.protocol_body(),
|
||||
Self::StreamingRequired => RequestDecodeError::InvalidValue {
|
||||
field: "stream".to_owned(),
|
||||
}
|
||||
.protocol_body(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn response_metadata_event(
|
||||
request_id: &str,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> String {
|
||||
let connection_options = super::response_connection_options(response_headers);
|
||||
let mut headers = Map::new();
|
||||
for header in response_headers {
|
||||
if !super::response_header_is_forwardable(header.name(), &connection_options) {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = std::str::from_utf8(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
// 官方 response.metadata 使用 string map,无法表达同名多值;后值保持既有覆盖语义。
|
||||
headers.insert(name.as_str().to_owned(), Value::String(value.to_owned()));
|
||||
}
|
||||
headers
|
||||
.entry("x-request-id".to_owned())
|
||||
.or_insert_with(|| Value::String(request_id.to_owned()));
|
||||
json!({
|
||||
"type": "response.metadata",
|
||||
"headers": headers,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn send_gateway_error(
|
||||
socket: &mut WebSocket,
|
||||
error: &GatewayError,
|
||||
request_id: &str,
|
||||
) -> ForwardOutcome {
|
||||
let (status, default_type, default_code) = gateway_error_contract(error.kind());
|
||||
if send_error_event(
|
||||
socket,
|
||||
status,
|
||||
error.client_error_type().unwrap_or(default_type),
|
||||
error.client_error_code().unwrap_or(default_code),
|
||||
error.client_message(),
|
||||
None,
|
||||
request_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ForwardOutcome::Continue
|
||||
} else {
|
||||
ForwardOutcome::Disconnect
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_protocol_error(
|
||||
socket: &mut WebSocket,
|
||||
status: StatusCode,
|
||||
body: ProtocolErrorBody,
|
||||
request_id: &str,
|
||||
) -> ForwardOutcome {
|
||||
let error = body.error;
|
||||
if send_error_event(
|
||||
socket,
|
||||
status,
|
||||
error.kind,
|
||||
error.code,
|
||||
&error.message,
|
||||
error.param.as_deref(),
|
||||
request_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
ForwardOutcome::Continue
|
||||
} else {
|
||||
ForwardOutcome::Disconnect
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_error_event(
|
||||
socket: &mut WebSocket,
|
||||
status: StatusCode,
|
||||
error_type: &str,
|
||||
code: &str,
|
||||
message: &str,
|
||||
param: Option<&str>,
|
||||
request_id: &str,
|
||||
) -> bool {
|
||||
let mut error = Map::new();
|
||||
error.insert("type".to_owned(), Value::String(error_type.to_owned()));
|
||||
error.insert("code".to_owned(), Value::String(code.to_owned()));
|
||||
error.insert("message".to_owned(), Value::String(message.to_owned()));
|
||||
if let Some(param) = param {
|
||||
error.insert("param".to_owned(), Value::String(param.to_owned()));
|
||||
}
|
||||
send_text(
|
||||
socket,
|
||||
json!({
|
||||
"type": "error",
|
||||
"status": status.as_u16(),
|
||||
"error": error,
|
||||
"request_id": request_id,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_messages(socket: &mut WebSocket, messages: Vec<String>) -> bool {
|
||||
for message in messages {
|
||||
if !send_text(socket, message).await {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn send_text(socket: &mut WebSocket, payload: String) -> bool {
|
||||
if let Err(error) = socket.send(Message::Text(payload.into())).await {
|
||||
tracing::info!(error = %error, "Responses WebSocket send failed");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn close_for_protocol_violation(socket: &mut WebSocket, reason: &'static str) {
|
||||
let _ = socket
|
||||
.send(Message::Close(Some(CloseFrame {
|
||||
code: close_code::POLICY,
|
||||
reason: reason.into(),
|
||||
})))
|
||||
.await;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 兼容 API 路由。
|
||||
//! OpenAI 客户端协议路由。
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -6,23 +6,24 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::api::AppState;
|
||||
|
||||
use super::{
|
||||
models::{model_catalog, model_detail, model_info, models},
|
||||
responses::{responses, responses_websocket, review_responses},
|
||||
};
|
||||
|
||||
pub const MAX_CLIENT_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||
use crate::ApiState;
|
||||
|
||||
/// 构造 OpenAI 兼容 API 路由。
|
||||
pub fn router() -> Router<AppState> {
|
||||
/// 构造 OpenAI 客户端协议路由。
|
||||
pub(crate) fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/v1/responses", get(responses_websocket).post(responses))
|
||||
.route("/v1/responses/review", post(review_responses))
|
||||
.route("/v1/models", get(models))
|
||||
.route("/v1/models/catalog", get(model_catalog))
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
.route("/v1/models/{model_id}/info", get(model_info))
|
||||
.layer(DefaultBodyLimit::max(MAX_CLIENT_REQUEST_BODY_BYTES))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
|
||||
// 上游本可接受的未来 payload。
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! OpenAI wire adapter 到 Core 执行用例的唯一映射。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::continuation::PreviousResponseId;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionRequestMetadata,
|
||||
ExecutionService, StartExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::routing::{PublicModelId, PublicModelProfile};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::auth::ClientApiKeyAuthError;
|
||||
use super::responses::{ContinuationIntent, DecodedResponsesRequest};
|
||||
|
||||
/// OpenAI HTTP/WS adapter 共享的 Core 与连接生命周期能力。
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct OpenAiService {
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
lifecycle: Arc<dyn ConnectionLifecycle>,
|
||||
}
|
||||
|
||||
impl OpenAiService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
lifecycle: Arc<dyn ConnectionLifecycle>,
|
||||
) -> Self {
|
||||
Self {
|
||||
execution,
|
||||
lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
|
||||
self.execution
|
||||
.authenticate(plaintext)
|
||||
.map_err(map_authentication_error)
|
||||
}
|
||||
|
||||
pub(crate) fn public_models(&self, client: &AuthenticatedClient) -> Vec<String> {
|
||||
self.execution
|
||||
.public_models(client)
|
||||
.into_iter()
|
||||
.map(|model| model.as_str().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn public_model_profiles(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
) -> Vec<PublicModelProfile> {
|
||||
self.execution.public_model_profiles(client)
|
||||
}
|
||||
|
||||
pub(crate) fn contains_public_model(
|
||||
&self,
|
||||
client: &AuthenticatedClient,
|
||||
model: &PublicModelId,
|
||||
) -> bool {
|
||||
self.execution.contains_public_model(client, model)
|
||||
}
|
||||
|
||||
pub(crate) async fn start_response(
|
||||
&self,
|
||||
client: AuthenticatedClient,
|
||||
request: DecodedResponsesRequest,
|
||||
transport: ClientTransport,
|
||||
endpoint: &'static str,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let (operation, metadata) = request.into_parts();
|
||||
let public_model = PublicModelId::from_client_wire(metadata.public_model().to_owned())
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::ModelNotFound,
|
||||
"requested model was not found",
|
||||
)
|
||||
})?;
|
||||
let previous_response_id = match metadata.continuation() {
|
||||
ContinuationIntent::None => None,
|
||||
ContinuationIntent::PreviousResponseId(value) => {
|
||||
Some(PreviousResponseId::new(value.clone()))
|
||||
}
|
||||
};
|
||||
self.execution
|
||||
.start(StartExecution {
|
||||
client,
|
||||
public_model,
|
||||
operation,
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".to_owned(),
|
||||
endpoint: endpoint.to_owned(),
|
||||
transport,
|
||||
stream: metadata.stream(),
|
||||
client_ip: metadata.client_ip(),
|
||||
user_agent: metadata.user_agent().map(str::to_owned),
|
||||
previous_response_id,
|
||||
},
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn try_register_connection(
|
||||
&self,
|
||||
) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
|
||||
self.lifecycle.try_register()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn lifecycle(&self) -> Arc<dyn ConnectionLifecycle> {
|
||||
Arc::clone(&self.lifecycle)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn next_request_id(&self) -> String {
|
||||
format!("req_{}", Uuid::now_v7().simple())
|
||||
}
|
||||
}
|
||||
|
||||
const fn map_authentication_error(error: ClientAuthenticationError) -> ClientApiKeyAuthError {
|
||||
match error {
|
||||
ClientAuthenticationError::InvalidKey => ClientApiKeyAuthError::InvalidKey,
|
||||
ClientAuthenticationError::SnapshotUnavailable => ClientApiKeyAuthError::RuntimeUnavailable,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
mod query {
|
||||
use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection};
|
||||
use gateway_api::admin::accounts::ListQuery;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn account_query_should_parse_provider_status_and_sort_once() {
|
||||
let query: ListQuery = serde_json::from_value(json!({
|
||||
"page": 3,
|
||||
"pageSize": 20,
|
||||
"provider": "xai",
|
||||
"search": " operator ",
|
||||
"status": "active",
|
||||
"sortBy": "lastUsedAt",
|
||||
"sortDirection": "desc"
|
||||
}))
|
||||
.expect("deserialize account query");
|
||||
let query = query.validate().expect("validate account query");
|
||||
assert_eq!(query.page, 3);
|
||||
assert_eq!(query.page_size.get(), 20);
|
||||
assert!(matches!(
|
||||
query.provider_kind,
|
||||
Some(ref provider) if provider.as_str() == "xai"
|
||||
));
|
||||
assert_eq!(query.search.as_deref(), Some("operator"));
|
||||
assert_eq!(query.status, Some(AccountStatus::Active));
|
||||
assert_eq!(
|
||||
query.sort.expect("sort").field,
|
||||
AccountSortField::LastUsedAt
|
||||
);
|
||||
assert_eq!(
|
||||
query.sort.expect("copy sort").direction,
|
||||
SortDirection::Desc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_query_should_reject_unbounded_page_size() {
|
||||
let query: ListQuery =
|
||||
serde_json::from_value(json!({"pageSize": 201})).expect("deserialize account query");
|
||||
assert_eq!(
|
||||
query.validate().expect_err("reject page size").field(),
|
||||
"pageSize"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_query_should_reject_incomplete_sort() {
|
||||
let query: ListQuery =
|
||||
serde_json::from_value(json!({"sortBy": "usage"})).expect("deserialize account query");
|
||||
assert_eq!(query.validate().expect_err("reject sort").field(), "sort");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_query_should_reject_unknown_fields() {
|
||||
assert!(serde_json::from_value::<ListQuery>(json!({"id": "cred_1"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod response {
|
||||
use gateway_api::admin::accounts::AccountUsageView;
|
||||
|
||||
#[test]
|
||||
fn account_usage_view_should_keep_unobserved_numbers_null() {
|
||||
let view = AccountUsageView {
|
||||
request_count: None,
|
||||
request_count_display: "-".to_owned(),
|
||||
input_tokens: None,
|
||||
input_tokens_display: "-".to_owned(),
|
||||
output_tokens: None,
|
||||
output_tokens_display: "-".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "-".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "-".to_owned(),
|
||||
image_output_tokens: None,
|
||||
image_output_tokens_display: "-".to_owned(),
|
||||
image_request_count: None,
|
||||
image_request_count_display: "-".to_owned(),
|
||||
image_request_failed_count: None,
|
||||
image_request_failed_count_display: "-".to_owned(),
|
||||
total_tokens: None,
|
||||
total_tokens_display: "-".to_owned(),
|
||||
created_tokens: None,
|
||||
created_tokens_display: "-".to_owned(),
|
||||
read_tokens: None,
|
||||
read_tokens_display: "-".to_owned(),
|
||||
last_used_at: None,
|
||||
last_used_at_display: "-".to_owned(),
|
||||
cost_estimate_status: "unknown".to_owned(),
|
||||
known_cost_count: None,
|
||||
partial_cost_count: None,
|
||||
unknown_cost_count: None,
|
||||
costs: Vec::new(),
|
||||
models: Vec::new(),
|
||||
};
|
||||
let value = serde_json::to_value(view).expect("serialize account usage");
|
||||
assert!(value["inputTokens"].is_null());
|
||||
assert!(value["totalTokens"].is_null());
|
||||
assert_eq!(value["createdTokensDisplay"], "-");
|
||||
}
|
||||
}
|
||||
|
||||
mod actions {
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
accounts::{AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus},
|
||||
provider_credentials::{
|
||||
CredentialDeletionResult, CredentialImportResult, CredentialMutationResult,
|
||||
},
|
||||
};
|
||||
use gateway_api::admin::accounts::{
|
||||
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
|
||||
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
|
||||
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
|
||||
AccountTestQuery, RotateAccountRequest, StartAccountAuthorizationRequest,
|
||||
};
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn export_should_require_explicit_unique_ids_and_confirmation() {
|
||||
let valid: AccountExportQuery = serde_json::from_value(json!({
|
||||
"accountIds": "acct_1,acct_2",
|
||||
"confirm": "export_sensitive_accounts"
|
||||
}))
|
||||
.expect("decode export query");
|
||||
assert_eq!(
|
||||
valid
|
||||
.into_ids()
|
||||
.expect("valid export")
|
||||
.into_iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
["acct_1".to_owned(), "acct_2".to_owned()]
|
||||
);
|
||||
|
||||
for query in [
|
||||
json!({ "accountIds": "", "confirm": "export_sensitive_accounts" }),
|
||||
json!({ "accountIds": "acct_1,acct_1", "confirm": "export_sensitive_accounts" }),
|
||||
json!({ "accountIds": "acct_1", "confirm": "yes" }),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountExportQuery>(query)
|
||||
.expect("decode invalid export query")
|
||||
.into_ids()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_reject_unknown_revision() {
|
||||
let id: AccountIdQuery =
|
||||
serde_json::from_value(json!({ "accountId": "acct_1" })).expect("decode ID query");
|
||||
assert!(id.validate().is_ok());
|
||||
let action: AccountActionRequest =
|
||||
serde_json::from_value(json!({ "accountId": "legacy-id" })).expect("decode action");
|
||||
assert_eq!(action.validate().unwrap_err().field(), "accountId");
|
||||
let refresh: AccountRefreshRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_1"
|
||||
}))
|
||||
.expect("decode refresh");
|
||||
assert!(refresh.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<AccountRefreshRequest>(json!({
|
||||
"accountId": "acct_1",
|
||||
"expectedConfigRevision": 0
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_recovery_requests_should_not_accept_client_revision_fences() {
|
||||
let authorization: StartAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"name": "reauthorize",
|
||||
"accountId": "acct_1"
|
||||
}))
|
||||
.expect("decode reauthorization");
|
||||
assert!(authorization.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<StartAccountAuthorizationRequest>(json!({
|
||||
"provider": "openai",
|
||||
"name": "reauthorize",
|
||||
"accountId": "acct_1",
|
||||
"expectedCredentialRevision": 1
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let rotation: RotateAccountRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"accountId": "acct_1",
|
||||
"accessToken": "header.payload.signature",
|
||||
"refreshToken": "refresh-token"
|
||||
}))
|
||||
.expect("decode rotation");
|
||||
assert!(rotation.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<RotateAccountRequest>(json!({
|
||||
"provider": "openai",
|
||||
"accountId": "acct_1",
|
||||
"expectedCredentialRevision": 1,
|
||||
"accessToken": "header.payload.signature"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_mutation_response_should_not_expose_internal_revision() {
|
||||
let response = AccountMutationData::from(CredentialMutationResult {
|
||||
config_revision: Revision::new(8).expect("config revision"),
|
||||
account_id: ProviderAccountId::new("acct_1").expect("account ID"),
|
||||
credential_revision: Some(Revision::new(9).expect("credential revision")),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize credential mutation"),
|
||||
json!({ "accountId": "acct_1" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_import_should_use_provider_and_opaque_data_fields() {
|
||||
let valid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"data": {
|
||||
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
|
||||
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
|
||||
}
|
||||
}))
|
||||
.expect("decode account import");
|
||||
assert!(valid.validate().is_ok());
|
||||
|
||||
let invalid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"data": []
|
||||
}))
|
||||
.expect("decode invalid account import");
|
||||
assert_eq!(invalid.validate().unwrap_err().field(), "data");
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"document": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_deletion_should_validate_one_provider_batch_and_emit_account_ids() {
|
||||
let request: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
}))
|
||||
.expect("decode account deletion");
|
||||
assert!(request.validate().is_ok());
|
||||
|
||||
let duplicate: AccountDeletionRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"accountIds": ["acct_1", "acct_1"]
|
||||
}))
|
||||
.expect("decode duplicate account deletion");
|
||||
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
|
||||
|
||||
let response = AccountDeletionData::from(CredentialDeletionResult {
|
||||
config_revision: Revision::new(9).expect("revision"),
|
||||
account_ids: vec![
|
||||
ProviderAccountId::new("acct_1").expect("account ID"),
|
||||
ProviderAccountId::new("acct_2").expect("account ID"),
|
||||
],
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account deletion"),
|
||||
json!({
|
||||
"deletedCount": 2,
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_import_response_should_emit_account_ids() {
|
||||
let response = AccountImportData::from(CredentialImportResult {
|
||||
config_revision: Revision::new(8).expect("revision"),
|
||||
credential_ids: vec![ProviderAccountId::new("acct_imported").expect("account ID")],
|
||||
});
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account import"),
|
||||
json!({
|
||||
"importedCount": 1,
|
||||
"accountIds": ["acct_imported"]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_test_should_require_model_in_query() {
|
||||
let query: AccountTestQuery = serde_json::from_value(json!({
|
||||
"accountId": "acct_1",
|
||||
"modelId": " "
|
||||
}))
|
||||
.expect("decode connection test query");
|
||||
assert_eq!(query.validate().unwrap_err().field(), "modelId");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_test_events_should_preserve_the_existing_frontend_contract() {
|
||||
let events = [
|
||||
DomainConnectionTestEvent::Started {
|
||||
model: "grok-4.5".to_owned(),
|
||||
},
|
||||
DomainConnectionTestEvent::Request {
|
||||
model: "grok-4.5".to_owned(),
|
||||
input_text: "Reply with exactly OK.".to_owned(),
|
||||
stream: true,
|
||||
store: false,
|
||||
},
|
||||
DomainConnectionTestEvent::Content {
|
||||
text: "OK".to_owned(),
|
||||
},
|
||||
DomainConnectionTestEvent::Completed {
|
||||
account_status: AccountStatus::Active,
|
||||
},
|
||||
DomainConnectionTestEvent::Failed {
|
||||
message: "upstream unavailable".to_owned(),
|
||||
provider_error_code: Some("usage_exhausted".to_owned()),
|
||||
provider_error_type: Some("invalid_request_error".to_owned()),
|
||||
upstream_status: Some(429),
|
||||
upstream_content_type: Some("application/json".to_owned()),
|
||||
upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()),
|
||||
account_status: AccountStatus::Active,
|
||||
},
|
||||
]
|
||||
.map(|event| AccountConnectionTestEvent::from(event).data);
|
||||
|
||||
assert_eq!(
|
||||
events,
|
||||
[
|
||||
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游 Responses" }),
|
||||
json!({
|
||||
"type": "request",
|
||||
"payload": {
|
||||
"model": "grok-4.5",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "Reply with exactly OK."
|
||||
}]
|
||||
}],
|
||||
"stream": true,
|
||||
"store": false
|
||||
}
|
||||
}),
|
||||
json!({ "type": "content", "text": "OK" }),
|
||||
json!({ "type": "test_complete", "success": true, "accountStatus": "active" }),
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": "upstream unavailable",
|
||||
"providerErrorCode": "usage_exhausted",
|
||||
"providerErrorType": "invalid_request_error",
|
||||
"upstreamStatus": 429,
|
||||
"upstreamContentType": "application/json",
|
||||
"upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#,
|
||||
"accountStatus": "active"
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_export_document_should_serialize_but_never_debug_secret() {
|
||||
let secret = "provider-refresh-token-must-not-enter-debug";
|
||||
let document = AccountExportData::new(json!({ "refresh_token": secret }));
|
||||
assert!(!format!("{document:?}").contains(secret));
|
||||
assert_eq!(
|
||||
serde_json::to_value(document).expect("serialize export"),
|
||||
json!({ "refresh_token": secret })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use gateway_admin::model::auth::{LoginCommand, LoginError};
|
||||
use gateway_api::admin::auth::{
|
||||
AdminLoginData, AdminLoginRequest, AdminLogoutData, AdminSessionStatusData,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::AdminTestFixture;
|
||||
|
||||
#[test]
|
||||
fn login_request_should_deny_unknown_fields_and_redact_password_debug() {
|
||||
let password = "admin-password-must-not-leak";
|
||||
let request = serde_json::from_value::<AdminLoginRequest>(json!({
|
||||
"username": "admin@example.invalid",
|
||||
"password": password
|
||||
}))
|
||||
.expect("deserialize login request");
|
||||
|
||||
assert!(!format!("{request:?}").contains(password));
|
||||
let (username, parsed_password) = request.into_parts();
|
||||
assert_eq!(username.as_deref(), Some("admin@example.invalid"));
|
||||
assert_eq!(parsed_password, password);
|
||||
assert!(
|
||||
serde_json::from_value::<AdminLoginRequest>(json!({
|
||||
"password": password,
|
||||
"rememberMe": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_responses_should_keep_stable_wire_shapes() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminLoginData::new("2026-07-18T08:00:00+08:00".to_owned()))
|
||||
.expect("serialize login"),
|
||||
json!({ "expiresAt": "2026-07-18T08:00:00+08:00" })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminSessionStatusData::new(true)).expect("serialize status"),
|
||||
json!({ "authenticated": true })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(AdminLogoutData::new()).expect("serialize logout"),
|
||||
json!({ "message": "Logged out successfully" })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_auth_service_should_initialize_login_validate_and_logout() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let service = fixture.services.auth();
|
||||
let session = service
|
||||
.login(LoginCommand {
|
||||
username: Some("admin_1".to_owned()),
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("login succeeds");
|
||||
assert!(
|
||||
service
|
||||
.validate_session(Some(&session.session_id))
|
||||
.await
|
||||
.expect("validate session")
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.resolve_admin_user_id(Some(&session.session_id))
|
||||
.await
|
||||
.expect("resolve session")
|
||||
.as_deref(),
|
||||
Some("admin_1")
|
||||
);
|
||||
service
|
||||
.logout(&session.session_id)
|
||||
.await
|
||||
.expect("logout session");
|
||||
assert!(
|
||||
!service
|
||||
.validate_session(Some(&session.session_id))
|
||||
.await
|
||||
.expect("validate logged-out session")
|
||||
);
|
||||
assert_eq!(fixture.auth.audit_count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_auth_service_should_verify_only_full_plaintext_admin_key() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let key = format!("admin-{}", "a".repeat(64));
|
||||
fixture.auth.set_api_key(&key);
|
||||
|
||||
assert!(
|
||||
fixture
|
||||
.services
|
||||
.auth()
|
||||
.verify_admin_api_key(&key)
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.services
|
||||
.auth()
|
||||
.verify_admin_api_key("admin-short")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
!fixture
|
||||
.services
|
||||
.auth()
|
||||
.verify_admin_api_key(&format!("admin-{}", "b".repeat(64)))
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audit_failure_should_revoke_new_session_before_returning_it() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.fail_audit(true);
|
||||
|
||||
assert_eq!(
|
||||
fixture
|
||||
.services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username: None,
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("audit failure rejects login"),
|
||||
LoginError::Unavailable
|
||||
);
|
||||
assert_eq!(fixture.auth.session_count(), 0);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use gateway_api::admin::client_keys::{
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
|
||||
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
|
||||
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
|
||||
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
|
||||
encode_client_key_cursor,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
#[test]
|
||||
fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
let unknown = serde_json::from_value::<ListClientKeysQuery>(json!({ "other": true }));
|
||||
let zero = serde_json::from_value::<ListClientKeysQuery>(json!({ "limit": 0 }))
|
||||
.expect("deserialize zero limit");
|
||||
let oversized = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"cursor": "a".repeat(513)
|
||||
}))
|
||||
.expect("deserialize oversized cursor");
|
||||
let oversized_search = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"search": "a".repeat(257)
|
||||
}))
|
||||
.expect("deserialize oversized search");
|
||||
let secret_search = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"search": format!("sk_{}", "a".repeat(43))
|
||||
}))
|
||||
.expect("deserialize secret search");
|
||||
let invalid_sort = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"sortBy": "plaintextKey",
|
||||
"sortDirection": "asc"
|
||||
}))
|
||||
.expect("deserialize invalid sort");
|
||||
|
||||
assert!(unknown.is_err());
|
||||
assert_eq!(
|
||||
zero.into_command().expect_err("reject zero limit").field(),
|
||||
"limit"
|
||||
);
|
||||
assert_eq!(
|
||||
oversized
|
||||
.into_command()
|
||||
.expect_err("reject oversized cursor")
|
||||
.field(),
|
||||
"cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
oversized_search
|
||||
.into_command()
|
||||
.expect_err("reject oversized search")
|
||||
.field(),
|
||||
"search"
|
||||
);
|
||||
assert_eq!(
|
||||
secret_search
|
||||
.into_command()
|
||||
.expect_err("reject full key in search")
|
||||
.field(),
|
||||
"search"
|
||||
);
|
||||
assert_eq!(
|
||||
invalid_sort
|
||||
.into_command()
|
||||
.expect_err("reject invalid sort")
|
||||
.field(),
|
||||
"sortBy"
|
||||
);
|
||||
let sorted = serde_json::from_value::<ListClientKeysQuery>(json!({
|
||||
"sortBy": "lastUsedAt",
|
||||
"sortDirection": "asc"
|
||||
}))
|
||||
.expect("deserialize valid sort")
|
||||
.into_command()
|
||||
.expect("validate sort");
|
||||
assert_eq!(
|
||||
sorted.sort.field,
|
||||
gateway_admin::model::client_keys::ClientKeySortField::LastUsedAt,
|
||||
);
|
||||
assert_eq!(
|
||||
sorted.sort.direction,
|
||||
gateway_admin::model::client_keys::SortDirection::Asc,
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ListClientKeysQuery>(json!({ "search": " " }))
|
||||
.expect("deserialize blank search")
|
||||
.into_command()
|
||||
.expect("normalize blank search")
|
||||
.search,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ListClientKeysQuery>(json!({ "limit": u16::MAX }))
|
||||
.expect("deserialize maximum u16 page size")
|
||||
.into_command()
|
||||
.expect("accept maximum u16 page size")
|
||||
.page_size
|
||||
.get(),
|
||||
u16::MAX
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"name": "terminal key",
|
||||
"label": "production",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.expect("deserialize create")
|
||||
.into_command()
|
||||
.expect("validate create");
|
||||
assert_eq!(valid.name, "terminal key");
|
||||
|
||||
for (payload, field) in [
|
||||
(
|
||||
json!({
|
||||
"name": " ",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
"name",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": u64::MAX,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
"maxConcurrency",
|
||||
),
|
||||
] {
|
||||
let request = serde_json::from_value::<CreateClientKeyRequest>(payload)
|
||||
.expect("deserialize invalid create shape");
|
||||
assert_eq!(
|
||||
request
|
||||
.into_command()
|
||||
.expect_err("reject invalid create")
|
||||
.field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateClientKeyRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0,
|
||||
"tokensPerMinute": 0
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
|
||||
"id": "key_1"
|
||||
}))
|
||||
.expect("deserialize mutation")
|
||||
.into_id()
|
||||
.expect("validate mutation");
|
||||
assert_eq!(mutation_id, "key_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_cursor_should_round_trip_and_reject_noncanonical_input() {
|
||||
let created_at = Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
|
||||
.single()
|
||||
.expect("valid time");
|
||||
let cursor = ClientKeyCursorData {
|
||||
sort: ClientKeySort {
|
||||
field: ClientKeySortField::CreatedAt,
|
||||
direction: ClientKeySortDirection::Desc,
|
||||
},
|
||||
value: ClientKeyCursorValue::CreatedAt(created_at),
|
||||
id: "key_cursor".to_owned(),
|
||||
};
|
||||
let encoded = encode_client_key_cursor(&cursor).expect("encode cursor");
|
||||
let decoded = decode_client_key_cursor(&encoded).expect("decode cursor");
|
||||
|
||||
assert_eq!(decoded, cursor);
|
||||
assert!(!encoded.contains("key_cursor"));
|
||||
for invalid in ["", "not-base64!", "e30"] {
|
||||
assert!(decode_client_key_cursor(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
let created_at = Utc
|
||||
.with_ymd_and_hms(2026, 7, 18, 8, 0, 0)
|
||||
.single()
|
||||
.expect("valid time");
|
||||
let view = ClientKeyView::from(gateway_admin::model::client_keys::ClientKeyRecord {
|
||||
id: gateway_core::policy::ClientApiKeyId::new("key_visible").expect("Client Key ID"),
|
||||
name: "visible".to_owned(),
|
||||
label: None,
|
||||
provider_kind: gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
|
||||
prefix: "sk_visible12".to_owned(),
|
||||
enabled: true,
|
||||
limits: gateway_core::policy::RateLimits {
|
||||
max_concurrency: 2,
|
||||
requests_per_minute: 60,
|
||||
},
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
});
|
||||
let list =
|
||||
serde_json::to_value(ClientKeyListData::new(vec![view], None, 1)).expect("serialize list");
|
||||
assert!(list.get("configRevision").is_none());
|
||||
assert_eq!(list["total"], 1);
|
||||
assert_eq!(list["items"][0]["id"], "key_visible");
|
||||
assert_eq!(list["items"][0]["providerKind"], "openai");
|
||||
assert_eq!(list["items"][0]["maxConcurrency"], 2);
|
||||
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
|
||||
assert!(list["items"][0].get("tokensPerMinute").is_none());
|
||||
assert!(list["items"][0].get("policyJson").is_none());
|
||||
assert_eq!(
|
||||
DateTime::parse_from_rfc3339(
|
||||
list["items"][0]["lastUsedAt"]
|
||||
.as_str()
|
||||
.expect("serialized last-used timestamp")
|
||||
)
|
||||
.expect("parse serialized last-used timestamp"),
|
||||
created_at.fixed_offset()
|
||||
);
|
||||
assert!(list["items"][0].get("plaintextKey").is_none());
|
||||
|
||||
let plaintext = format!("sk_{}", "a".repeat(43));
|
||||
let created = CreatedClientKeyData::new(
|
||||
"key_created".to_owned(),
|
||||
"sk_aaaaaaaaa".to_owned(),
|
||||
plaintext.clone(),
|
||||
);
|
||||
assert!(!format!("{created:?}").contains(&plaintext));
|
||||
let value = serde_json::to_value(created).expect("serialize created key");
|
||||
assert_eq!(value["plaintextKey"], plaintext);
|
||||
let revealed = RevealedClientKeyData::new("key_created".to_owned(), plaintext.clone());
|
||||
assert!(!format!("{revealed:?}").contains(&plaintext));
|
||||
assert_eq!(
|
||||
serde_json::to_value(revealed).expect("serialize revealed key"),
|
||||
json!({ "id": "key_created", "plaintextKey": plaintext })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
|
||||
.expect("serialize mutation"),
|
||||
json!({ "id": "key_created" })
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reveal_route_should_use_query_id_and_no_store_response() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = client_keys::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/client-keys/reveal?id=key_1")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_reveal")
|
||||
.body(Body::empty())
|
||||
.expect("reveal request"),
|
||||
)
|
||||
.await
|
||||
.expect("reveal response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(header::CACHE_CONTROL)
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("no-store")
|
||||
);
|
||||
let body = to_bytes(response.into_body(), 4096)
|
||||
.await
|
||||
.expect("reveal body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("reveal JSON");
|
||||
assert_eq!(value["data"]["id"], "key_1");
|
||||
assert_eq!(
|
||||
value["data"]["plaintextKey"],
|
||||
format!("sk_{}", "a".repeat(43))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_route_should_accept_the_full_nonzero_u16_page_size() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = client_keys::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/client-keys?limit=65535")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_client_keys_max_page")
|
||||
.body(Body::empty())
|
||||
.expect("client key list request"),
|
||||
)
|
||||
.await
|
||||
.expect("client key list response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DiagnosticDimension, DiagnosticObservation, OpsErrorPage,
|
||||
OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail, UsageFilter, UsageOverview,
|
||||
UsagePage, UsageQuery, UsageRecord,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
CredentialImportCommit, CredentialImportResult, CredentialListQuery,
|
||||
CredentialMutationResult, CredentialPage, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
|
||||
ProviderModels, ProviderQuota,
|
||||
},
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ModelMappings, ReplaceRuntimeSettings,
|
||||
RotationStrategy, RuntimeSettings,
|
||||
},
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
},
|
||||
ports::{
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
|
||||
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
},
|
||||
system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations,
|
||||
SystemUpdateEventStream,
|
||||
},
|
||||
},
|
||||
};
|
||||
use gateway_api::admin::AdminSessionState;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::{
|
||||
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
|
||||
},
|
||||
};
|
||||
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod client_keys;
|
||||
mod observability;
|
||||
mod settings;
|
||||
mod system;
|
||||
mod wire;
|
||||
|
||||
pub(super) struct AdminTestFixture {
|
||||
pub services: AdminServices,
|
||||
pub auth: Arc<MemoryAuthStore>,
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageRecord>>>,
|
||||
}
|
||||
|
||||
impl AdminTestFixture {
|
||||
pub async fn new() -> Self {
|
||||
Self::with_system(Arc::new(UnusedSystem)).await
|
||||
}
|
||||
|
||||
pub async fn with_system(system: Arc<dyn SystemOperations>) -> Self {
|
||||
let api_key = Arc::new(Mutex::new(None));
|
||||
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
|
||||
let settings = Arc::new(MemorySettingsStore::new(api_key));
|
||||
let client_keys = Arc::new(MemoryClientKeyStore);
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let unused = Arc::new(UnusedStore {
|
||||
usage_records: Arc::clone(&usage_records),
|
||||
});
|
||||
let stores = AdminStorePorts::new(
|
||||
unused.clone(),
|
||||
auth.clone(),
|
||||
client_keys.clone(),
|
||||
unused,
|
||||
settings.clone(),
|
||||
);
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai")),
|
||||
Arc::new(UnusedProvider::new("xai")),
|
||||
];
|
||||
let bundle = gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
session_ttl_minutes: 60,
|
||||
default_username: "admin_1".to_owned(),
|
||||
default_password: InitialAdminPassword::new("strong-admin-password"),
|
||||
},
|
||||
stores,
|
||||
providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
Arc::new(NoopProbe),
|
||||
system,
|
||||
)
|
||||
.await
|
||||
.expect("initialize test admin services");
|
||||
Self {
|
||||
services: bundle.services(),
|
||||
auth,
|
||||
settings,
|
||||
usage_records,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> AdminTestState {
|
||||
AdminTestState(self.services.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct AdminTestState(AdminServices);
|
||||
|
||||
impl AdminSessionState for AdminTestState {
|
||||
fn admin_services(&self) -> &AdminServices {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MemoryAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
api_key: Arc<Mutex<Option<AdminApiKey>>>,
|
||||
fail_audit: AtomicBool,
|
||||
}
|
||||
|
||||
impl MemoryAuthStore {
|
||||
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
|
||||
Self {
|
||||
password_hash: Mutex::new(None),
|
||||
sessions: Mutex::new(BTreeMap::new()),
|
||||
audits: Mutex::new(Vec::new()),
|
||||
api_key,
|
||||
fail_audit: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_session(&self, session_id: &str) {
|
||||
self.sessions.lock().expect("sessions").insert(
|
||||
session_id.to_owned(),
|
||||
AdminSession {
|
||||
admin_user_id: "admin_1".to_owned(),
|
||||
expires_at: Utc::now() + Duration::hours(1),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn set_api_key(&self, value: &str) {
|
||||
*self.api_key.lock().expect("API key") = Some(AdminApiKey::new(value));
|
||||
}
|
||||
|
||||
pub fn fail_audit(&self, fail: bool) {
|
||||
self.fail_audit.store(fail, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.sessions.lock().expect("sessions").len()
|
||||
}
|
||||
|
||||
pub fn audit_count(&self) -> usize {
|
||||
self.audits.lock().expect("audits").len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for MemoryAuthStore {
|
||||
async fn load_password_hash(&self, _: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(self.password_hash.lock().expect("password hash").clone())
|
||||
}
|
||||
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_: &str,
|
||||
password_hash: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut stored = self.password_hash.lock().expect("password hash");
|
||||
if stored.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
*stored = Some(password_hash.to_owned());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn load_admin_api_key(&self) -> AdminStoreResult<Option<AdminApiKey>> {
|
||||
Ok(self.api_key.lock().expect("API key").clone())
|
||||
}
|
||||
|
||||
async fn load_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Ok(self
|
||||
.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
.get(session_id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn store_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
session: &AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
self.sessions
|
||||
.lock()
|
||||
.expect("sessions")
|
||||
.insert(session_id.to_owned(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>> {
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
if self.fail_audit.load(Ordering::SeqCst) {
|
||||
return Err(unavailable("auth audit"));
|
||||
}
|
||||
self.audits.lock().expect("audits").push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MemorySettingsStore {
|
||||
settings: Mutex<RuntimeSettings>,
|
||||
api_key: Arc<Mutex<Option<AdminApiKey>>>,
|
||||
}
|
||||
|
||||
impl MemorySettingsStore {
|
||||
fn new(api_key: Arc<Mutex<Option<AdminApiKey>>>) -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(test_runtime_settings()),
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_api_key(&self, value: &str) {
|
||||
*self.api_key.lock().expect("API key") = Some(AdminApiKey::new(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SettingsStore for MemorySettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(self.settings.lock().expect("settings").clone())
|
||||
}
|
||||
|
||||
async fn admin_api_key_exists(&self) -> AdminStoreResult<bool> {
|
||||
Ok(self.api_key.lock().expect("API key").is_some())
|
||||
}
|
||||
|
||||
async fn replace_runtime_settings(
|
||||
&self,
|
||||
command: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
model_mappings: command.model_mappings,
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
max_concurrent_per_account: command.max_concurrent_per_account,
|
||||
request_interval_ms: command.request_interval_ms,
|
||||
rotation_strategy: command.rotation_strategy,
|
||||
usage_retention_days: command.usage_retention_days,
|
||||
ops_event_retention_days: command.ops_event_retention_days,
|
||||
audit_retention_days: command.audit_retention_days,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*settings = updated.clone();
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
key: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = Some(key);
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(revision),
|
||||
exists: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
*self.api_key.lock().expect("API key") = None;
|
||||
let revision = self.settings.lock().expect("settings").config_revision;
|
||||
Ok(AdminApiKeyMutation {
|
||||
config_revision: next_revision(revision),
|
||||
exists: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct MemoryClientKeyStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for MemoryClientKeyStore {
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
Ok(ClientKeyPage {
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
items: Vec::new(),
|
||||
total: 0,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn reveal_client_key(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
) -> AdminStoreResult<Option<ClientKeySecret>> {
|
||||
let now = Utc::now();
|
||||
Ok(Some(ClientKeySecret::new(
|
||||
ClientKeyRecord {
|
||||
id: id.clone(),
|
||||
name: "revealed".to_owned(),
|
||||
label: None,
|
||||
provider_kind: ProviderKind::new("openai").expect("provider kind"),
|
||||
prefix: "sk_aaaaaaaaa".to_owned(),
|
||||
enabled: true,
|
||||
limits: RateLimits::unlimited(),
|
||||
last_used_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
format!("sk_{}", "a".repeat(43)),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn create_client_key(
|
||||
&self,
|
||||
_: NewClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key create"))
|
||||
}
|
||||
|
||||
async fn update_client_key(
|
||||
&self,
|
||||
_: UpdateClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key update"))
|
||||
}
|
||||
|
||||
async fn set_client_key_enabled(
|
||||
&self,
|
||||
_: SetClientKeyEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<(Revision, ClientKeyRecord)> {
|
||||
Err(unavailable("client key enabled"))
|
||||
}
|
||||
|
||||
async fn delete_client_key(
|
||||
&self,
|
||||
_: DeleteClientKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("client key delete"))
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedStore {
|
||||
usage_records: Arc<Mutex<Vec<UsageRecord>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountStore for UnusedStore {
|
||||
async fn list_accounts(&self, _: AccountListQuery) -> AdminStoreResult<AccountPage> {
|
||||
Err(unavailable("account list"))
|
||||
}
|
||||
|
||||
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: &[String],
|
||||
) -> AdminStoreResult<Vec<AccountUsage>> {
|
||||
Err(unavailable("account usage"))
|
||||
}
|
||||
|
||||
async fn load_account_usage_by_windows(
|
||||
&self,
|
||||
_: &[AccountUsageWindowQuery],
|
||||
) -> AdminStoreResult<Vec<AccountUsageWindowResult>> {
|
||||
Err(unavailable("account quota window usage"))
|
||||
}
|
||||
|
||||
async fn list_credentials(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: CredentialListQuery,
|
||||
) -> AdminStoreResult<CredentialPage> {
|
||||
Err(unavailable("credential list"))
|
||||
}
|
||||
|
||||
async fn credential_details(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: &ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>> {
|
||||
Err(unavailable("credential"))
|
||||
}
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
_: &[ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>> {
|
||||
Err(unavailable("credential export"))
|
||||
}
|
||||
|
||||
async fn commit_credential_import(
|
||||
&self,
|
||||
_: CredentialImportCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialImportResult> {
|
||||
Err(unavailable("credential import"))
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
&self,
|
||||
_: AuthorizationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("authorization"))
|
||||
}
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
&self,
|
||||
_: CredentialRotationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("credential rotation"))
|
||||
}
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
&self,
|
||||
_: CredentialRotationCommit,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult> {
|
||||
Err(unavailable("credential refresh"))
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
&self,
|
||||
_: SetAccountEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("account enabled"))
|
||||
}
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
_: DeleteAccounts,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
Err(unavailable("account delete"))
|
||||
}
|
||||
|
||||
async fn record_credential_export(
|
||||
&self,
|
||||
_: &[ProviderAccountId],
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<()> {
|
||||
Err(unavailable("credential export audit"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnusedStore {
|
||||
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
Err(unavailable("dashboard"))
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Err(unavailable("dashboard trend"))
|
||||
}
|
||||
|
||||
async fn usage_trend(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
Err(unavailable("usage trend"))
|
||||
}
|
||||
|
||||
async fn usage_calculated_billing_facts(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
) -> AdminStoreResult<Vec<gateway_admin::model::observability::UsageCalculatedBillingFact>>
|
||||
{
|
||||
Err(unavailable("usage billing facts"))
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
Ok(UsagePage {
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
Err(unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
Err(unavailable("usage summary"))
|
||||
}
|
||||
|
||||
async fn usage_diagnostics(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
}
|
||||
|
||||
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
Err(unavailable("ops errors"))
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedProvider {
|
||||
kind: ProviderKind,
|
||||
}
|
||||
|
||||
impl UnusedProvider {
|
||||
fn new(kind: &str) -> Self {
|
||||
Self {
|
||||
kind: ProviderKind::new(kind).expect("provider kind"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for UnusedProvider {
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
async fn account_unavailable(&self, _: &ProviderAccountId) {}
|
||||
|
||||
fn connection_test_operation(
|
||||
&self,
|
||||
_: &gateway_core::routing::UpstreamModelId,
|
||||
_: &str,
|
||||
) -> Result<gateway_core::operation::Operation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
fn dashboard_wire_profile(
|
||||
&self,
|
||||
) -> Option<gateway_admin::model::observability::DashboardWireProfile> {
|
||||
None
|
||||
}
|
||||
|
||||
fn calculated_billing(
|
||||
&self,
|
||||
_: &gateway_admin::model::observability::ProviderBillingInput,
|
||||
) -> Result<
|
||||
Option<gateway_admin::model::observability::CalculatedBillingBreakdown>,
|
||||
ProviderAdminError,
|
||||
> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn prepare_import(
|
||||
&self,
|
||||
_: PrepareCredentialImport,
|
||||
) -> Result<PreparedCredentialImport, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn start_authorization(
|
||||
&self,
|
||||
_: PendingAuthorizationMutation,
|
||||
) -> Result<AuthorizationStarted, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn complete_authorization(
|
||||
&self,
|
||||
_: CompleteAuthorization,
|
||||
) -> Result<PreparedAuthorizationCommit, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn prepare_rotation(
|
||||
&self,
|
||||
_: PrepareCredentialRotation,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn prepare_refresh(
|
||||
&self,
|
||||
_: PrepareCredentialRefresh,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
_: gateway_admin::model::provider_credentials::ProviderQuotaRequest,
|
||||
) -> Result<ProviderQuota, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn models(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
_: bool,
|
||||
) -> Result<ProviderModels, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
|
||||
async fn export_credentials(
|
||||
&self,
|
||||
_: Vec<ProviderExportCredentialInput>,
|
||||
) -> Result<ProviderExport, ProviderAdminError> {
|
||||
Err(unsupported_provider())
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopSnapshot;
|
||||
|
||||
impl SnapshotControl for NoopSnapshot {
|
||||
fn publish_committed(&self, _: ConfigRevision) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopProbe;
|
||||
|
||||
impl AccountProbe for NoopProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async { panic!("unused account probe") })
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedSystem;
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for UnusedSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(futures::stream::empty())
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
|
||||
fn test_runtime_settings() -> RuntimeSettings {
|
||||
let mappings: ModelMappings = BTreeMap::from([
|
||||
(
|
||||
PublicModelId::new("coding-default").expect("public model"),
|
||||
UpstreamModelId::new("gpt-5.4").expect("upstream model"),
|
||||
),
|
||||
(
|
||||
PublicModelId::new("grok-latest").expect("public model"),
|
||||
UpstreamModelId::new("grok-4.5").expect("upstream model"),
|
||||
),
|
||||
]);
|
||||
RuntimeSettings {
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
model_mappings: mappings,
|
||||
refresh_margin_seconds: 3_600,
|
||||
refresh_concurrency: 2,
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 90,
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_revision(revision: Revision) -> Revision {
|
||||
Revision::new(revision.get().saturating_add(1)).expect("next revision")
|
||||
}
|
||||
|
||||
fn unavailable(resource: &'static str) -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
resource,
|
||||
"unused test port",
|
||||
)
|
||||
}
|
||||
|
||||
fn unsupported_provider() -> ProviderAdminError {
|
||||
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
|
||||
}
|
||||
|
||||
fn unavailable_system() -> SystemOperationError {
|
||||
SystemOperationError::new(SystemOperationErrorKind::Internal, "unused test operation")
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
mod query {
|
||||
use gateway_api::admin::observability::{
|
||||
DashboardQuery, DiagnosticDimension, DiagnosticsQuery, OpsQuery, TrendKind, UsageQuery,
|
||||
parse_attempt_index, parse_datetime, parse_status,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn dashboard_query_should_parse_terminal_trend_kinds() {
|
||||
let query: DashboardQuery = serde_json::from_value(json!({"kind": "errors"})).unwrap();
|
||||
assert_eq!(query.trend_kind().unwrap(), TrendKind::Errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_query_should_reject_unknown_trend_kind() {
|
||||
let query: DashboardQuery = serde_json::from_value(json!({"kind": "secret"})).unwrap();
|
||||
assert_eq!(query.trend_kind().unwrap_err().field(), "kind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_bound_page_size_and_cursor() {
|
||||
let query: UsageQuery = serde_json::from_value(json!({
|
||||
"page": 2,
|
||||
"pageSize": 100,
|
||||
"cursor": "opaque"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.validate_page().unwrap(), (2, 100));
|
||||
assert!(query.validate_cursor().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_zero_page() {
|
||||
let query: UsageQuery = serde_json::from_value(json!({"page": 0})).unwrap();
|
||||
assert_eq!(query.validate_page().unwrap_err().field(), "page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ops_query_should_reject_page_size_above_terminal_limit() {
|
||||
let query: OpsQuery = serde_json::from_value(json!({"pageSize": 101})).unwrap();
|
||||
assert_eq!(query.validate_page().unwrap_err().field(), "pageSize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_query_should_keep_wire_dimension_name() {
|
||||
let query: DiagnosticsQuery =
|
||||
serde_json::from_value(json!({"dimension": "failure_class"})).unwrap();
|
||||
assert_eq!(query.dimension().unwrap(), DiagnosticDimension::Failure);
|
||||
assert_eq!(DiagnosticDimension::Failure.display_name(), "failureClass");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_query_parsers_should_reject_out_of_range_values_without_echoing_input() {
|
||||
assert_eq!(parse_status(Some(99)).unwrap_err().field(), "statusCode");
|
||||
assert_eq!(
|
||||
parse_attempt_index(Some(0)).unwrap_err().field(),
|
||||
"attemptIndex"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_datetime(Some("not-a-time")).unwrap_err().field(),
|
||||
"timeRange"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod response {
|
||||
use chrono::{TimeZone, Utc};
|
||||
use gateway_admin::model::observability::DesktopReleaseStatus;
|
||||
use gateway_api::admin::observability::{
|
||||
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
|
||||
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
|
||||
DashboardWireProfileView, DashboardWireTargetView, PageData, PageMeta, TokenDetailsView,
|
||||
TrendData, TrendKind, TrendPointView, TrendSummaryView,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn usage_page_should_keep_terminal_camel_case_shape() {
|
||||
let data = PageData {
|
||||
items: vec![json!({"id": "request_1"})],
|
||||
page: PageMeta::new(1, 50, 1, 1),
|
||||
next_cursor: Some("cursor".to_owned()),
|
||||
};
|
||||
let value = serde_json::to_value(data).unwrap();
|
||||
assert_eq!(value["page"]["pageSize"], 50);
|
||||
assert_eq!(value["nextCursor"], "cursor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_wire_profiles_should_keep_provider_specific_attributes() {
|
||||
let value = serde_json::to_value(DashboardWireProfileView {
|
||||
provider: "xai".to_owned(),
|
||||
product: "Grok Build".to_owned(),
|
||||
version: "0.2.106".to_owned(),
|
||||
build: None,
|
||||
target: DashboardWireTargetView {
|
||||
os_type: "linux".to_owned(),
|
||||
os_version: "—".to_owned(),
|
||||
arch: "x86_64".to_owned(),
|
||||
terminal: "headless".to_owned(),
|
||||
},
|
||||
user_agent: "grok-shell/0.2.106 (linux; x86_64)".to_owned(),
|
||||
attributes: vec![DashboardWireAttributeView {
|
||||
label: "客户端标识".to_owned(),
|
||||
value: "grok-shell".to_owned(),
|
||||
}],
|
||||
verified_at: None,
|
||||
release: None,
|
||||
})
|
||||
.expect("dashboard profile");
|
||||
|
||||
assert_eq!(value["provider"], "xai");
|
||||
assert_eq!(value["version"], "0.2.106");
|
||||
assert_eq!(value["attributes"][0]["label"], "客户端标识");
|
||||
assert!(value.get("release").is_none());
|
||||
assert!(value.get("verifiedAt").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashboard_account_usage_should_keep_daily_request_timeline() {
|
||||
let bucket_start = Utc.timestamp_opt(0, 0).single().unwrap();
|
||||
let value = serde_json::to_value(DashboardAccountUsageView {
|
||||
id: "account_1".to_owned(),
|
||||
provider: "xai".to_owned(),
|
||||
authentication_kind: "oauth".to_owned(),
|
||||
email: "account@example.com".to_owned(),
|
||||
plan_type: Some("free".to_owned()),
|
||||
tokens: "—".to_owned(),
|
||||
request_count: 3,
|
||||
request_buckets: vec![DashboardAccountRequestBucketView {
|
||||
bucket_start,
|
||||
request_count: 3,
|
||||
}],
|
||||
quota_used_percent: None,
|
||||
last_used: "刚刚".to_owned(),
|
||||
})
|
||||
.expect("dashboard account usage");
|
||||
|
||||
assert_eq!(value["requestCount"], 3);
|
||||
assert_eq!(
|
||||
value["requestBuckets"][0]["bucketStart"],
|
||||
"1970-01-01T00:00:00Z"
|
||||
);
|
||||
assert_eq!(value["requestBuckets"][0]["requestCount"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trend_wire_should_serialize_kind_and_values_without_store_types() {
|
||||
let now = Utc.timestamp_opt(0, 0).single().unwrap();
|
||||
let data = TrendData {
|
||||
kind: TrendKind::Usage,
|
||||
points: vec![TrendPointView {
|
||||
time: "08:00".to_owned(),
|
||||
bucket: now,
|
||||
label: "01-01 08:00".to_owned(),
|
||||
requests: "1".to_owned(),
|
||||
requests_value: 1,
|
||||
input_tokens: "2".to_owned(),
|
||||
input_tokens_value: 2,
|
||||
output_tokens: "3".to_owned(),
|
||||
output_tokens_value: 3,
|
||||
cached_tokens: "0".to_owned(),
|
||||
cached_tokens_value: 0,
|
||||
cache_hit_rate_value: 0.0,
|
||||
tokens_value: 5,
|
||||
errors: "0".to_owned(),
|
||||
errors_value: 0,
|
||||
latency: "1 ms".to_owned(),
|
||||
latency_value: Some(1),
|
||||
first_token_latency: "1 ms".to_owned(),
|
||||
first_token_latency_value: Some(1),
|
||||
max_latency: "1 ms".to_owned(),
|
||||
max_latency_value: Some(1),
|
||||
min_latency: "1 ms".to_owned(),
|
||||
min_latency_value: Some(1),
|
||||
success_rate: "100.0%".to_owned(),
|
||||
success_rate_value: Some(100.0),
|
||||
}],
|
||||
summary: vec![TrendSummaryView {
|
||||
label: "输入".to_owned(),
|
||||
value: "2".to_owned(),
|
||||
ratio: None,
|
||||
}],
|
||||
};
|
||||
let value = serde_json::to_value(data).unwrap();
|
||||
assert_eq!(value["kind"], "usage");
|
||||
assert_eq!(value["points"][0]["requestsValue"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
|
||||
let coverage = CostCoverageView {
|
||||
known: 1,
|
||||
partial: 0,
|
||||
unknown: 0,
|
||||
not_billable: 0,
|
||||
};
|
||||
let token_details = TokenDetailsView {
|
||||
input_tokens: Some(1),
|
||||
output_tokens: Some(2),
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
image_input_tokens: None,
|
||||
image_output_tokens: None,
|
||||
total_tokens: Some(3),
|
||||
input_tokens_display: "1".to_owned(),
|
||||
output_tokens_display: "2".to_owned(),
|
||||
cached_tokens_display: "-".to_owned(),
|
||||
cache_write_tokens_display: "-".to_owned(),
|
||||
reasoning_tokens_display: "-".to_owned(),
|
||||
image_input_tokens_display: "-".to_owned(),
|
||||
image_output_tokens_display: "-".to_owned(),
|
||||
total_tokens_display: "3".to_owned(),
|
||||
};
|
||||
let cursor = CursorWire {
|
||||
observed_at: Utc.timestamp_opt(0, 0).single().unwrap(),
|
||||
stable_id: "request_1".to_owned(),
|
||||
};
|
||||
let value = serde_json::to_value((&coverage, &token_details, &cursor)).unwrap();
|
||||
assert!(value.to_string().contains("known"));
|
||||
assert!(!value.to_string().contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_view_should_preserve_the_original_detail_contract() {
|
||||
let value = serde_json::to_value(BillingView {
|
||||
input_amount_display: "$0.03".to_owned(),
|
||||
output_amount_display: "$0.00".to_owned(),
|
||||
cache_read_amount_display: "$0.14".to_owned(),
|
||||
cache_write_amount_display: "$0.00".to_owned(),
|
||||
standard_amount_display: "$0.17".to_owned(),
|
||||
total_amount_display: "$0.17".to_owned(),
|
||||
input_price_display: "$10.0000 / 1M Token".to_owned(),
|
||||
output_price_display: "$60.0000 / 1M Token".to_owned(),
|
||||
cache_read_price_display: "$1.0000 / 1M Token".to_owned(),
|
||||
cache_write_price_display: "$12.5000 / 1M Token".to_owned(),
|
||||
service_tier_display: "Fast".to_owned(),
|
||||
multiplier_display: "1.00x".to_owned(),
|
||||
})
|
||||
.expect("billing view");
|
||||
|
||||
assert_eq!(value["inputAmountDisplay"], "$0.03");
|
||||
assert_eq!(value["cacheReadPriceDisplay"], "$1.0000 / 1M Token");
|
||||
assert_eq!(value["serviceTierDisplay"], "Fast");
|
||||
assert_eq!(value["multiplierDisplay"], "1.00x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_release_status_should_preserve_the_existing_dashboard_wire_values() {
|
||||
for (domain, expected) in [
|
||||
(DesktopReleaseStatus::Unchecked, "unchecked"),
|
||||
(DesktopReleaseStatus::Current, "aligned"),
|
||||
(DesktopReleaseStatus::UpdateAvailable, "review_required"),
|
||||
(DesktopReleaseStatus::Failed, "check_failed"),
|
||||
] {
|
||||
let status = DashboardDesktopReleaseStatusView::from(domain);
|
||||
assert_eq!(serde_json::to_value(status).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_route_should_forward_a_bounded_unknown_outcome_filter() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records?outcome=provider_future_state")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_usage_other_outcome")
|
||||
.body(Body::empty())
|
||||
.expect("usage request"),
|
||||
)
|
||||
.await
|
||||
.expect("usage response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_route_should_expose_image_and_websocket_facts() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::{RequestOutcome, UsageRecord};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.usage_records
|
||||
.lock()
|
||||
.expect("usage records")
|
||||
.push(UsageRecord {
|
||||
id: "request_endpoint".to_owned(),
|
||||
client_api_key_ref: "key_endpoint".to_owned(),
|
||||
config_revision: 1,
|
||||
protocol: "openai".to_owned(),
|
||||
operation: "generate".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "grok-4.5".to_owned(),
|
||||
provider_kind: Some("xai".to_owned()),
|
||||
provider_account_ref: None,
|
||||
provider_account_name: None,
|
||||
provider_account_email: None,
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
http_version: Some("h2".to_owned()),
|
||||
websocket_pool: Some("reuse".to_owned()),
|
||||
service_tier: Some("priority".to_owned()),
|
||||
provider_metadata_json: Some(
|
||||
serde_json::json!({
|
||||
"effectiveModel": "grok-4.5",
|
||||
"requestSummary": {"inputItemsCount": 2},
|
||||
"transport": "must-not-overwrite-core",
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: None,
|
||||
outcome: RequestOutcome::Succeeded,
|
||||
client_status_code: Some(200),
|
||||
upstream_status_code: Some(200),
|
||||
client_response_id: Some("resp_usage_metadata".to_owned()),
|
||||
upstream_request_id: Some("upstream_usage_metadata".to_owned()),
|
||||
upstream_response_id: None,
|
||||
error_kind: None,
|
||||
provider_error_code: None,
|
||||
error_message: None,
|
||||
retry_after_ms: None,
|
||||
input_tokens: Some(1),
|
||||
output_tokens: Some(1),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(31),
|
||||
image_output_tokens: Some(9),
|
||||
total_tokens: Some(2),
|
||||
cost_source: "unavailable".to_owned(),
|
||||
cost_amount: None,
|
||||
cost_currency: None,
|
||||
billing: None,
|
||||
transport_decision_wait_ms: Some(7),
|
||||
connect_ms: Some(11),
|
||||
headers_ms: Some(13),
|
||||
first_event_ms: Some(17),
|
||||
first_reasoning_ms: Some(19),
|
||||
first_text_ms: Some(23),
|
||||
first_token_ms: Some(19),
|
||||
provider_processing_ms: Some(29),
|
||||
latency_ms: Some(31),
|
||||
client_ip: Some("127.0.0.1".to_owned()),
|
||||
user_agent: Some("usage-metadata-test".to_owned()),
|
||||
reasoning_effort: Some("max".to_owned()),
|
||||
reasoning_preset: Some("ultra".to_owned()),
|
||||
request_kind: Some("review".to_owned()),
|
||||
subagent_kind: Some("worker".to_owned()),
|
||||
compact: true,
|
||||
image_generation_requested: true,
|
||||
image_generation_succeeded: Some(true),
|
||||
started_at: Utc::now(),
|
||||
deadline_at: Utc::now(),
|
||||
completed_at: Some(Utc::now()),
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_usage_endpoint")
|
||||
.body(Body::empty())
|
||||
.expect("usage request"),
|
||||
)
|
||||
.await
|
||||
.expect("usage response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("usage response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage response JSON");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"serviceTier": value["data"]["items"][0]["serviceTier"],
|
||||
"authenticationKind": value["data"]["items"][0]["authenticationKind"],
|
||||
"imageInputTokens": value["data"]["items"][0]["tokenDetails"]["imageInputTokens"],
|
||||
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
|
||||
"websocketPool": value["data"]["items"][0]["metadata"]["websocketPool"],
|
||||
"imageGenerationRequested": value["data"]["items"][0]["metadata"]["imageGenerationRequested"],
|
||||
"imageGenerationSucceeded": value["data"]["items"][0]["metadata"]["imageGenerationSucceeded"],
|
||||
"requestedModel": value["data"]["items"][0]["metadata"]["requestedModel"],
|
||||
"upstreamModel": value["data"]["items"][0]["metadata"]["upstreamModel"],
|
||||
"clientIp": value["data"]["items"][0]["metadata"]["clientIp"],
|
||||
"userAgent": value["data"]["items"][0]["metadata"]["userAgent"],
|
||||
"reasoningEffort": value["data"]["items"][0]["metadata"]["reasoningEffort"],
|
||||
"reasoningPreset": value["data"]["items"][0]["metadata"]["reasoningPreset"],
|
||||
"requestKind": value["data"]["items"][0]["metadata"]["requestKind"],
|
||||
"subagentKind": value["data"]["items"][0]["metadata"]["subagentKind"],
|
||||
"compact": value["data"]["items"][0]["metadata"]["compact"],
|
||||
"transport": value["data"]["items"][0]["metadata"]["transport"],
|
||||
"transportDecisionWaitMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["transportDecisionWaitMs"],
|
||||
"wsConnectMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["wsConnectMs"],
|
||||
"firstReasoningMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstReasoningMs"],
|
||||
"firstTextMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTextMs"],
|
||||
"firstTokenMs": value["data"]["items"][0]["metadata"]["latencyDetails"]["firstTokenMs"],
|
||||
"effectiveModel": value["data"]["items"][0]["metadata"]["effectiveModel"],
|
||||
"requestSummary": value["data"]["items"][0]["metadata"]["requestSummary"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"route": "/v1/responses",
|
||||
"serviceTier": "priority",
|
||||
"authenticationKind": "oauth",
|
||||
"imageInputTokens": 31,
|
||||
"imageOutputTokens": 9,
|
||||
"websocketPool": {"kind": "reuse"},
|
||||
"imageGenerationRequested": true,
|
||||
"imageGenerationSucceeded": true,
|
||||
"requestedModel": "grok-4.5",
|
||||
"upstreamModel": "grok-4.5",
|
||||
"clientIp": "127.0.0.1",
|
||||
"userAgent": "usage-metadata-test",
|
||||
"reasoningEffort": "max",
|
||||
"reasoningPreset": "ultra",
|
||||
"requestKind": "review",
|
||||
"subagentKind": "worker",
|
||||
"compact": true,
|
||||
"transport": "http_sse",
|
||||
"transportDecisionWaitMs": 7,
|
||||
"wsConnectMs": 11,
|
||||
"firstReasoningMs": 19,
|
||||
"firstTextMs": 23,
|
||||
"firstTokenMs": 19,
|
||||
"effectiveModel": "grok-4.5",
|
||||
"requestSummary": {"inputItemsCount": 2},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use gateway_api::admin::settings::{self, UpdateRuntimeSettingsRequest};
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState};
|
||||
|
||||
fn app(state: AdminTestState) -> Router {
|
||||
settings::router::<AdminTestState>().with_state(state)
|
||||
}
|
||||
|
||||
fn request(method: Method, path: &str, body: Option<Value>) -> Request<Body> {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_admin_settings");
|
||||
let body = if let Some(value) = body {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
Body::from(value.to_string())
|
||||
} else {
|
||||
Body::empty()
|
||||
};
|
||||
builder.body(body).expect("build settings request")
|
||||
}
|
||||
|
||||
async fn response_json(response: axum::response::Response) -> Value {
|
||||
let bytes = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("read response body");
|
||||
serde_json::from_slice(&bytes).expect("parse response JSON")
|
||||
}
|
||||
|
||||
fn update_body() -> Value {
|
||||
json!({
|
||||
"modelMappings": {
|
||||
"gpt-5.4": "gpt-5.5",
|
||||
"grok-latest": "grok-4.5"
|
||||
},
|
||||
"refreshMarginSeconds": 1800,
|
||||
"refreshConcurrency": 4,
|
||||
"maxConcurrentPerAccount": 5,
|
||||
"requestIntervalMs": 25,
|
||||
"rotationStrategy": "round_robin",
|
||||
"usageRetentionDays": 32,
|
||||
"opsEventRetentionDays": 31,
|
||||
"auditRetentionDays": 91
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
let mut body = update_body();
|
||||
body["rotationStrategy"] = json!("random");
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
|
||||
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_unknown_revision_field() {
|
||||
let mut body = update_body();
|
||||
body["expectedConfigRevision"] = json!(7);
|
||||
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_removed_bucket_retention() {
|
||||
let mut body = update_body();
|
||||
body["bucketRetentionDays"] = json!(365);
|
||||
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(body).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_get_should_preserve_global_model_mappings() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.expect("settings response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
data["modelMappings"]["coding-default"].as_str(),
|
||||
data["modelMappings"]["grok-latest"].as_str(),
|
||||
data["rotationStrategy"].as_str()
|
||||
),
|
||||
(Some("gpt-5.4"), Some("grok-4.5"), Some("smart"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_post_should_replace_global_model_mappings() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/update",
|
||||
Some(update_body()),
|
||||
))
|
||||
.await
|
||||
.expect("settings update response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert!(data.get("configRevision").is_none());
|
||||
assert_eq!(data["modelMappings"]["gpt-5.4"], "gpt-5.5");
|
||||
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_invalid_model_mapping_name() {
|
||||
let mut body = update_body();
|
||||
body["modelMappings"] = json!({ "\0": "gpt-5.5" });
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
|
||||
assert_eq!(request.validate().unwrap_err().field(), "modelMappings");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_should_require_admin_auth() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let response = app(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/settings")
|
||||
.header("x-request-id", "req_unauthorized")
|
||||
.body(Body::empty())
|
||||
.expect("unauthorized request"),
|
||||
)
|
||||
.await
|
||||
.expect("unauthorized response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_key_should_return_secret_only_on_regenerate() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/admin-api-key/regenerate",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("regenerate response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert!(
|
||||
data["key"]
|
||||
.as_str()
|
||||
.is_some_and(|key| key.starts_with("admin-") && key.len() == 70)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_key_delete_should_use_fixed_post_path() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture.settings.set_api_key("admin-valid-test-key");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/admin-api-key/delete",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("delete response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_should_accept_admin_api_key_header() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let key = format!("admin-{}", "a".repeat(64));
|
||||
fixture.auth.set_api_key(&key);
|
||||
let response = app(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/settings")
|
||||
.header("x-api-key", key)
|
||||
.header("x-request-id", "req_api_key")
|
||||
.body(Body::empty())
|
||||
.expect("api key request"),
|
||||
)
|
||||
.await
|
||||
.expect("api key response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_auth_should_accept_a_configured_request_id_header_name() {
|
||||
use axum::http::HeaderName;
|
||||
use tower_http::request_id::{MakeRequestUuid, SetRequestIdLayer};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
// 部署把 api.request_id_header 改名后,注入的 header 不再叫 x-request-id;
|
||||
// 管理请求仍须拿到请求上下文,而不是退化为 500。
|
||||
let custom = HeaderName::from_static("x-trace-id");
|
||||
let app = app(fixture.state()).layer(SetRequestIdLayer::new(custom, MakeRequestUuid));
|
||||
let unlabelled = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/api/admin/settings")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.body(Body::empty())
|
||||
.expect("build settings request");
|
||||
|
||||
let response = app.oneshot(unlabelled).await.expect("settings response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use futures::stream;
|
||||
use gateway_admin::{
|
||||
model::system::{
|
||||
SystemOperationAccepted, SystemUpdateDetail, SystemUpdateEvent, SystemUpdateEventLevel,
|
||||
SystemUpdateStatus, SystemVersion,
|
||||
},
|
||||
ports::system::{SystemOperationError, SystemOperations, SystemUpdateEventStream},
|
||||
};
|
||||
use gateway_api::admin::system::{self, UpdateDetailQuery, UpdateRequest};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState, unavailable_system};
|
||||
|
||||
#[test]
|
||||
fn update_detail_query_should_default_to_no_refresh_and_reject_unknown_fields() {
|
||||
let query: UpdateDetailQuery = serde_json::from_str("{}").expect("empty query object");
|
||||
assert!(!query.refresh());
|
||||
assert!(serde_json::from_str::<UpdateDetailQuery>(r#"{"refresh":true,"extra":1}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_request_should_preserve_target_version_for_domain_validation() {
|
||||
let request: UpdateRequest =
|
||||
serde_json::from_str(r#"{"targetVersion":"v0.2.0"}"#).expect("update request");
|
||||
assert_eq!(request.into_target_version(), "v0.2.0");
|
||||
assert!(
|
||||
serde_json::from_str::<UpdateRequest>(r#"{"targetVersion":"v0.2.0","extra":1}"#).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_event_stream_should_preserve_event_id_and_download_progress_percent() {
|
||||
let fixture = AdminTestFixture::with_system(Arc::new(ProgressSystem)).await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/system/update/events")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_system_update_events")
|
||||
.body(Body::empty())
|
||||
.expect("update event request"),
|
||||
)
|
||||
.await
|
||||
.expect("update event response");
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("update event body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("UTF-8 event stream");
|
||||
|
||||
assert!(
|
||||
body.contains(r#""id":"update-progress-1""#),
|
||||
"unexpected update event stream: {body}"
|
||||
);
|
||||
assert!(
|
||||
body.contains(r#""progressPercent":40"#),
|
||||
"unexpected update event stream: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
fn app(state: AdminTestState) -> Router {
|
||||
system::router::<AdminTestState>().with_state(state)
|
||||
}
|
||||
|
||||
struct ProgressSystem;
|
||||
|
||||
#[async_trait]
|
||||
impl SystemOperations for ProgressSystem {
|
||||
async fn version(&self) -> Result<SystemVersion, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_detail(&self, _: bool) -> Result<SystemUpdateDetail, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
fn update_events(&self) -> SystemUpdateEventStream {
|
||||
Box::pin(stream::once(async {
|
||||
SystemUpdateEvent {
|
||||
id: "update-progress-1".to_owned(),
|
||||
operation_id: Some("update-1".to_owned()),
|
||||
level: SystemUpdateEventLevel::Info,
|
||||
step: Some("download".to_owned()),
|
||||
message: "已下载 4.0 MiB / 10.0 MiB (40%)".to_owned(),
|
||||
terminal: true,
|
||||
progress_percent: Some(40),
|
||||
occurred_at: Utc::now(),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn perform_update(
|
||||
&self,
|
||||
_: Option<String>,
|
||||
) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn update_status(&self) -> Result<SystemUpdateStatus, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn rollback(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
|
||||
async fn restart(&self) -> Result<SystemOperationAccepted, SystemOperationError> {
|
||||
Err(unavailable_system())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
mod common {
|
||||
use gateway_api::admin::PageMeta;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn page_meta_should_keep_the_existing_camel_case_wire_shape() {
|
||||
let value = serde_json::to_value(PageMeta::new(2, 10, 21, 3))
|
||||
.expect("page metadata should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"page": 2,
|
||||
"pageSize": 10,
|
||||
"total": 21,
|
||||
"totalPages": 3
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod error {
|
||||
use gateway_api::admin::{AdminErrorBody, AdminErrorCode};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn error_codes_should_keep_all_published_numeric_values() {
|
||||
let actual = [
|
||||
AdminErrorCode::MALFORMED_JSON,
|
||||
AdminErrorCode::BAD_REQUEST,
|
||||
AdminErrorCode::INVALID_TIME_RANGE,
|
||||
AdminErrorCode::INVALID_MODEL_SOURCE,
|
||||
AdminErrorCode::SESSION_REQUIRED,
|
||||
AdminErrorCode::INVALID_CREDENTIALS,
|
||||
AdminErrorCode::INVALID_API_KEY,
|
||||
AdminErrorCode::NOT_FOUND,
|
||||
AdminErrorCode::CONFLICT,
|
||||
AdminErrorCode::TOO_MANY_LOGIN_ATTEMPTS,
|
||||
AdminErrorCode::SETTINGS_PERSIST,
|
||||
AdminErrorCode::INTERNAL,
|
||||
AdminErrorCode::USAGE_RECORD_ACCOUNTS,
|
||||
AdminErrorCode::BAD_GATEWAY,
|
||||
AdminErrorCode::SERVICE_UNAVAILABLE,
|
||||
]
|
||||
.map(AdminErrorCode::value);
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
[
|
||||
40000, 40001, 40002, 40003, 40101, 40102, 40103, 40401, 40901, 42901, 50000, 50001,
|
||||
50002, 50201, 50301,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_admin_api_key_should_keep_code_and_null_data() {
|
||||
let value = serde_json::to_value(AdminErrorBody::new(
|
||||
AdminErrorCode::INVALID_API_KEY,
|
||||
"Invalid admin API key",
|
||||
))
|
||||
.expect("admin error body should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"code": 40103,
|
||||
"message": "Invalid admin API key",
|
||||
"data": null
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod response {
|
||||
use axum::{
|
||||
body::to_bytes,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_api::admin::{AdminEnvelope, AdminError, AdminPageData, AdminResponse, PageMeta};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn success_envelope_should_keep_the_existing_wire_shape() {
|
||||
let value = serde_json::to_value(AdminEnvelope::ok(json!({ "id": "resource_1" })))
|
||||
.expect("admin success envelope should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": { "id": "resource_1" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_envelope_should_keep_items_and_page_nested_under_data() {
|
||||
let data = AdminPageData::new(
|
||||
vec![json!({ "id": "resource_1" })],
|
||||
PageMeta::new(2, 10, 21, 3),
|
||||
);
|
||||
let value = serde_json::to_value(AdminEnvelope::ok(data))
|
||||
.expect("admin page envelope should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"code": 200,
|
||||
"message": "OK",
|
||||
"data": {
|
||||
"items": [{ "id": "resource_1" }],
|
||||
"page": {
|
||||
"page": 2,
|
||||
"pageSize": 10,
|
||||
"total": 21,
|
||||
"totalPages": 3
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async fn response_json(response: Response) -> serde_json::Value {
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("read admin response body");
|
||||
serde_json::from_slice(&body).expect("parse admin response body")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_response_should_keep_http_status_independent_from_envelope_code() {
|
||||
let response: Response = AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(json!({ "id": "resource_created" })),
|
||||
)
|
||||
.into_response();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert_eq!(
|
||||
(status, body["code"].as_u64()),
|
||||
(StatusCode::CREATED, Some(200))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_admin_api_key_should_keep_its_published_wire_contract() {
|
||||
let response = AdminError::invalid_admin_api_key().into_response();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert_eq!(
|
||||
(status, body),
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
json!({
|
||||
"code": 40103,
|
||||
"message": "Invalid admin API key",
|
||||
"data": null
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_error_variants_should_use_stable_http_and_null_data_contract() {
|
||||
let cases = [
|
||||
(
|
||||
AdminError::bad_request("bad input"),
|
||||
StatusCode::BAD_REQUEST,
|
||||
40001,
|
||||
),
|
||||
(
|
||||
AdminError::admin_session_required(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
40101,
|
||||
),
|
||||
(
|
||||
AdminError::invalid_admin_credentials(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
40102,
|
||||
),
|
||||
(
|
||||
AdminError::too_many_login_attempts(),
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
42901,
|
||||
),
|
||||
(
|
||||
AdminError::conflict("conflict"),
|
||||
StatusCode::CONFLICT,
|
||||
40901,
|
||||
),
|
||||
(
|
||||
AdminError::not_found("missing"),
|
||||
StatusCode::NOT_FOUND,
|
||||
40401,
|
||||
),
|
||||
(
|
||||
AdminError::internal("internal"),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
50001,
|
||||
),
|
||||
(
|
||||
AdminError::bad_gateway("upstream"),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
50201,
|
||||
),
|
||||
(
|
||||
AdminError::service_unavailable("storage"),
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
50301,
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected_status, expected_code) in cases {
|
||||
let response = error.into_response();
|
||||
let status = response.status();
|
||||
let body = response_json(response).await;
|
||||
|
||||
assert_eq!(status, expected_status);
|
||||
assert_eq!(body["code"], expected_code);
|
||||
assert!(body["message"].is_string());
|
||||
assert!(body["data"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
use std::{fs, path::Path};
|
||||
|
||||
#[test]
|
||||
fn manifest_should_depend_only_on_core_protocol_and_http_adapter_layers() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let forbidden = [
|
||||
"codex-proxy-rs",
|
||||
"gateway-store",
|
||||
"provider-",
|
||||
"redis",
|
||||
"reqwest",
|
||||
"sqlx",
|
||||
];
|
||||
|
||||
assert!(
|
||||
forbidden
|
||||
.iter()
|
||||
.all(|dependency| !manifest.contains(dependency)),
|
||||
"gateway-api manifest contains an infrastructure/provider dependency"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_should_include_gateway_api() {
|
||||
let workspace = include_str!("../../../Cargo.toml");
|
||||
|
||||
assert!(workspace.contains("\"crates/gateway-api\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_tree_should_match_frozen_machine_manifest() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut actual = rust_files(&root.join("src"));
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"src/admin/accounts.rs",
|
||||
"src/admin/auth.rs",
|
||||
"src/admin/client_keys.rs",
|
||||
"src/admin/mod.rs",
|
||||
"src/admin/observability.rs",
|
||||
"src/admin/settings.rs",
|
||||
"src/admin/system.rs",
|
||||
"src/admin/wire.rs",
|
||||
"src/health.rs",
|
||||
"src/lib.rs",
|
||||
"src/openai/auth.rs",
|
||||
"src/openai/error.rs",
|
||||
"src/openai/mod.rs",
|
||||
"src/openai/models.rs",
|
||||
"src/openai/responses/error.rs",
|
||||
"src/openai/responses/http.rs",
|
||||
"src/openai/responses/mod.rs",
|
||||
"src/openai/responses/request.rs",
|
||||
"src/openai/responses/response.rs",
|
||||
"src/openai/responses/websocket.rs",
|
||||
"src/openai/router.rs",
|
||||
"src/openai/service.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tree_should_match_frozen_rust_mirror() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut actual = rust_files(&root.join("tests"));
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"tests/admin/accounts.rs",
|
||||
"tests/admin/auth.rs",
|
||||
"tests/admin/client_keys.rs",
|
||||
"tests/admin/mod.rs",
|
||||
"tests/admin/observability.rs",
|
||||
"tests/admin/settings.rs",
|
||||
"tests/admin/system.rs",
|
||||
"tests/admin/wire.rs",
|
||||
"tests/architecture.rs",
|
||||
"tests/health.rs",
|
||||
"tests/main.rs",
|
||||
"tests/openai/auth.rs",
|
||||
"tests/openai/error.rs",
|
||||
"tests/openai/mod.rs",
|
||||
"tests/openai/models.rs",
|
||||
"tests/openai/responses/http.rs",
|
||||
"tests/openai/responses/mod.rs",
|
||||
"tests/openai/responses/websocket.rs",
|
||||
"tests/openai/router.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_openai_fixture_set_should_be_complete() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/openai/responses/fixtures");
|
||||
let files = all_files(&root);
|
||||
|
||||
assert_eq!(files.len(), 69);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_routes_should_use_only_get_post_and_static_paths() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/admin");
|
||||
let mut combined = String::new();
|
||||
for path in all_files(&root) {
|
||||
if path.extension().and_then(|value| value.to_str()) == Some("rs") {
|
||||
combined.push_str(&fs::read_to_string(path).expect("read admin source"));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
!combined.contains("routing::put")
|
||||
&& !combined.contains("routing::patch")
|
||||
&& !combined.contains("routing::delete")
|
||||
&& !combined.contains("/:id")
|
||||
&& !combined.contains("/{id}")
|
||||
);
|
||||
}
|
||||
|
||||
fn rust_files(root: &Path) -> Vec<String> {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
all_files(root)
|
||||
.into_iter()
|
||||
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("rs"))
|
||||
.map(|path| {
|
||||
path.strip_prefix(manifest)
|
||||
.expect("path below manifest")
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn all_files(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
let mut pending = vec![root.to_path_buf()];
|
||||
while let Some(path) = pending.pop() {
|
||||
for entry in fs::read_dir(path).expect("read architecture directory") {
|
||||
let path = entry.expect("read architecture entry").path();
|
||||
if path.is_dir() {
|
||||
pending.push(path);
|
||||
} else {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
|
||||
StartedExecution,
|
||||
},
|
||||
error::GatewayError,
|
||||
routing::PublicModelId,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn healthz_should_return_no_content_when_all_inputs_are_healthy() {
|
||||
let response = crate::openai::api_router(Arc::new(UnusedExecution))
|
||||
.await
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/healthz")
|
||||
.body(Body::empty())
|
||||
.expect("health request"),
|
||||
)
|
||||
.await
|
||||
.expect("health response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
struct UnusedExecution;
|
||||
|
||||
impl ExecutionService for UnusedExecution {
|
||||
fn authenticate(&self, _: &str) -> Result<AuthenticatedClient, ClientAuthenticationError> {
|
||||
unreachable!("health check does not authenticate")
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
unreachable!("health check does not list models")
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
|
||||
unreachable!("health check does not inspect models")
|
||||
}
|
||||
|
||||
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async { unreachable!("health check does not execute requests") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
mod admin;
|
||||
mod architecture;
|
||||
mod health;
|
||||
mod openai;
|
||||
@@ -0,0 +1,90 @@
|
||||
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
|
||||
|
||||
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_missing_authorization() {
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&HeaderMap::new()),
|
||||
Err(ClientApiKeyAuthError::MissingAuthorization)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_non_utf8_authorization() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_bytes(&[0xff]).expect("opaque header value"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::MalformedAuthorization)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_require_exact_bearer_scheme() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("bearer sk_client"));
|
||||
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::MalformedAuthorization)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_empty_bearer_token() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer "));
|
||||
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::MalformedAuthorization)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_non_gateway_key_prefix() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, HeaderValue::from_static("Bearer xai-secret"));
|
||||
|
||||
assert_eq!(
|
||||
bearer_client_api_key(&headers),
|
||||
Err(ClientApiKeyAuthError::InvalidKeyFormat)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_return_trimmed_gateway_key() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
AUTHORIZATION,
|
||||
HeaderValue::from_static("Bearer sk_client_secret "),
|
||||
);
|
||||
|
||||
assert_eq!(bearer_client_api_key(&headers), Ok("sk_client_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
|
||||
assert_eq!(
|
||||
[
|
||||
ClientApiKeyAuthError::MissingAuthorization,
|
||||
ClientApiKeyAuthError::MalformedAuthorization,
|
||||
ClientApiKeyAuthError::InvalidKeyFormat,
|
||||
ClientApiKeyAuthError::InvalidKey,
|
||||
ClientApiKeyAuthError::RuntimeUnavailable,
|
||||
]
|
||||
.map(ClientApiKeyAuthError::reason),
|
||||
[
|
||||
"missing_authorization",
|
||||
"malformed_authorization",
|
||||
"invalid_key_format",
|
||||
"invalid_key",
|
||||
"runtime_unavailable",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
use axum::{body::to_bytes, http::StatusCode};
|
||||
use gateway_core::engine::{EngineError, UpstreamSendState};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
|
||||
StoreError, StoreErrorKind,
|
||||
};
|
||||
|
||||
use gateway_api::openai::error::{
|
||||
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
|
||||
openai_error_response,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn invalid_request_error_should_map_to_openai_bad_request() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::InvalidRequest),
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
"invalid_request",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_error_should_map_to_openai_bad_request() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::Unsupported),
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
"unsupported_capability",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_not_found_error_should_map_to_openai_model_not_found() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::ModelNotFound),
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
"invalid_request_error",
|
||||
"model_not_found",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_available_provider_error_should_map_to_service_unavailable() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::NoAvailableProvider),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"no_available_provider",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_error_should_map_to_openai_retryable_status() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::RateLimited),
|
||||
(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
"rate_limit_exceeded",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_unavailable_error_should_map_to_bad_gateway() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::UpstreamUnavailable),
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"server_error",
|
||||
"upstream_unavailable",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_error_should_map_to_gateway_timeout() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::Timeout),
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
"server_error",
|
||||
"request_timeout",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_error_should_not_be_misclassified_as_upstream_failure() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::Cancelled),
|
||||
(
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
"server_error",
|
||||
"request_cancelled",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_error_should_preserve_retry_classification() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::RateLimited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_preserve_safe_provider_detail() {
|
||||
let detail = ClientVisibleUpstreamError::new(
|
||||
"no account is eligible for the requested model",
|
||||
Some("no_eligible_account".to_owned()),
|
||||
Some("account_unavailable_error".to_owned()),
|
||||
);
|
||||
let error = EngineError::Provider(
|
||||
ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent)
|
||||
.with_client_visible_upstream_error(detail),
|
||||
);
|
||||
let gateway = gateway_error_from_engine(&error);
|
||||
|
||||
assert_eq!(gateway.kind(), GatewayErrorKind::NoAvailableProvider);
|
||||
assert_eq!(
|
||||
gateway.client_message(),
|
||||
"no account is eligible for the requested model"
|
||||
);
|
||||
assert_eq!(gateway.client_error_code(), Some("no_eligible_account"));
|
||||
assert_eq!(
|
||||
gateway.client_error_type(),
|
||||
Some("account_unavailable_error")
|
||||
);
|
||||
assert_eq!(
|
||||
gateway.safe_message(),
|
||||
"no upstream provider is currently available for this request"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::NoEligibleAccount,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_provider_unavailability_should_remain_bad_gateway() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::UpstreamUnavailable
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_invalid_request_should_remain_a_client_request_error() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::InvalidRequest
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_unsupported_capability_should_remain_unsupported() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unsupported,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::Unsupported
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_credential_failures_should_not_impersonate_client_auth_failures() {
|
||||
for kind in [
|
||||
ProviderErrorKind::Unauthorized,
|
||||
ProviderErrorKind::PermissionDenied,
|
||||
] {
|
||||
let error = EngineError::Provider(ProviderError::new(kind, UpstreamSendState::Sent));
|
||||
let mapped = gateway_error_from_engine(&error);
|
||||
|
||||
assert_eq!(mapped.kind(), GatewayErrorKind::UpstreamUnavailable);
|
||||
assert_eq!(
|
||||
mapped.safe_message(),
|
||||
"upstream authentication resource is unavailable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_quota_exhaustion_should_use_the_retryable_capacity_contract() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::QuotaExhausted,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::RateLimited
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_timeout_and_cancellation_should_remain_distinct() {
|
||||
let timeout = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Timeout,
|
||||
UpstreamSendState::Ambiguous,
|
||||
));
|
||||
let cancelled = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Cancelled,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
gateway_error_from_engine(&timeout).kind(),
|
||||
gateway_error_from_engine(&cancelled).kind(),
|
||||
),
|
||||
(GatewayErrorKind::Timeout, GatewayErrorKind::Cancelled)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_provider_runtime_failures_should_collapse_to_safe_unavailability() {
|
||||
for kind in [
|
||||
ProviderErrorKind::Transport,
|
||||
ProviderErrorKind::Protocol,
|
||||
ProviderErrorKind::Unavailable,
|
||||
ProviderErrorKind::ProcessTerminated,
|
||||
] {
|
||||
let error = EngineError::Provider(ProviderError::new(kind, UpstreamSendState::Ambiguous));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::UpstreamUnavailable,
|
||||
"upstream service is unavailable"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_store_error_should_collapse_to_safe_internal_error() {
|
||||
let error = EngineError::Store(StoreError::new(StoreErrorKind::Unavailable));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn openai_error_response_should_preserve_only_safe_contract_fields() {
|
||||
let (status, body) = openai_error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"upstream service is unavailable",
|
||||
"server_error",
|
||||
"upstream_unavailable",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(status, body.0),
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"message": "upstream service is unavailable",
|
||||
"type": "server_error",
|
||||
"code": "upstream_unavailable"
|
||||
}
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_error_response_should_expose_only_structured_client_visible_upstream_fields() {
|
||||
let error = GatewayError::from_provider(
|
||||
&ProviderError::new(ProviderErrorKind::QuotaExhausted, UpstreamSendState::Sent)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Your Codex quota is exhausted",
|
||||
Some("quota_exhausted".to_owned()),
|
||||
Some("rate_limit_error".to_owned()),
|
||||
)),
|
||||
);
|
||||
|
||||
let response = gateway_error_response(&error);
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read OpenAI error body");
|
||||
let body: serde_json::Value = serde_json::from_slice(&body).expect("OpenAI error JSON");
|
||||
|
||||
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"message": "Your Codex quota is exhausted",
|
||||
"type": "rate_limit_error",
|
||||
"code": "quota_exhausted"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
mod auth;
|
||||
mod error;
|
||||
mod models;
|
||||
mod responses;
|
||||
mod router;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::admission::{
|
||||
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
|
||||
ClientAdmissionRequest, ClientAdmissionRestoreResult,
|
||||
};
|
||||
use gateway_core::engine::continuation::{
|
||||
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{AccountSelectionPolicy, RotationStrategy};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
|
||||
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
|
||||
};
|
||||
use gateway_core::engine::provider::ProviderRegistry;
|
||||
use gateway_core::engine::{
|
||||
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
|
||||
NewModelRequest, RecoveryReport, UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::StoreError;
|
||||
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
|
||||
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot,
|
||||
UpstreamModelId,
|
||||
};
|
||||
|
||||
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
|
||||
api_router_with_origins(execution, Vec::new()).await
|
||||
}
|
||||
|
||||
pub(super) async fn api_router_with_origins(
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
cors_allowed_origins: Vec<String>,
|
||||
) -> axum::Router {
|
||||
let admin = crate::admin::AdminTestFixture::new().await;
|
||||
gateway_api::initialize(
|
||||
gateway_api::ApiConfig {
|
||||
asset_directory: std::env::temp_dir(),
|
||||
cors_allowed_origins,
|
||||
request_timeout_seconds: None,
|
||||
request_id_header: "x-request-id".to_owned(),
|
||||
},
|
||||
execution,
|
||||
admin.services,
|
||||
Vec::new(),
|
||||
Arc::new(EmptyWorkerHealth),
|
||||
Arc::new(TestLifecycle::default()),
|
||||
)
|
||||
.expect("API bundle")
|
||||
.router()
|
||||
}
|
||||
|
||||
pub(super) fn authenticated_client(plaintext: &str) -> AuthenticatedClient {
|
||||
authenticated_client_for_provider(plaintext, "openai")
|
||||
}
|
||||
|
||||
pub(super) fn authenticated_client_for_provider(
|
||||
plaintext: &str,
|
||||
provider_name: &str,
|
||||
) -> AuthenticatedClient {
|
||||
let source = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot(plaintext, provider_name)),
|
||||
Arc::new(UnusedExecutionStore),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedCircuits),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(IgnoredClientApiKeyUsage),
|
||||
);
|
||||
source
|
||||
.authenticate(plaintext)
|
||||
.expect("authenticated client")
|
||||
}
|
||||
|
||||
struct IgnoredClientApiKeyUsage;
|
||||
|
||||
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
|
||||
fn record_used(&self, _: &ClientApiKeyId) {}
|
||||
}
|
||||
|
||||
fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
|
||||
let provider = ProviderKind::new(provider_name).expect("provider");
|
||||
let capabilities = ModelCapabilities::new(
|
||||
BTreeSet::from([gateway_core::operation::OperationKind::Generate]),
|
||||
Some(16_000),
|
||||
);
|
||||
RuntimeSnapshot::new(
|
||||
ConfigRevision::new(1).expect("revision"),
|
||||
AccountSelectionPolicy::new(
|
||||
RotationStrategy::Smart,
|
||||
NonZeroU32::new(2).expect("concurrency"),
|
||||
Duration::from_millis(1),
|
||||
),
|
||||
vec![provider.clone()],
|
||||
["model-a", "model-b"]
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
ProviderModel::new(
|
||||
provider.clone(),
|
||||
UpstreamModelId::new(model).expect("model"),
|
||||
capabilities.clone(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
vec![ClientPolicy::new(
|
||||
ClientApiKeyId::new("key_api_test").expect("key ID"),
|
||||
PlaintextClientApiKey::new(plaintext).expect("plaintext key"),
|
||||
provider,
|
||||
true,
|
||||
RateLimits::unlimited(),
|
||||
)],
|
||||
)
|
||||
.expect("runtime snapshot")
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestLifecycle {
|
||||
cancellation: gateway_core::engine::CancellationToken,
|
||||
}
|
||||
|
||||
struct TestConnectionGuard;
|
||||
|
||||
impl ConnectionGuard for TestConnectionGuard {}
|
||||
|
||||
impl ConnectionLifecycle for TestLifecycle {
|
||||
fn try_register(&self) -> Result<Box<dyn ConnectionGuard>, ConnectionDraining> {
|
||||
Ok(Box::new(TestConnectionGuard))
|
||||
}
|
||||
|
||||
fn cancellation(&self) -> gateway_core::engine::CancellationToken {
|
||||
self.cancellation.clone()
|
||||
}
|
||||
|
||||
fn is_draining(&self) -> bool {
|
||||
self.cancellation.is_cancelled()
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyWorkerHealth;
|
||||
|
||||
impl WorkerHealthSource for EmptyWorkerHealth {
|
||||
fn snapshot(&self) -> Vec<WorkerHealthSnapshot> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedExecutionStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutionStore for UnusedExecutionStore {
|
||||
async fn create_model_request(&self, _: NewModelRequest) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn record_attempt(&self, _: AttemptRecord) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn mark_send_state(
|
||||
&self,
|
||||
_: &ModelRequestId,
|
||||
_: UpstreamSendState,
|
||||
) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn mark_downstream_committed(
|
||||
&self,
|
||||
_: &ModelRequestId,
|
||||
_: SystemTime,
|
||||
_: Option<u16>,
|
||||
) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn record_client_status(&self, _: &ModelRequestId, _: u16) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn record_intermediate_failure(&self, _: IntermediateFailure) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn finalize_model_request(&self, _: ModelRequestFinalization) -> Result<(), StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
|
||||
async fn recover_expired(&self, _: SystemTime) -> Result<RecoveryReport, StoreError> {
|
||||
unreachable!("authentication fixture does not execute")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedAdmissions;
|
||||
|
||||
impl ClientAdmissionPort for UnusedAdmissions {
|
||||
fn admit(
|
||||
&self,
|
||||
_: ClientAdmissionRequest,
|
||||
) -> BoxFuture<'_, Result<ClientAdmissionDecision, ClientAdmissionError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
|
||||
fn release<'a>(
|
||||
&'a self,
|
||||
_: &'a ClientApiKeyId,
|
||||
_: &'a ModelRequestId,
|
||||
) -> BoxFuture<'a, Result<bool, ClientAdmissionError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
|
||||
fn restore(
|
||||
&self,
|
||||
_: ClientAdmissionRecovery,
|
||||
) -> BoxFuture<'_, Result<ClientAdmissionRestoreResult, ClientAdmissionError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedCircuits;
|
||||
|
||||
impl ProviderCircuitPort for UnusedCircuits {
|
||||
fn decision<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<ProviderCircuitDecision, ProviderCircuitError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
|
||||
fn observe_failure<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
|
||||
fn observe_success<'a>(
|
||||
&'a self,
|
||||
_: &'a ProviderKind,
|
||||
) -> BoxFuture<'a, Result<(), ProviderCircuitError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedContinuation;
|
||||
|
||||
impl NativeContinuationPort for UnusedContinuation {
|
||||
fn resolve<'a>(
|
||||
&'a self,
|
||||
_: &'a PreviousResponseId,
|
||||
) -> BoxFuture<'a, Result<Option<NativeContinuationPin>, NativeContinuationStoreError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
|
||||
fn record<'a>(
|
||||
&'a self,
|
||||
_: NativeContinuationPin,
|
||||
) -> BoxFuture<'a, Result<(), NativeContinuationStoreError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, StartExecution,
|
||||
StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::routing::{
|
||||
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
pub(super) struct ModelsExecution {
|
||||
client: AuthenticatedClient,
|
||||
profiles: bool,
|
||||
}
|
||||
|
||||
impl ModelsExecution {
|
||||
pub(super) fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
profiles: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_profiles() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
profiles: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionService for ModelsExecution {
|
||||
fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
|
||||
if plaintext == "sk_models_test" {
|
||||
Ok(self.client.clone())
|
||||
} else {
|
||||
Err(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
["model-a", "model-b"]
|
||||
.into_iter()
|
||||
.map(|model| PublicModelId::new(model).expect("model"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn public_model_profiles(&self, _: &AuthenticatedClient) -> Vec<PublicModelProfile> {
|
||||
if !self.profiles {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![PublicModelProfile::new(
|
||||
PublicModelId::new("grok-4.5").expect("model"),
|
||||
ModelPresentation::new(
|
||||
Some("Grok 4.5".to_owned()),
|
||||
Some("xAI Grok 4.5 frontier model.".to_owned()),
|
||||
)
|
||||
.with_reasoning(
|
||||
Some("medium".to_owned()),
|
||||
["low", "medium", "high", "xhigh"]
|
||||
.map(str::to_owned)
|
||||
.to_vec(),
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
.with_agent_tools(true, true)
|
||||
.with_service_tiers(vec![
|
||||
ModelServiceTier::new(
|
||||
"priority",
|
||||
"Fast",
|
||||
"Route the request through Codex fast mode.",
|
||||
)
|
||||
.with_speed_tier("fast"),
|
||||
]),
|
||||
)]
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
matches!(model.as_str(), "model-a" | "model-b")
|
||||
}
|
||||
|
||||
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"models test must not start a response",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn authorized_request(path: &str) -> Request<Body> {
|
||||
Request::get(path)
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build models request")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_encode_the_service_visible_catalog() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models"))
|
||||
.await
|
||||
.expect("list models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id":"model-a","object":"model","created":1700000000_i64,"owned_by":"gateway"},
|
||||
{"id":"model-b","object":"model","created":1700000000_i64,"owned_by":"gateway"}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
let response = api_router(ModelsExecution::with_profiles())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
|
||||
.await
|
||||
.expect("list Codex models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read Codex models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
|
||||
let model = &value["models"][0];
|
||||
|
||||
assert_eq!(
|
||||
value
|
||||
.as_object()
|
||||
.map(|catalog| catalog.keys().map(String::as_str).collect::<Vec<_>>()),
|
||||
Some(vec!["models"])
|
||||
);
|
||||
assert_eq!(model["slug"], "grok-4.5");
|
||||
assert_eq!(model["default_reasoning_level"], "medium");
|
||||
assert_eq!(model["context_window"], 500_000);
|
||||
assert_eq!(model["apply_patch_tool_type"], "freeform");
|
||||
assert_eq!(model["additional_speed_tiers"], serde_json::json!(["fast"]));
|
||||
assert_eq!(
|
||||
model["service_tiers"],
|
||||
serde_json::json!([{
|
||||
"id": "priority",
|
||||
"name": "Fast",
|
||||
"description": "Route the request through Codex fast mode."
|
||||
}])
|
||||
);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"],
|
||||
serde_json::json!([
|
||||
{
|
||||
"effort": "low",
|
||||
"description": "Fast responses with lighter reasoning"
|
||||
},
|
||||
{
|
||||
"effort": "medium",
|
||||
"description": "Balances speed and reasoning depth for everyday tasks"
|
||||
},
|
||||
{
|
||||
"effort": "high",
|
||||
"description": "Greater reasoning depth for complex problems"
|
||||
},
|
||||
{
|
||||
"effort": "xhigh",
|
||||
"description": "Extra high reasoning depth for complex problems"
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_keep_the_codex_contract_when_profiles_are_empty() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models?client_version=0.145.0"))
|
||||
.await
|
||||
.expect("list empty Codex models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read empty Codex models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("Codex models JSON");
|
||||
|
||||
assert_eq!(value, serde_json::json!({ "models": [] }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_with_provider_profiles_should_keep_the_openai_list_contract() {
|
||||
let response = api_router(ModelsExecution::with_profiles())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models"))
|
||||
.await
|
||||
.expect("list compatible models response");
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read compatible models body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("models JSON");
|
||||
|
||||
assert_eq!(
|
||||
value,
|
||||
serde_json::json!({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id":"model-a","object":"model","created":1700000000_i64,"owned_by":"gateway"},
|
||||
{"id":"model-b","object":"model","created":1700000000_i64,"owned_by":"gateway"}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_detail_should_keep_the_official_path_id_contract() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-a"))
|
||||
.await
|
||||
.expect("model detail response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_detail_should_hide_unknown_models() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-private"))
|
||||
.await
|
||||
.expect("unknown model response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_catalog_should_keep_the_codex_catalog_contract() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/catalog"))
|
||||
.await
|
||||
.expect("catalog response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read catalog body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("catalog JSON");
|
||||
assert_eq!(value[0]["id"], "model-a");
|
||||
assert_eq!(value[0]["displayName"], "model-a");
|
||||
assert_eq!(value[0]["source"], "gateway");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_info_should_return_a_single_visible_catalog_entry() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-a/info"))
|
||||
.await
|
||||
.expect("model info response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_info_should_hide_unknown_models() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-private/info"))
|
||||
.await
|
||||
.expect("missing model info response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_require_a_valid_bearer_client_key() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models")
|
||||
.body(Body::empty())
|
||||
.expect("build unauthenticated request"),
|
||||
)
|
||||
.await
|
||||
.expect("unauthenticated models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
+5
-5
@@ -1,5 +1,5 @@
|
||||
event: response.completed
|
||||
data: {}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
event: response.completed
|
||||
data: {}
|
||||
|
||||
data: [DONE]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user