mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef37ae8d9e | ||
|
|
fc13d4f2b6 | ||
|
|
78f9c0c800 | ||
|
|
a3bc7bc445 | ||
|
|
0132114285 | ||
|
|
5970efbb7c | ||
|
|
7bf28b571d | ||
|
|
bc3bd6bc43 | ||
|
|
9acfab0a86 | ||
|
|
3e5a84d2a0 | ||
|
|
92948f51a9 | ||
|
|
776637fccb | ||
|
|
82acb08d26 | ||
|
|
7744e1b0b4 | ||
|
|
0b8a1add18 | ||
|
|
2dd60c08b6 | ||
|
|
cce9880937 | ||
|
|
b8ea1cd076 | ||
|
|
4117840f75 | ||
|
|
f642764f34 | ||
|
|
482e192e2d | ||
|
|
ee51a07de4 | ||
|
|
bf23658aaf | ||
|
|
bc4728332e | ||
|
|
2cb5cda077 | ||
|
|
9d90dedb81 | ||
|
|
6074cff68c | ||
|
|
6ab8e7d0a6 | ||
|
|
17f9ef8915 | ||
|
|
15e0a28bd0 | ||
|
|
58bc438992 | ||
|
|
b04616f59f | ||
|
|
82ba4fcbba | ||
|
|
819f428ac3 | ||
|
|
35f28e8aab | ||
|
|
65645c7403 | ||
|
|
71159d5633 | ||
|
|
f2ebb09e34 | ||
|
|
07acc67ce6 | ||
|
|
01ad8bba8a | ||
|
|
986a128d15 | ||
|
|
6cad2bb6a1 | ||
|
|
2b8b3de0e4 | ||
|
|
4127886584 | ||
|
|
ee3e5db6c2 | ||
|
|
153cb67b9c | ||
|
|
f370d355b6 | ||
|
|
a31dad992c | ||
|
|
55a88f5f6b | ||
|
|
5d90a92582 | ||
|
|
93abe8efcd | ||
|
|
f4b75c42ed | ||
|
|
36ff038c78 | ||
|
|
545009590c | ||
|
|
5b7b9b21ee | ||
|
|
44a2a87b69 | ||
|
|
184e88083b | ||
|
|
53b40aea46 | ||
|
|
aa328b6dbf | ||
|
|
64231ff403 | ||
|
|
d7b80a2066 | ||
|
|
08d63ea3f3 | ||
|
|
ac9c4c85ad | ||
|
|
492c401599 | ||
|
|
a063258a40 | ||
|
|
dc590f2b5f | ||
|
|
ca52e2bfd9 | ||
|
|
43604e36dc | ||
|
|
a4a997e351 | ||
|
|
73ca463924 | ||
|
|
9eeec2c78c | ||
|
|
90890be2b4 | ||
|
|
2e4bab17a8 | ||
|
|
b052e79a16 | ||
|
|
9775451e83 | ||
|
|
c22a2ad144 | ||
|
|
7bd362d544 | ||
|
|
e7036a616f | ||
|
|
98652d4c68 | ||
|
|
d0dc7ef92b | ||
|
|
194fb20d5f | ||
|
|
30f528f084 | ||
|
|
28d024ceda | ||
|
|
6b9c98c59f | ||
|
|
1294e50b76 | ||
|
|
a0e540642b | ||
|
|
14d6a7de31 | ||
|
|
cd7b60a3ba | ||
|
|
ebbed22f36 | ||
|
|
f6e5d8f673 | ||
|
|
bf1c6211b8 | ||
|
|
d388fa344a | ||
|
|
eabb61b08c | ||
|
|
a5c3438927 | ||
|
|
b6948542bb | ||
|
|
0242bd3193 | ||
|
|
229ef48470 | ||
|
|
19c35ba901 | ||
|
|
3c06ae97e8 | ||
|
|
f34966e4c7 | ||
|
|
e9ebf45640 | ||
|
|
c00e5bd243 | ||
|
|
ab5f18a437 | ||
|
|
330b222359 | ||
|
|
d81cb2d4f0 | ||
|
|
8e2edb680d | ||
|
|
00879be47d | ||
|
|
34656a3f07 | ||
|
|
27cf037309 | ||
|
|
2198fa69ab | ||
|
|
95f6434443 | ||
|
|
8734b0d3d5 | ||
|
|
790d0d9628 | ||
|
|
3c562563c5 | ||
|
|
f19785d0e1 | ||
|
|
a3d098a87c | ||
|
|
449d7c4218 | ||
|
|
d199141a91 | ||
|
|
df83ffdafb | ||
|
|
cf03c56cfa | ||
|
|
3d1e33558a | ||
|
|
f314ebb3a2 | ||
|
|
31d6ef9585 | ||
|
|
fdfb77c4a2 | ||
|
|
a93e3853f7 | ||
|
|
34da9d7cc8 | ||
|
|
b3dd6bbe46 | ||
|
|
9ab3e45288 | ||
|
|
d255a86644 | ||
|
|
f3f50a818f | ||
|
|
1ff520659f | ||
|
|
33381fc472 | ||
|
|
e3d767bf54 | ||
|
|
8f82d0e100 | ||
|
|
3085e5aabe | ||
|
|
36dce639a9 | ||
|
|
6e15757150 | ||
|
|
365d389a14 | ||
|
|
e79d57eaf9 | ||
|
|
8d5dfea6de | ||
|
|
e02e5b186e | ||
|
|
2dc670875a | ||
|
|
9768921ff7 | ||
|
|
db90f0631a | ||
|
|
8a0a8f9af4 | ||
|
|
8ca2c68e90 | ||
|
|
d26fc44fe6 | ||
|
|
7ed2a6668a | ||
|
|
3c4ac99ae4 | ||
|
|
63d276767e | ||
|
|
60dcc5b0db | ||
|
|
0d2c648422 | ||
|
|
56f7ddd1dc | ||
|
|
930e311f51 | ||
|
|
5df8c71b89 | ||
|
|
dfedb5069d | ||
|
|
7016c8f401 | ||
|
|
5a14b74ff2 | ||
|
|
00bab8d390 | ||
|
|
f6e364ca33 | ||
|
|
4cd98889a7 | ||
|
|
c9b613659c | ||
|
|
01b4029998 | ||
|
|
f583e50e88 | ||
|
|
a7ea957843 | ||
|
|
c92c95a733 | ||
|
|
ae7608440e | ||
|
|
5c9da8a39d | ||
|
|
e012f24ef2 | ||
|
|
756c9c7158 | ||
|
|
ab4bf75d0e | ||
|
|
7a9715ed41 | ||
|
|
c353a6159e | ||
|
|
f5dca4848a | ||
|
|
36321a9af4 | ||
|
|
b485b8bd4a | ||
|
|
cae2c8656a | ||
|
|
acf574129f | ||
|
|
6426e0d3de | ||
|
|
eedc39b33a | ||
|
|
9672a2e000 | ||
|
|
0fffd2c550 | ||
|
|
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 |
@@ -21,6 +21,11 @@ updates:
|
||||
day: monday
|
||||
time: '09:10'
|
||||
timezone: Asia/Shanghai
|
||||
# 固定官方 Codex Desktop 的 TLS 指纹;升级前必须抓取握手并比对扩展顺序。
|
||||
ignore:
|
||||
- dependency-name: hyper-rustls
|
||||
- dependency-name: reqwest
|
||||
- dependency-name: rustls
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -55,6 +55,9 @@ jobs:
|
||||
target: runtime
|
||||
load: true
|
||||
tags: ${{ env.CPR_IMAGE }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
|
||||
@@ -76,7 +79,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@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
|
||||
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
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
|
||||
@@ -71,7 +106,7 @@ jobs:
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -117,12 +152,12 @@ jobs:
|
||||
CI: 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
@@ -156,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,14 +25,14 @@ 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
|
||||
|
||||
- name: Detect affected checks
|
||||
id: filter
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
filters: |
|
||||
backend:
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -15,7 +12,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
group: release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -29,6 +26,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,16 +38,16 @@ 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 }}
|
||||
ref: ${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read release metadata
|
||||
id: version
|
||||
shell: bash
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
ruby <<'RUBY' >> "$GITHUB_OUTPUT"
|
||||
require "json"
|
||||
@@ -104,7 +102,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 +144,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 +174,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 }}
|
||||
|
||||
@@ -185,9 +185,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -234,7 +234,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 }}
|
||||
|
||||
@@ -250,9 +250,9 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
backend/target
|
||||
key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock') }}
|
||||
key: cargo-v2-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('backend/Cargo.lock', 'backend/rust-toolchain.toml', 'backend/**/Cargo.toml', 'backend/.cargo/config.toml') }}
|
||||
restore-keys: |
|
||||
cargo-${{ runner.os }}-${{ matrix.target }}-
|
||||
cargo-v2-${{ runner.os }}-${{ matrix.target }}-
|
||||
|
||||
- name: Build binary
|
||||
shell: bash
|
||||
@@ -286,7 +286,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 +321,6 @@ jobs:
|
||||
import pathlib
|
||||
import shutil
|
||||
import tarfile
|
||||
import zipfile
|
||||
|
||||
APP_NAME = "codex-proxy-rs"
|
||||
|
||||
@@ -357,8 +356,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 +364,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 +375,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Generate release asset provenance
|
||||
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
with:
|
||||
subject-path: dist/release/*
|
||||
|
||||
@@ -412,7 +405,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 +451,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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -475,8 +468,11 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
CPR_PREBUILT_DIR=dist/docker
|
||||
cache-from: type=gha,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=runtime-prebuilt-${{ matrix.os }}-${{ matrix.arch }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime-prebuilt
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
|
||||
provenance: mode=max
|
||||
sbom: true
|
||||
labels: |
|
||||
@@ -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@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
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@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
|
||||
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
|
||||
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?** Online upgrades across major versions are not supported. Deploy the new major fresh with a new `.runtime/` data directory, then 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 }}
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@1a3a6d54512beeaffd394f8d516ca16f2c506a20 # 1.97
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
|
||||
@@ -47,12 +47,12 @@ 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 }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
version: 11.7.0
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ Thumbs.db
|
||||
/.codex-ws-audit/
|
||||
/.agents/
|
||||
/.reasonix/
|
||||
/.claude
|
||||
/.worktrees/
|
||||
/worktrees/
|
||||
/.cargo/
|
||||
|
||||
@@ -21,15 +21,16 @@
|
||||
|
||||
## 能力
|
||||
|
||||
| 领域 | 实现 |
|
||||
| -------- | --------------------------------------------------------------------------- |
|
||||
| 协议 | OpenAI Responses JSON、SSE、WebSocket 与模型目录 |
|
||||
| Provider | 固定 OpenAI、xAI 两个 Provider;各自管理 OAuth、账号、额度与模型目录 |
|
||||
| 路由 | Client Key 绑定 Provider、全局模型映射、会话亲和与同 Provider 安全 fallback |
|
||||
| 透明边界 | OpenAI 保持 Responses 语义透明;xAI 的协议适配只驻留在 `providers/xai` |
|
||||
| 延续 | OpenAI native/replay continuation;xAI 使用客户端提交的完整历史 |
|
||||
| 管理 | Client Key、账号、模型目录、设置、观测、系统更新与 OAuth |
|
||||
| 计量 | 模型请求 Token、费用、延迟、账号与 Provider 归因 |
|
||||
| 领域 | 实现 |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| 协议 | 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` |
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -91,7 +92,12 @@ curl -i http://127.0.0.1:8080/healthz
|
||||
2. 在账号页选择对应 Provider:OpenAI 支持 OAuth、账号文件与 Agent Identity;xAI 支持 OAuth
|
||||
和兼容账号文件导入。
|
||||
3. 按需配置客户端模型到上游模型的全局映射;未命中映射的模型名原样透传。
|
||||
4. 创建 `sk_...` Client Key,绑定 `openai` 或 `xai`,并设置模型范围、速率与并发限制。
|
||||
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。
|
||||
@@ -145,7 +151,7 @@ curl http://127.0.0.1:8080/v1/responses \
|
||||
## 运维
|
||||
|
||||
```bash
|
||||
# 升级
|
||||
# 实例升级
|
||||
docker compose -f deploy/compose.yaml pull codex-proxy-rs
|
||||
docker compose -f deploy/compose.yaml up -d --no-build
|
||||
|
||||
@@ -158,9 +164,13 @@ docker compose -f deploy/compose.yaml up -d
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点和自更新状态。删除该目录会永久清除
|
||||
> `.runtime/` 保存 PostgreSQL、Redis、日志、OpenAI 会话锚点密钥和自更新状态。删除该目录会永久清除
|
||||
> 运行状态。
|
||||
|
||||
> [!WARNING]
|
||||
> 不支持跨大版本在线升级。升级到新的大版本时,请使用全新的 `.runtime/` 数据目录重新部署,
|
||||
> 并重新导入或重新授权 Provider 账号与客户端 Key。
|
||||
|
||||
Compose 默认只绑定 `127.0.0.1`。公网接入应使用 HTTPS 反向代理,转发 WebSocket
|
||||
upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
|
||||
@@ -172,8 +182,10 @@ upgrade 与真实客户端 IP;不要暴露 PostgreSQL 或 Redis。
|
||||
release/publish <version>
|
||||
```
|
||||
|
||||
脚本会更新 `release/version.yaml`、创建约定提交与带注释的 `v<version>` tag,并原子推送分支和 tag。
|
||||
不要手工修改版本后单独打 tag。
|
||||
脚本依赖已登录的 GitHub CLI(`gh auth login`)。它会更新 `release/version.yaml`、创建约定提交与带
|
||||
注释的 `v<version>` tag,原子推送分支和 tag,再从发布分支触发 Release 构建,使构建缓存可以跨版本
|
||||
复用。不要手工修改版本后单独打 tag。该脚本不会登录服务器,也不会改变任何运行实例;源码提交、
|
||||
仓库发版和实例升级是三个独立状态。
|
||||
|
||||
## 文档
|
||||
|
||||
|
||||
Generated
+821
-267
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ tokio = { version = "1.52.3", default-features = false, features = ["rt-multi-th
|
||||
|
||||
[dev-dependencies]
|
||||
config = { version = "0.15.23", default-features = false, features = ["yaml"] }
|
||||
syn = { version = "2.0.118", features = ["full"] }
|
||||
serde_json = "1"
|
||||
syn = { version = "3.0.3", features = ["full"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -72,7 +72,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
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(
|
||||
let mut admin = gateway_admin::initialize(
|
||||
admin,
|
||||
store.admin_ports(),
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
@@ -99,6 +99,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
plan.extend(core.take_worker_contributions());
|
||||
plan.extend(openai.take_worker_contributions());
|
||||
plan.extend(xai.take_worker_contributions());
|
||||
plan.extend(admin.take_worker_contributions());
|
||||
host.start_workers(plan, store.worker_leader_lease())?;
|
||||
host.report_startup_ready("Workers");
|
||||
host.serve(api.router()).await?;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
use provider_openai::transport::tls::ensure_rustls_provider;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
let mut arguments = env::args_os().skip(1);
|
||||
match arguments.next().as_deref().and_then(|value| value.to_str()) {
|
||||
None | Some("serve") => {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
//! 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"],
|
||||
);
|
||||
}
|
||||
|
||||
/// workspace 包名到冻结成员路径的映射。
|
||||
const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "apps/gateway"),
|
||||
("gateway-admin", "crates/gateway-admin"),
|
||||
("gateway-api", "crates/gateway-api"),
|
||||
("gateway-core", "crates/gateway-core"),
|
||||
("gateway-host", "crates/gateway-host"),
|
||||
("gateway-protocol", "crates/gateway-protocol"),
|
||||
("gateway-store", "crates/gateway-store"),
|
||||
("provider-openai", "crates/providers/openai"),
|
||||
("provider-xai", "crates/providers/xai"),
|
||||
];
|
||||
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
|
||||
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "gateway-admin"),
|
||||
("codex-proxy-rs", "gateway-api"),
|
||||
("codex-proxy-rs", "gateway-core"),
|
||||
("codex-proxy-rs", "gateway-host"),
|
||||
("codex-proxy-rs", "gateway-store"),
|
||||
("codex-proxy-rs", "provider-openai"),
|
||||
("codex-proxy-rs", "provider-xai"),
|
||||
("gateway-admin", "gateway-core"),
|
||||
("gateway-api", "gateway-admin"),
|
||||
("gateway-api", "gateway-core"),
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-store", "gateway-admin"),
|
||||
("gateway-store", "gateway-core"),
|
||||
("provider-openai", "gateway-admin"),
|
||||
("provider-openai", "gateway-core"),
|
||||
("provider-openai", "gateway-protocol"),
|
||||
("provider-xai", "gateway-admin"),
|
||||
("provider-xai", "gateway-core"),
|
||||
("provider-xai", "gateway-protocol"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
let metadata = cargo_metadata_json();
|
||||
let packages = metadata["packages"]
|
||||
.as_array()
|
||||
.expect("cargo metadata packages");
|
||||
let internal_names: std::collections::BTreeSet<&str> =
|
||||
PACKAGE_TO_MEMBER.iter().map(|(name, _)| *name).collect();
|
||||
|
||||
let mut actual: Vec<(String, String)> = Vec::new();
|
||||
for package in packages {
|
||||
let name = package["name"].as_str().expect("package name");
|
||||
if !internal_names.contains(name) {
|
||||
continue;
|
||||
}
|
||||
for dependency in package["dependencies"].as_array().into_iter().flatten() {
|
||||
let dep_name = dependency["name"].as_str().expect("dependency name");
|
||||
if !internal_names.contains(dep_name) {
|
||||
continue;
|
||||
}
|
||||
let is_runtime_edge = dependency["dep_kinds"]
|
||||
.as_array()
|
||||
.map(|kinds| {
|
||||
kinds
|
||||
.iter()
|
||||
.any(|kind| kind["kind"].as_str() == Some("normal"))
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if is_runtime_edge {
|
||||
actual.push((name.to_owned(), dep_name.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut expected: Vec<(String, String)> = ALLOWED_INTERNAL_EDGES
|
||||
.iter()
|
||||
.map(|(from, to)| ((*from).to_owned(), (*to).to_owned()))
|
||||
.collect();
|
||||
actual.sort();
|
||||
expected.sort();
|
||||
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"workspace internal dependency edges diverged from the frozen DAG"
|
||||
);
|
||||
}
|
||||
|
||||
#[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")
|
||||
}
|
||||
|
||||
fn cargo_metadata_json() -> serde_json::Value {
|
||||
let output = std::process::Command::new("cargo")
|
||||
.args(["metadata", "--format-version", "1", "--no-deps"])
|
||||
.current_dir(backend_root())
|
||||
.output()
|
||||
.expect("run cargo metadata");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"cargo metadata failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("parse cargo metadata")
|
||||
}
|
||||
|
||||
/// 提取成员 `[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}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,8 +134,11 @@ fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_core_version() {
|
||||
assert_rejected(valid_config().replace("codex_version: '0.145.0'", "codex_version: 'latest'"));
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"codex_version: '0.147.0-alpha.6.6'",
|
||||
"codex_version: 'latest'",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,7 +157,7 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_desktop_profile_fields() {
|
||||
assert_rejected(valid_config().replace("desktop_build: '5828'", "desktop_build: 'build'"));
|
||||
assert_rejected(valid_config().replace("desktop_build: '6415'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod architecture;
|
||||
mod bootstrap;
|
||||
|
||||
use std::{
|
||||
@@ -21,7 +22,11 @@ fn app_tree_matches_frozen_terminal_manifest() {
|
||||
);
|
||||
assert_eq!(
|
||||
rust_files(&root.join("tests")),
|
||||
BTreeSet::from([PathBuf::from("bootstrap.rs"), PathBuf::from("main.rs")]),
|
||||
BTreeSet::from([
|
||||
PathBuf::from("architecture.rs"),
|
||||
PathBuf::from("bootstrap.rs"),
|
||||
PathBuf::from("main.rs"),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ path = "tests/main.rs"
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
base64 = "0.23.0"
|
||||
chrono = "0.4.45"
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
@@ -24,10 +24,16 @@ 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"] }
|
||||
cron = "0.17"
|
||||
chrono-tz = "0.10"
|
||||
tokio = { version = "1.52", features = ["rt", "time", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1.0.150", features = ["preserve_order"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! 备份纯策略与 Worker 任务。
|
||||
|
||||
pub mod policy;
|
||||
pub mod task;
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 备份纯策略:Cron 推进与保留候选。
|
||||
//!
|
||||
//! 本模块不持有任何端口或对象,是 `BackupService` 与 `BackupTask` 共用的纯函数集。
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Duration, TimeZone as _, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use cron::Schedule;
|
||||
|
||||
use crate::model::backup::{BackupError, BackupRecord, BackupStatus, code};
|
||||
|
||||
/// 向历史回溯的最大范围(分钟)。覆盖 Feb 29 + 特定星期等罕见组合的最长间隔。
|
||||
const MAX_BACKDATE_MINUTES: i64 = 40 * 366 * 24 * 60;
|
||||
|
||||
/// 已解析并校验的备份计划。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackupSchedule {
|
||||
schedule: Schedule,
|
||||
timezone: Tz,
|
||||
}
|
||||
|
||||
impl BackupSchedule {
|
||||
/// 解析并校验 5 段 Cron 与 IANA 时区。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Cron 非法或时区不是 IANA 名称时返回 [`BackupError`]。
|
||||
pub fn parse(cron_expression: &str, schedule_timezone: &str) -> Result<Self, BackupError> {
|
||||
let expression = format!("0 {cron_expression}");
|
||||
let schedule = Schedule::from_str(&expression).map_err(|_| {
|
||||
BackupError::new(code::INVALID_CRON, "Cron 表达式必须是 5 段格式".to_owned())
|
||||
})?;
|
||||
let timezone = schedule_timezone.parse::<Tz>().map_err(|_| {
|
||||
BackupError::new(code::INVALID_TIMEZONE, "时区必须是 IANA 名称".to_owned())
|
||||
})?;
|
||||
Ok(Self { schedule, timezone })
|
||||
}
|
||||
|
||||
/// 返回最接近 `at`(含 `at` 所在分钟)的最近一次触发时间。
|
||||
///
|
||||
/// 若过去 `MAX_BACKDATE_MINUTES` 内没有任何触发(极罕见的稀疏计划或超长停机),
|
||||
/// 返回 `None`,调用方不应为历史点补任务。
|
||||
#[must_use]
|
||||
pub fn last_firing_at_or_before(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let high_min = at.timestamp().div_euclid(60);
|
||||
let low_min = high_min.saturating_sub(MAX_BACKDATE_MINUTES);
|
||||
|
||||
// 检查窗口内是否存在触发;不存在则直接返回 None。
|
||||
let low_instant = instant(low_min, self.timezone)?;
|
||||
if self
|
||||
.schedule
|
||||
.after(&low_instant)
|
||||
.next()
|
||||
.is_none_or(|firing| firing.timestamp() > high_min * 60 + 59)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// 二分查找最小的 u(分钟)使 after(u) > high;prev = after(u - 1 分钟)。
|
||||
let mut lo = low_min;
|
||||
let mut hi = high_min;
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
if self
|
||||
.firing_after(mid)
|
||||
.is_some_and(|firing| firing > high_min)
|
||||
{
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
self.firing_after(lo - 1)
|
||||
.filter(|&firing| firing <= high_min)
|
||||
.and_then(|firing| Utc.timestamp_opt(firing * 60, 0).single())
|
||||
}
|
||||
|
||||
/// 返回第一个严格晚于 `at` 的触发时间。
|
||||
#[must_use]
|
||||
pub fn next_after(&self, at: DateTime<Utc>) -> Option<DateTime<Utc>> {
|
||||
let local = at.with_timezone(&self.timezone);
|
||||
self.schedule
|
||||
.after(&local)
|
||||
.next()
|
||||
.map(|firing| firing.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
/// `after(u 分钟)` 返回的第一个触发时间(分钟索引),用于二分搜索。
|
||||
fn firing_after(&self, minute_index: i64) -> Option<i64> {
|
||||
let instant = instant(minute_index, self.timezone)?;
|
||||
self.schedule
|
||||
.after(&instant)
|
||||
.next()
|
||||
.map(|firing| firing.timestamp().div_euclid(60))
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造指定分钟索引(UTC epoch 分钟)在目标时区下的 `DateTime`。
|
||||
fn instant(minute_index: i64, timezone: Tz) -> Option<DateTime<Tz>> {
|
||||
timezone.timestamp_opt(minute_index * 60, 0).single()
|
||||
}
|
||||
|
||||
/// 一条记录被纳入删除流程的原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RetentionReason {
|
||||
/// 超过 `retention_days`。
|
||||
Days,
|
||||
/// 超过 `retention_count`。
|
||||
Count,
|
||||
}
|
||||
|
||||
/// 保留决策。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetentionDecision {
|
||||
pub record_id: String,
|
||||
pub reason: RetentionReason,
|
||||
}
|
||||
|
||||
/// 从按完成时间倒序的 completed 计划备份中决定纳入删除流程的记录。
|
||||
///
|
||||
/// 规则(`docs/s3-backup-design-audit.md` §4.4):
|
||||
/// - `retentionDays = 0` 时不按天数清理;`retentionCount = 0` 时不按份数清理。
|
||||
/// - 自动计划备份满足任一启用阈值即可进入删除。
|
||||
/// - 无论阈值如何,至少保留最近一个成功的计划备份(倒序下标 0)。
|
||||
/// - 手动备份不受自动阈值影响;无 TTL,仅由管理员手动删除。
|
||||
#[must_use]
|
||||
pub fn decide_retention(
|
||||
retention_days: u32,
|
||||
retention_count: u32,
|
||||
now: DateTime<Utc>,
|
||||
scheduled_completed_desc: &[BackupRecord],
|
||||
) -> Vec<RetentionDecision> {
|
||||
let mut decisions = Vec::new();
|
||||
for (index, record) in scheduled_completed_desc.iter().enumerate() {
|
||||
if record.status != BackupStatus::Completed {
|
||||
continue;
|
||||
}
|
||||
// 最近一个成功的计划备份永远保留。
|
||||
if index == 0 {
|
||||
continue;
|
||||
}
|
||||
let expired_by_days = retention_days > 0
|
||||
&& record.completed_at.is_some_and(|completed_at| {
|
||||
completed_at <= now - Duration::days(i64::from(retention_days))
|
||||
});
|
||||
let exceeded_count =
|
||||
retention_count > 0 && index >= usize::try_from(retention_count).unwrap_or(usize::MAX);
|
||||
if expired_by_days || exceeded_count {
|
||||
decisions.push(RetentionDecision {
|
||||
record_id: record.id.clone(),
|
||||
reason: if expired_by_days {
|
||||
RetentionReason::Days
|
||||
} else {
|
||||
RetentionReason::Count
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
decisions
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
//! Backup Worker 贡献:单个可取消 `DaemonTask` 承担调度、执行、删除收敛与保留清理。
|
||||
//!
|
||||
//! 当前部署边界是单副本,因此不需要 Redis lease、fencing token 或 heartbeat。长时间
|
||||
//! `pg_dump` 与上传由本 Daemon 自身持有;Host 只负责 panic 后重启、健康与关闭。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
model::backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, build_backup_seed, code,
|
||||
},
|
||||
ports::backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpRequest,
|
||||
StatusTransitionUpdate, UploadObjectRequest,
|
||||
},
|
||||
};
|
||||
|
||||
impl From<BackupError> for WorkerTaskError {
|
||||
fn from(error: BackupError) -> Self {
|
||||
WorkerTaskError::safe(error.message())
|
||||
}
|
||||
}
|
||||
|
||||
use super::policy::{BackupSchedule, decide_retention};
|
||||
|
||||
/// 循环间隔:管理员修改 Cron 后该时长内生效;无工作时可取消等待。
|
||||
const BACKUP_LOOP_INTERVAL: Duration = Duration::from_secs(30);
|
||||
/// 每个循环最多执行的保留删除数量。
|
||||
const RETENTION_BATCH_SIZE: usize = 10;
|
||||
/// 保留扫描一次读取的 completed 计划备份上限。
|
||||
const RETENTION_SCAN_LIMIT: u32 = 1000;
|
||||
/// 每个循环最多完成的待删除记录数量。
|
||||
const PENDING_DELETION_BATCH: u32 = 20;
|
||||
|
||||
/// 备份 Daemon 任务。
|
||||
pub struct BackupTask {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
/// 组合仓储、导出器与对象存储适配器。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DaemonTask for BackupTask {
|
||||
fn run(&self, cancellation: CancellationToken) -> BoxFuture<'_, Result<(), WorkerTaskError>> {
|
||||
Box::pin(async move { self.run_daemon(cancellation).await })
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupTask {
|
||||
async fn run_daemon(&self, cancellation: CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.run_cycle(&cancellation).await?;
|
||||
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => return Ok(()),
|
||||
_ = tokio::time::sleep(BACKUP_LOOP_INTERVAL) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行一个周期:推进计划、恢复中间态、删除收敛、领取执行一个任务、保留清理。
|
||||
///
|
||||
/// 对外暴露以便集成测试逐周期驱动;daemon 循环内部复用。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 仓储或基础设施不可用、任务执行失败时返回 [`WorkerTaskError`]。
|
||||
pub async fn run_cycle(&self, cancellation: &CancellationToken) -> Result<(), WorkerTaskError> {
|
||||
if cancellation.is_cancelled() {
|
||||
return Ok(());
|
||||
}
|
||||
self.advance_schedule(Utc::now()).await?;
|
||||
self.recover_intermediate(cancellation).await?;
|
||||
self.finish_pending_deletions().await?;
|
||||
self.process_next_task(cancellation).await?;
|
||||
self.run_retention_batch(Utc::now()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 推进到期计划并幂等插入 scheduled 任务。
|
||||
async fn advance_schedule(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
if !settings.schedule_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
let cron = settings.cron_expression.as_deref().unwrap_or_default();
|
||||
let timezone = settings.schedule_timezone.as_deref().unwrap_or_default();
|
||||
if cron.is_empty() || timezone.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let schedule = BackupSchedule::parse(cron, timezone)
|
||||
.map_err(|_| WorkerTaskError::safe("backup schedule is invalid"))?;
|
||||
|
||||
let due = match settings.next_run_at {
|
||||
Some(next_run_at) if next_run_at <= now => true,
|
||||
Some(_) => false,
|
||||
None => {
|
||||
// 刚启用计划但尚无游标:直接初始化。
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !due {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let scheduled_at = match schedule.last_firing_at_or_before(now) {
|
||||
Some(scheduled_at) => scheduled_at,
|
||||
None => {
|
||||
// 无法从计划推导最近到期时间:推进游标避免反复触发。
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let expires_at = if settings.retention_days > 0 {
|
||||
Some(now + chrono::Duration::days(i64::from(settings.retention_days)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let seed = build_backup_seed(
|
||||
BackupTriggerKind::Scheduled,
|
||||
Some(scheduled_at),
|
||||
prefix,
|
||||
now,
|
||||
expires_at,
|
||||
)
|
||||
.map_err(infra_error)?;
|
||||
let inserted = self
|
||||
.repository
|
||||
.insert_scheduled_record(seed)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
if inserted {
|
||||
info!(scheduled_at = %scheduled_at, "计划备份任务已创建");
|
||||
} else {
|
||||
warn!(scheduled_at = %scheduled_at, "计划时间点冲突,跳过并推进游标");
|
||||
}
|
||||
if let Some(next_run_at) = schedule.next_after(now) {
|
||||
let _ = self
|
||||
.repository
|
||||
.advance_schedule_cursor(next_run_at, cron, timezone)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 恢复中间状态(dumping/uploading/deleting)。
|
||||
async fn recover_intermediate(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let records = self
|
||||
.repository
|
||||
.list_intermediate_records()
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in records {
|
||||
match record.status {
|
||||
BackupStatus::Dumping => self.recover_dumping(&record, cancellation).await?,
|
||||
BackupStatus::Uploading => self.recover_uploading(&record, cancellation).await?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// dumping:有完整暂存则继续上传,否则清理暂存并标记失败。
|
||||
async fn recover_dumping(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
match self.dump.inspect_staging(&record.id).await? {
|
||||
Some(artifact) => {
|
||||
let now = Utc::now();
|
||||
let update = StatusTransitionUpdate {
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
sha256: Some(artifact.sha256.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(uploading_record) = self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
self.upload_and_verify(
|
||||
&uploading_record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
now,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
self.fail_task(record, code::PG_DUMP_FAILED, "导出进程中断,暂存归档不完整")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// uploading:远端匹配则补记完成,有完整暂存则重试上传,否则失败。
|
||||
async fn recover_uploading(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
if let Some(remote) = self
|
||||
.object_store
|
||||
.head_object(&config, &record.object_key)
|
||||
.await?
|
||||
&& record.size_bytes == Some(remote.size_bytes)
|
||||
&& record.sha256.as_deref() == Some(remote.sha256.as_str())
|
||||
{
|
||||
let now = Utc::now();
|
||||
self.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
StatusTransitionUpdate::default(),
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
return Ok(());
|
||||
}
|
||||
match self.dump.inspect_staging(&record.id).await? {
|
||||
Some(artifact) => {
|
||||
self.upload_and_verify(
|
||||
record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
Utc::now(),
|
||||
cancellation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
self.fail_task(
|
||||
record,
|
||||
code::S3_UPLOAD_FAILED,
|
||||
"远端对象缺失且本地无完整暂存",
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成待删除记录(deleting → DeleteObject → 硬删除)。
|
||||
async fn finish_pending_deletions(&self) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
let records = self
|
||||
.repository
|
||||
.list_pending_deletions(PENDING_DELETION_BATCH)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in records {
|
||||
if self
|
||||
.object_store
|
||||
.delete_object(&config, &record.object_key)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
self.repository
|
||||
.delete_record(&record.id)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 领取一个 queued 任务并执行导出、上传与远端校验。
|
||||
async fn process_next_task(
|
||||
&self,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let now = Utc::now();
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.claim_next_queued(now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
info!(
|
||||
backup_id = %record.id,
|
||||
attempt = record.attempt_count,
|
||||
"领取备份任务"
|
||||
);
|
||||
if let Err(error) = self.execute_task(&record, cancellation).await {
|
||||
warn!(backup_id = %record.id, error = %error, "备份任务执行失败");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行单个任务;内部处理全部状态迁移。
|
||||
async fn execute_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
if BackupStorageConfig::from_settings(&settings).is_none() {
|
||||
return self
|
||||
.fail_task(record, code::INVALID_CONFIG, "备份存储配置不完整")
|
||||
.await;
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return self
|
||||
.fail_task(record, code::INVALID_CONFIG, "备份存储配置尚未通过连接测试")
|
||||
.await;
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let artifact = match self
|
||||
.dump
|
||||
.dump(DumpRequest {
|
||||
backup_id: record.id.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
let (error_code, message) = classify_dump_error(error);
|
||||
return self.fail_task(record, error_code, message).await;
|
||||
}
|
||||
};
|
||||
|
||||
// dumping → uploading,持久化 size/sha256。
|
||||
let update = StatusTransitionUpdate {
|
||||
size_bytes: Some(artifact.size_bytes),
|
||||
sha256: Some(artifact.sha256.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let Some(uploading_record) = self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
update,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.upload_and_verify(
|
||||
&uploading_record,
|
||||
&artifact.path,
|
||||
artifact.size_bytes,
|
||||
&artifact.sha256,
|
||||
now,
|
||||
cancellation,
|
||||
)
|
||||
.await?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 上传 + 远端校验,内部完成终态迁移。
|
||||
async fn upload_and_verify(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
source: &std::path::Path,
|
||||
size_bytes: u64,
|
||||
sha256: &str,
|
||||
now: DateTime<Utc>,
|
||||
cancellation: &CancellationToken,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
let store = self.object_store.clone();
|
||||
let metadata =
|
||||
BackupObjectMetadata::new(record.id.clone(), sha256.to_owned(), now, size_bytes);
|
||||
if let Err(error) = store
|
||||
.upload_file(
|
||||
&config,
|
||||
UploadObjectRequest {
|
||||
object_key: record.object_key.clone(),
|
||||
source: source.to_path_buf(),
|
||||
metadata,
|
||||
cancellation: cancellation.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("上传到对象存储失败:{}", error.message()),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match store.head_object(&config, &record.object_key).await {
|
||||
Ok(Some(remote)) if remote.size_bytes == size_bytes && remote.sha256 == sha256 => {
|
||||
if self
|
||||
.repository
|
||||
.transition_status(
|
||||
&record.id,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
StatusTransitionUpdate::default(),
|
||||
Utc::now(),
|
||||
)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
.is_some()
|
||||
{
|
||||
info!(
|
||||
backup_id = %record.id,
|
||||
size_bytes,
|
||||
"备份完成"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Some(remote)) => {
|
||||
let size_matches = remote.size_bytes == size_bytes;
|
||||
let sha256_matches = remote.sha256 == sha256;
|
||||
warn!(
|
||||
backup_id = %record.id,
|
||||
expected_size_bytes = size_bytes,
|
||||
remote_size_bytes = remote.size_bytes,
|
||||
sha256_matches,
|
||||
"备份远端对象校验不一致"
|
||||
);
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
match (size_matches, sha256_matches) {
|
||||
(false, true) => "远端对象大小不一致",
|
||||
(true, false) => "远端对象 sha256 校验值不一致",
|
||||
(false, false) => "远端对象大小和 sha256 校验值均不一致",
|
||||
(true, true) => "远端对象校验状态异常",
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!(backup_id = %record.id, "上传完成后未找到远端对象");
|
||||
self.fail_task(
|
||||
record,
|
||||
code::REMOTE_VERIFICATION_FAILED,
|
||||
"上传完成后未找到远端对象",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = store.delete_object(&config, &record.object_key).await;
|
||||
self.fail_task(
|
||||
record,
|
||||
error.code(),
|
||||
format!("远端对象校验失败:{}", error.message()),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把活跃任务写入失败终态并清理暂存。
|
||||
async fn fail_task(
|
||||
&self,
|
||||
record: &BackupRecord,
|
||||
error_code: &'static str,
|
||||
message: impl Into<String>,
|
||||
) -> Result<(), WorkerTaskError> {
|
||||
let message = message.into();
|
||||
warn!(
|
||||
backup_id = %record.id,
|
||||
error_code,
|
||||
error_message = %message,
|
||||
"备份任务失败"
|
||||
);
|
||||
let now = Utc::now();
|
||||
let update = StatusTransitionUpdate {
|
||||
error_code: Some(error_code.to_string()),
|
||||
error_message: Some(message),
|
||||
completed_at: Some(now),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = self
|
||||
.repository
|
||||
.transition_status(&record.id, record.status, BackupStatus::Failed, update, now)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let _ = self.dump.cleanup_staging(&record.id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 执行一小批到期保留清理:先处理 `expires_at` 已到期的记录,再按天数/份数清理计划备份。
|
||||
async fn run_retention_batch(&self, now: DateTime<Utc>) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
|
||||
// 1. expires_at 已到期的记录(手动或计划)无条件进入删除。
|
||||
let expired = self
|
||||
.repository
|
||||
.list_expired_records(RETENTION_BATCH_SIZE as u32)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
for record in expired {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&record.id, now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.finalize_deletion(&record).await?;
|
||||
}
|
||||
|
||||
// 2. 计划备份按 retentionDays / retentionCount 清理。
|
||||
let records = self
|
||||
.repository
|
||||
.list_scheduled_completed_desc(RETENTION_SCAN_LIMIT)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
let decisions = decide_retention(
|
||||
settings.retention_days,
|
||||
settings.retention_count,
|
||||
now,
|
||||
&records,
|
||||
);
|
||||
for decision in decisions.into_iter().take(RETENTION_BATCH_SIZE) {
|
||||
let Some(record) = self
|
||||
.repository
|
||||
.transition_to_deleting(&decision.record_id, now)
|
||||
.await
|
||||
.map_err(repo_error)?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
self.finalize_deletion(&record).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对已进入 deleting 的记录执行对象删除并硬删除记录行。
|
||||
async fn finalize_deletion(&self, record: &BackupRecord) -> Result<(), WorkerTaskError> {
|
||||
let settings = self.repository.load_settings().await.map_err(repo_error)?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(());
|
||||
};
|
||||
if self
|
||||
.object_store
|
||||
.delete_object(&config, &record.object_key)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
self.repository
|
||||
.delete_record(&record.id)
|
||||
.await
|
||||
.map_err(repo_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_dump_error(error: BackupError) -> (&'static str, &'static str) {
|
||||
match error.code() {
|
||||
code::CANCELLED => (code::CANCELLED, "备份已取消"),
|
||||
code::STAGING_SPACE_EXHAUSTED => (code::STAGING_SPACE_EXHAUSTED, "暂存磁盘空间不足"),
|
||||
_ => (code::PG_DUMP_FAILED, "数据库导出失败"),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_error(error: crate::ports::store::AdminStoreError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(format!("backup store failed: {error}"))
|
||||
}
|
||||
|
||||
fn infra_error(error: BackupError) -> WorkerTaskError {
|
||||
WorkerTaskError::safe(error.message())
|
||||
}
|
||||
@@ -2,23 +2,28 @@
|
||||
//!
|
||||
//! 本 crate 不包含 HTTP wire、数据库实现或具体 Provider 实现。
|
||||
|
||||
use std::{fmt, path::Path, sync::Arc};
|
||||
use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use gateway_core::{
|
||||
engine::probe::AccountProbe,
|
||||
routing::{ProviderKind, snapshot::SnapshotControl},
|
||||
task::{
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
|
||||
WorkerRunnable,
|
||||
},
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod backup;
|
||||
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,
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService,
|
||||
openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
@@ -28,10 +33,10 @@ use ports::{
|
||||
system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
accounts::DefaultAccountsService, auth::DefaultAuthService,
|
||||
client_keys::DefaultClientKeyService, observability::DefaultObservabilityService,
|
||||
openai::DefaultOpenAiService, settings::DefaultSettingsService, system::DefaultSystemService,
|
||||
xai::DefaultXaiService,
|
||||
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
|
||||
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
|
||||
};
|
||||
|
||||
const OPENAI_PROVIDER_KIND: &str = "openai";
|
||||
@@ -48,6 +53,8 @@ const WEAK_INITIAL_PASSWORDS: &[&str] = &[
|
||||
"codex-proxy-rs",
|
||||
];
|
||||
|
||||
const BACKUP_WORKER_OWNER: &str = "backup";
|
||||
|
||||
/// 只用于首次幂等创建默认管理员的启动密码。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
@@ -140,12 +147,14 @@ pub enum AdminConfigError {
|
||||
pub struct AdminServices {
|
||||
auth: Arc<dyn AuthService>,
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
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>,
|
||||
backups: Arc<dyn BackupService>,
|
||||
}
|
||||
|
||||
impl AdminServices {
|
||||
@@ -159,6 +168,11 @@ impl AdminServices {
|
||||
self.accounts.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_groups(&self) -> &dyn AccountGroupService {
|
||||
self.account_groups.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_keys(&self) -> &dyn ClientKeyService {
|
||||
self.client_keys.as_ref()
|
||||
@@ -188,11 +202,17 @@ impl AdminServices {
|
||||
pub fn xai(&self) -> &dyn XaiService {
|
||||
self.xai.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn backups(&self) -> &dyn BackupService {
|
||||
self.backups.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin 初始化完成后的封闭能力包。
|
||||
pub struct AdminBundle {
|
||||
services: AdminServices,
|
||||
worker_contributions: Vec<WorkerContribution>,
|
||||
}
|
||||
|
||||
impl AdminBundle {
|
||||
@@ -200,6 +220,11 @@ impl AdminBundle {
|
||||
pub fn services(&self) -> AdminServices {
|
||||
self.services.clone()
|
||||
}
|
||||
|
||||
/// 取出 Backup Worker 贡献;只能调用一次,与其它 Bundle 的贡献一并交给 Host。
|
||||
pub fn take_worker_contributions(&mut self) -> Vec<WorkerContribution> {
|
||||
std::mem::take(&mut self.worker_contributions)
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验配置、建立动态 Provider 注册表并完成默认管理员幂等初始化。
|
||||
@@ -236,14 +261,29 @@ pub async fn initialize(
|
||||
|
||||
let accounts = Arc::new(DefaultAccountsService::new(
|
||||
store.accounts(),
|
||||
store.settings(),
|
||||
registry.clone(),
|
||||
snapshot.clone(),
|
||||
probe.clone(),
|
||||
));
|
||||
let backup_ports = store.backup();
|
||||
let backups = Arc::new(DefaultBackupService::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.object_store(),
|
||||
store.auth(),
|
||||
snapshot.clone(),
|
||||
));
|
||||
let backup_task = backup::task::BackupTask::new(
|
||||
backup_ports.repository(),
|
||||
backup_ports.dump(),
|
||||
backup_ports.object_store(),
|
||||
);
|
||||
let services = AdminServices {
|
||||
auth,
|
||||
accounts,
|
||||
account_groups: Arc::new(DefaultAccountGroupService::new(
|
||||
store.account_groups(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
client_keys: Arc::new(DefaultClientKeyService::new(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
@@ -268,8 +308,32 @@ pub async fn initialize(
|
||||
store.accounts(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
backups,
|
||||
};
|
||||
Ok(AdminBundle { services })
|
||||
let worker_contributions = backup_worker_contribution(backup_task)?;
|
||||
Ok(AdminBundle {
|
||||
services,
|
||||
worker_contributions,
|
||||
})
|
||||
}
|
||||
|
||||
/// Backup Worker 注册:单个可取消 Daemon,owner 固定为 `backup`。
|
||||
fn backup_worker_contribution(
|
||||
task: backup::task::BackupTask,
|
||||
) -> Result<Vec<WorkerContribution>, AdminError> {
|
||||
let id = WorkerId::try_new(WorkerKind::Backup, BACKUP_WORKER_OWNER)
|
||||
.map_err(|_| AdminError::internal("Backup worker id is invalid"))?;
|
||||
let restart = DaemonRestartPolicy::try_new(Duration::from_secs(1), Duration::from_secs(60))
|
||||
.map_err(|_| AdminError::internal("Backup worker restart policy is invalid"))?;
|
||||
let registration = WorkerRegistration::try_new(
|
||||
id,
|
||||
WorkerRunnable::Daemon {
|
||||
restart,
|
||||
task: Box::new(task),
|
||||
},
|
||||
)
|
||||
.map_err(|_| AdminError::internal("Backup worker registration is invalid"))?;
|
||||
Ok(vec![WorkerContribution::Registration(registration)])
|
||||
}
|
||||
|
||||
fn provider_kind(value: &'static str) -> Result<ProviderKind, AdminError> {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Provider-neutral account group commands and projections.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
|
||||
use super::{PageSize, Revision, observability::DecimalAmount};
|
||||
|
||||
/// Canonical `#RRGGBBAA` color persisted with an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupColor(String);
|
||||
|
||||
impl AccountGroupColor {
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
(value.len() == 9
|
||||
&& value.starts_with('#')
|
||||
&& value[1..].bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
.then(|| Self(value.to_ascii_uppercase()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Group member availability at the current observation time.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupAccountSummary {
|
||||
pub available: u64,
|
||||
pub limited: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Group scheduling slots derived from available accounts and runtime leases.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountGroupCapacity {
|
||||
pub used_slots: Option<u64>,
|
||||
pub total_slots: u64,
|
||||
}
|
||||
|
||||
/// Successful, downstream-committed USD request costs for group accounts.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupUsage {
|
||||
pub today_usd: DecimalAmount,
|
||||
pub total_usd: DecimalAmount,
|
||||
}
|
||||
|
||||
/// Lightweight group reference embedded in account and client-key views.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRef {
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub color: AccountGroupColor,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Account group list query.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub search: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Complete account group summary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupRecord {
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
pub enabled: bool,
|
||||
pub member_count: u64,
|
||||
pub provider_counts: BTreeMap<String, u64>,
|
||||
pub client_key_count: u64,
|
||||
pub account_summary: AccountGroupAccountSummary,
|
||||
pub capacity: AccountGroupCapacity,
|
||||
pub usage: AccountGroupUsage,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Paginated account groups.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountGroupRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: u16,
|
||||
}
|
||||
|
||||
/// Create an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateAccountGroup {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Store-ready create command with a generated stable ID.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NewAccountGroup {
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Update an account group's descriptive fields.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateAccountGroup {
|
||||
pub id: AccountGroupId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub color: AccountGroupColor,
|
||||
}
|
||||
|
||||
/// Enable or disable an account group.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountGroupEnabled {
|
||||
pub id: AccountGroupId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Delete an account group that is not referenced by a client key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccountGroup {
|
||||
pub id: AccountGroupId,
|
||||
}
|
||||
|
||||
/// Result of any account-group mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountGroupMutation {
|
||||
pub config_revision: Revision,
|
||||
pub id: AccountGroupId,
|
||||
pub record: Option<AccountGroupRecord>,
|
||||
}
|
||||
@@ -7,29 +7,13 @@ use futures::Stream;
|
||||
|
||||
use gateway_core::routing::ProviderKind;
|
||||
|
||||
use super::{PageSize, Revision, observability::TimeRange};
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
|
||||
|
||||
/// Provider 账号当前可用性。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountAvailability {
|
||||
Unknown,
|
||||
Ready,
|
||||
Cooldown,
|
||||
QuotaExhausted,
|
||||
Expired,
|
||||
Banned,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// 管理页使用的归一化账号状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountStatus {
|
||||
Active,
|
||||
Expired,
|
||||
QuotaExhausted,
|
||||
Disabled,
|
||||
Banned,
|
||||
}
|
||||
pub use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
|
||||
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
|
||||
QuotaState, resolve_account_status,
|
||||
};
|
||||
|
||||
/// 账号列表排序字段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -62,19 +46,28 @@ pub struct AccountListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub provider_kind: Option<ProviderKind>,
|
||||
pub group_filter: Option<AccountGroupFilter>,
|
||||
pub search: Option<String>,
|
||||
pub status: Option<AccountStatus>,
|
||||
pub sort: Option<AccountSort>,
|
||||
}
|
||||
|
||||
/// Optional account membership filter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AccountGroupFilter {
|
||||
Group(gateway_core::routing::AccountGroupId),
|
||||
Ungrouped,
|
||||
}
|
||||
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
@@ -83,11 +76,13 @@ pub struct AccountRecord {
|
||||
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 concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
pub quota: QuotaState,
|
||||
pub last_error_reason: Option<AccountErrorReason>,
|
||||
pub last_error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -172,32 +167,68 @@ pub struct AccountUsageWindowResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPage {
|
||||
pub config_revision: Revision,
|
||||
pub items: Vec<AccountRecord>,
|
||||
pub items: Vec<AccountPageItem>,
|
||||
pub total: u64,
|
||||
pub summary: AccountSummary,
|
||||
}
|
||||
|
||||
/// 同一状态快照下的账号事实与唯一状态投影。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountPageItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: AccountStatusProjection,
|
||||
}
|
||||
|
||||
/// 统一账号目录的全局状态计数,不受当前筛选和分页影响。
|
||||
///
|
||||
/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSummary {
|
||||
pub total: u64,
|
||||
pub active: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub unavailable: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// 账号启停写入命令。
|
||||
/// 账号可编辑事实的一次性替换命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetAccountEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub struct UpdateAccount {
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
/// 账号更新结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: gateway_core::engine::credential::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 一批账号可编辑事实的一次性替换命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<AccountConcurrencyLimit>,
|
||||
pub weight: AccountWeight,
|
||||
pub group_ids: Vec<gateway_core::routing::AccountGroupId>,
|
||||
}
|
||||
|
||||
/// 批量账号更新结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<gateway_core::engine::credential::ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 账号批量删除命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteAccounts {
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -216,14 +247,14 @@ pub enum AccountConnectionTestEvent {
|
||||
Content {
|
||||
text: String,
|
||||
},
|
||||
Completed {
|
||||
account_status: AccountStatus,
|
||||
},
|
||||
Completed,
|
||||
Failed {
|
||||
message: String,
|
||||
provider_error_code: Option<String>,
|
||||
provider_error_type: Option<String>,
|
||||
account_status: AccountStatus,
|
||||
upstream_status: Option<u16>,
|
||||
upstream_content_type: Option<String>,
|
||||
upstream_body: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ impl AdminRequestContext {
|
||||
pub struct LoginCommand {
|
||||
pub username: Option<String>,
|
||||
pub password: String,
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for LoginCommand {
|
||||
@@ -48,7 +47,6 @@ impl std::fmt::Debug for LoginCommand {
|
||||
.debug_struct("LoginCommand")
|
||||
.field("username", &self.username)
|
||||
.field("password", &"[REDACTED]")
|
||||
.field("source", &self.source)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -65,8 +63,6 @@ pub struct LoginResult {
|
||||
pub enum LoginError {
|
||||
#[error("invalid administrator credentials")]
|
||||
InvalidCredentials,
|
||||
#[error("administrator login is throttled")]
|
||||
Throttled,
|
||||
#[error("administrator authentication is unavailable")]
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
//! S3 备份领域模型、状态机与稳定错误。
|
||||
//!
|
||||
//! 领域类型只携带事实,不承载基础设施细节。Secret 使用 [`secrecy::SecretString`],
|
||||
//! 全流程不实现 `Serialize`,避免被错误地写入 wire 或日志。管理员身份等审计事实
|
||||
//! 只存在于 `admin_audit_events`,本模型不重复保存。
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use secrecy::SecretString;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::PageSize;
|
||||
|
||||
/// 备份触发来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BackupTriggerKind {
|
||||
Manual,
|
||||
Scheduled,
|
||||
}
|
||||
|
||||
impl BackupTriggerKind {
|
||||
/// 稳定持久化值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Manual => "manual",
|
||||
Self::Scheduled => "scheduled",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"manual" => Some(Self::Manual),
|
||||
"scheduled" => Some(Self::Scheduled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BackupTriggerKind {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务状态机(六状态)。
|
||||
///
|
||||
/// 迁移 `backup_records_lifecycle_ck` 与字段空值语义共同定义合法持久化行;
|
||||
/// [`Self::allows_transition_to`] 只约束领域内的显式状态迁移,两层都必须满足。
|
||||
/// 删除成功后记录行被硬删除,因此不存在 `deleted` 终态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum BackupStatus {
|
||||
Queued,
|
||||
Dumping,
|
||||
Uploading,
|
||||
Completed,
|
||||
Failed,
|
||||
Deleting,
|
||||
}
|
||||
|
||||
impl BackupStatus {
|
||||
/// 稳定持久化值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Queued => "queued",
|
||||
Self::Dumping => "dumping",
|
||||
Self::Uploading => "uploading",
|
||||
Self::Completed => "completed",
|
||||
Self::Failed => "failed",
|
||||
Self::Deleting => "deleting",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析持久化值。
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"queued" => Some(Self::Queued),
|
||||
"dumping" => Some(Self::Dumping),
|
||||
"uploading" => Some(Self::Uploading),
|
||||
"completed" => Some(Self::Completed),
|
||||
"failed" => Some(Self::Failed),
|
||||
"deleting" => Some(Self::Deleting),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 该状态是否消耗全局活跃名额。
|
||||
#[must_use]
|
||||
pub const fn is_active(self) -> bool {
|
||||
matches!(self, Self::Queued | Self::Dumping | Self::Uploading)
|
||||
}
|
||||
|
||||
/// 该状态是否允许进入删除流程。
|
||||
#[must_use]
|
||||
pub const fn can_be_deleted(self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed)
|
||||
}
|
||||
|
||||
/// 领域内允许的显式迁移。
|
||||
#[must_use]
|
||||
pub fn allows_transition_to(self, target: BackupStatus) -> bool {
|
||||
matches!(
|
||||
(self, target),
|
||||
(Self::Queued, Self::Dumping)
|
||||
| (Self::Queued, Self::Failed)
|
||||
| (Self::Dumping, Self::Uploading)
|
||||
| (Self::Dumping, Self::Failed)
|
||||
| (Self::Uploading, Self::Completed)
|
||||
| (Self::Uploading, Self::Failed)
|
||||
| (Self::Completed, Self::Deleting)
|
||||
| (Self::Failed, Self::Deleting)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for BackupStatus {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 存储配置更新命令;`secret_access_key` 为 `None` 表示保留旧值。
|
||||
#[derive(Clone)]
|
||||
pub struct UpdateBackupStorageCommand {
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: Option<SecretString>,
|
||||
pub prefix: String,
|
||||
pub force_path_style: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for UpdateBackupStorageCommand {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("UpdateBackupStorageCommand")
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &self.secret_access_key.is_some())
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度配置更新命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateBackupScheduleCommand {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
|
||||
/// 完整保存的备份设置(含 Secret)。
|
||||
///
|
||||
/// 只从仓储读取;不实现 `Serialize`,防止 Secret 进入 wire 或日志。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupSettings {
|
||||
pub storage_revision: u64,
|
||||
pub endpoint: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
pub access_key_id: Option<String>,
|
||||
pub secret_access_key: Option<SecretString>,
|
||||
pub prefix: Option<String>,
|
||||
pub force_path_style: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
pub last_verified_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackupSettings {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BackupSettings")
|
||||
.field("storage_revision", &self.storage_revision)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &self.secret_access_key.is_some())
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.field("schedule_enabled", &self.schedule_enabled)
|
||||
.field("cron_expression", &self.cron_expression)
|
||||
.field("schedule_timezone", &self.schedule_timezone)
|
||||
.field("retention_days", &self.retention_days)
|
||||
.field("retention_count", &self.retention_count)
|
||||
.field("next_run_at", &self.next_run_at)
|
||||
.field("last_verified_at", &self.last_verified_at)
|
||||
.field("updated_at", &self.updated_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupSettings {
|
||||
/// S3 存储配置是否完整(bucket/region/密钥/前缀齐全)。
|
||||
#[must_use]
|
||||
pub fn storage_configured(&self) -> bool {
|
||||
self.endpoint.is_some()
|
||||
&& self.region.is_some()
|
||||
&& self.bucket.is_some()
|
||||
&& self.access_key_id.is_some()
|
||||
&& self.secret_access_key.is_some()
|
||||
&& self.prefix.is_some()
|
||||
}
|
||||
|
||||
/// 探测是否已对当前 revision 成功;`last_verified_at` 非空即表示当前配置已通过探针。
|
||||
#[must_use]
|
||||
pub fn storage_verified(&self) -> bool {
|
||||
self.last_verified_at.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// 已经校验并可用于对象存储操作的存储配置快照。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorageConfig {
|
||||
pub storage_revision: u64,
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
pub secret_access_key: SecretString,
|
||||
pub prefix: String,
|
||||
pub force_path_style: bool,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BackupStorageConfig {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BackupStorageConfig")
|
||||
.field("storage_revision", &self.storage_revision)
|
||||
.field("endpoint", &self.endpoint)
|
||||
.field("region", &self.region)
|
||||
.field("bucket", &self.bucket)
|
||||
.field("access_key_id", &self.access_key_id)
|
||||
.field("secret_access_key", &"[REDACTED]")
|
||||
.field("prefix", &self.prefix)
|
||||
.field("force_path_style", &self.force_path_style)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl BackupStorageConfig {
|
||||
/// 从完整设置推导对象存储配置;设置不完整时返回 `None`。
|
||||
#[must_use]
|
||||
pub fn from_settings(settings: &BackupSettings) -> Option<Self> {
|
||||
Some(Self {
|
||||
storage_revision: settings.storage_revision,
|
||||
endpoint: settings.endpoint.clone()?,
|
||||
region: settings.region.clone()?,
|
||||
bucket: settings.bucket.clone()?,
|
||||
access_key_id: settings.access_key_id.clone()?,
|
||||
secret_access_key: settings.secret_access_key.clone()?,
|
||||
prefix: settings.prefix.clone()?,
|
||||
force_path_style: settings.force_path_style,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条备份记录。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecord {
|
||||
pub id: String,
|
||||
pub trigger_kind: BackupTriggerKind,
|
||||
pub status: BackupStatus,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub object_key: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
/// 手动过期时间;创建时确定,到期自动清理。
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 备份记录分页查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordListQuery {
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub status: Option<BackupStatus>,
|
||||
pub trigger: Option<BackupTriggerKind>,
|
||||
}
|
||||
|
||||
/// 备份记录分页结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordPage {
|
||||
pub items: Vec<BackupRecord>,
|
||||
pub total: u64,
|
||||
pub page: u32,
|
||||
pub page_size: PageSize,
|
||||
}
|
||||
|
||||
impl BackupRecordPage {
|
||||
/// 计算总页数;空集合视为一页。
|
||||
#[must_use]
|
||||
pub fn total_pages(&self) -> u32 {
|
||||
let size = u64::from(self.page_size.get());
|
||||
if self.total == 0 {
|
||||
1
|
||||
} else {
|
||||
self.total.div_ceil(size).min(u64::from(u32::MAX)) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建备份任务的持久化数据;id 与对象 key 由领域生成。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupRecordSeed {
|
||||
pub id: String,
|
||||
pub trigger_kind: BackupTriggerKind,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub object_key: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 连接测试结果;只携带稳定阶段与脱敏消息。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub ok: bool,
|
||||
pub stage: &'static str,
|
||||
pub code: Option<&'static str>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 对象存储探测阶段。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConnectionTestStage {
|
||||
PutObject,
|
||||
HeadObject,
|
||||
GetObject,
|
||||
DeleteObject,
|
||||
}
|
||||
|
||||
impl ConnectionTestStage {
|
||||
/// 稳定 wire 值。
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::PutObject => "putObject",
|
||||
Self::HeadObject => "headObject",
|
||||
Self::GetObject => "getObject",
|
||||
Self::DeleteObject => "deleteObject",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传到对象存储并期望远端校验的归档 metadata。
|
||||
///
|
||||
/// `size_bytes` 在上传时不写入 metadata,但 `HeadObject` 会返回远端大小,
|
||||
/// 用于与本地归档校验一致。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackupObjectMetadata {
|
||||
pub backup_id: String,
|
||||
pub sha256: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub size_bytes: u64,
|
||||
}
|
||||
|
||||
impl BackupObjectMetadata {
|
||||
/// 构造上传 metadata。
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
backup_id: String,
|
||||
sha256: String,
|
||||
created_at: DateTime<Utc>,
|
||||
size_bytes: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
backup_id,
|
||||
sha256,
|
||||
created_at,
|
||||
size_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 短时下载地址结果。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct DownloadUrlResult {
|
||||
pub url: String,
|
||||
pub file_name: String,
|
||||
pub expires_in: std::time::Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DownloadUrlResult {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DownloadUrlResult")
|
||||
.field("url", &"[REDACTED]")
|
||||
.field("file_name", &self.file_name)
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份基础设施错误:只携带稳定错误码与脱敏消息。
|
||||
///
|
||||
/// 该类型不构成第二套 kind 枚举;`gateway-admin` 边界统一映射为既有
|
||||
/// `AdminErrorKind`。稳定 `error_code` 同时用于任务记录、日志与 UI。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("{message}")]
|
||||
pub struct BackupError {
|
||||
code: &'static str,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl BackupError {
|
||||
/// 构造基础设施错误。
|
||||
#[must_use]
|
||||
pub const fn new(code: &'static str, message: String) -> Self {
|
||||
Self { code, message }
|
||||
}
|
||||
|
||||
/// 稳定错误码,例如 `backup.s3_auth_failed`。
|
||||
#[must_use]
|
||||
pub const fn code(&self) -> &'static str {
|
||||
self.code
|
||||
}
|
||||
|
||||
/// 脱敏后的可操作消息。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造稳定错误码常量。
|
||||
pub mod code {
|
||||
/// 配置或参数非法。
|
||||
pub const INVALID_CONFIG: &str = "backup.invalid_config";
|
||||
/// Cron 表达式非法。
|
||||
pub const INVALID_CRON: &str = "backup.invalid_cron";
|
||||
/// IANA 时区非法。
|
||||
pub const INVALID_TIMEZONE: &str = "backup.invalid_timezone";
|
||||
/// 记录不存在。
|
||||
pub const RECORD_NOT_FOUND: &str = "backup.record_not_found";
|
||||
/// 已存在活跃任务,拒绝创建。
|
||||
pub const ACTIVE_TASK_CONFLICT: &str = "backup.active_task_conflict";
|
||||
/// 记录状态与期望不符,无法迁移。
|
||||
pub const STATE_CONFLICT: &str = "backup.state_conflict";
|
||||
/// 已有记录时禁止切换存储目标。
|
||||
pub const STORAGE_IDENTITY_LOCKED: &str = "backup.storage_identity_locked";
|
||||
/// `pg_dump` 非零退出或失败。
|
||||
pub const PG_DUMP_FAILED: &str = "backup.pg_dump_failed";
|
||||
/// 暂存磁盘空间不足。
|
||||
pub const STAGING_SPACE_EXHAUSTED: &str = "backup.staging_space_exhausted";
|
||||
/// S3 认证失败。
|
||||
pub const S3_AUTH_FAILED: &str = "backup.s3_auth_failed";
|
||||
/// S3 权限拒绝。
|
||||
pub const S3_PERMISSION_DENIED: &str = "backup.s3_permission_denied";
|
||||
/// S3 上传失败。
|
||||
pub const S3_UPLOAD_FAILED: &str = "backup.s3_upload_failed";
|
||||
/// 远端对象校验失败。
|
||||
pub const REMOTE_VERIFICATION_FAILED: &str = "backup.remote_verification_failed";
|
||||
/// 任务被取消。
|
||||
pub const CANCELLED: &str = "backup.cancelled";
|
||||
/// 仓储或对象存储暂不可用。
|
||||
pub const STORE_UNAVAILABLE: &str = "backup.store_unavailable";
|
||||
/// 上游 S3 服务返回无效或失败响应。
|
||||
pub const UPSTREAM_FAILURE: &str = "backup.upstream_failure";
|
||||
}
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
|
||||
///
|
||||
/// 规范化规则:prefix 不得为空、不得以前导 `/` 开头、不得包含 `..`、反斜杠或控制字符;
|
||||
/// 输出统一使用 `/` 分隔且不含空段。
|
||||
pub fn build_object_key(
|
||||
prefix: &str,
|
||||
backup_id: &str,
|
||||
at: DateTime<Utc>,
|
||||
) -> Result<String, BackupError> {
|
||||
let prefix = normalize_prefix(prefix)?;
|
||||
Ok(format!(
|
||||
"{prefix}/{}/{:02}/{:02}/codex-proxy-rs_{}_{}.dump",
|
||||
at.format("%Y"),
|
||||
at.format("%m"),
|
||||
at.format("%d"),
|
||||
at.format("%Y%m%d_%H%M%S"),
|
||||
short_backup_id(backup_id),
|
||||
))
|
||||
}
|
||||
|
||||
/// 下载文件名:`codex-proxy-rs_{短 id}.dump`,与对象 key 的文件名一致。
|
||||
#[must_use]
|
||||
pub fn build_download_file_name(backup_id: &str) -> String {
|
||||
format!("codex-proxy-rs_{}.dump", short_backup_id(backup_id))
|
||||
}
|
||||
|
||||
/// 截取备份 id 的可读短标识:去掉 `backup_` 前缀,最多保留 8 位 hex。
|
||||
#[must_use]
|
||||
fn short_backup_id(backup_id: &str) -> String {
|
||||
let hex = backup_id.strip_prefix("backup_").unwrap_or(backup_id);
|
||||
hex.get(..8).unwrap_or(hex).to_owned()
|
||||
}
|
||||
|
||||
/// 规范化并校验对象 prefix。
|
||||
///
|
||||
/// 返回去掉尾部 `/` 的规范 prefix;空串、前导 `/`、`..` 段、反斜杠和控制字符均拒绝。
|
||||
pub fn normalize_prefix(prefix: &str) -> Result<String, BackupError> {
|
||||
if prefix.is_empty() || prefix.starts_with('/') {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀必须非空且不能以前导 / 开头".to_owned(),
|
||||
));
|
||||
}
|
||||
if prefix.bytes().any(|byte| byte.is_ascii_control()) || prefix.contains('\\') {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀包含非法字符".to_owned(),
|
||||
));
|
||||
}
|
||||
if prefix.split('/').any(|segment| segment == "..") {
|
||||
return Err(BackupError::new(
|
||||
code::INVALID_CONFIG,
|
||||
"存储对象前缀不能包含 .. 路径段".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(prefix.trim_end_matches('/').to_owned())
|
||||
}
|
||||
|
||||
/// 生成新的备份记录 id。
|
||||
#[must_use]
|
||||
pub fn new_backup_id() -> String {
|
||||
format!("backup_{}", Uuid::now_v7().simple())
|
||||
}
|
||||
|
||||
/// 从触发类型与计划时间构造可持久化 seed;id 与对象 key 一次生成保持一致。
|
||||
pub fn build_backup_seed(
|
||||
trigger_kind: BackupTriggerKind,
|
||||
scheduled_at: Option<DateTime<Utc>>,
|
||||
prefix: &str,
|
||||
at: DateTime<Utc>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecordSeed, BackupError> {
|
||||
let id = new_backup_id();
|
||||
let object_key = build_object_key(prefix, &id, at)?;
|
||||
Ok(BackupRecordSeed {
|
||||
id,
|
||||
trigger_kind,
|
||||
scheduled_at,
|
||||
object_key,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
@@ -6,10 +6,10 @@ use chrono::{DateTime, Utc};
|
||||
|
||||
use gateway_core::{
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::ProviderKind,
|
||||
routing::{AccountGroupId, ProviderKind},
|
||||
};
|
||||
|
||||
use super::{AdminModelError, Revision};
|
||||
use super::{AdminModelError, Revision, account_groups::AccountGroupRef};
|
||||
|
||||
/// Client Key 列表保持旧 HTTP 合同允许的完整非零 `u16` 页大小。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
@@ -88,7 +88,8 @@ pub struct ClientKeyRecord {
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub groups: Vec<AccountGroupRef>,
|
||||
pub provider_kinds: Vec<ProviderKind>,
|
||||
pub prefix: String,
|
||||
pub enabled: bool,
|
||||
pub limits: RateLimits,
|
||||
@@ -141,21 +142,19 @@ impl fmt::Debug for ClientKeySecret {
|
||||
/// API 提交的 Client Key 创建命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CreateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
}
|
||||
|
||||
/// 管理用例生成 ID 与明文后的持久化命令。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NewClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
pub plaintext: String,
|
||||
}
|
||||
@@ -166,7 +165,7 @@ impl fmt::Debug for NewClientKey {
|
||||
.debug_struct("NewClientKey")
|
||||
.field("id", &self.id)
|
||||
.field("name", &self.name)
|
||||
.field("provider_kind", &self.provider_kind)
|
||||
.field("group_ids", &self.group_ids)
|
||||
.field("plaintext", &"[REDACTED]")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
@@ -175,18 +174,16 @@ impl fmt::Debug for NewClientKey {
|
||||
/// 修改 Client Key 的公开策略字段。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub name: String,
|
||||
pub label: Option<String>,
|
||||
pub provider_kind: ProviderKind,
|
||||
pub group_ids: Vec<AccountGroupId>,
|
||||
pub limits: RateLimits,
|
||||
}
|
||||
|
||||
/// 修改 Client Key 启用状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyEnabled {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -194,7 +191,6 @@ pub struct SetClientKeyEnabled {
|
||||
/// 删除 Client Key。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteClientKey {
|
||||
pub expected_config_revision: Revision,
|
||||
pub id: ClientApiKeyId,
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
@@ -50,6 +52,11 @@ impl AdminError {
|
||||
Self::new(AdminErrorKind::Invalid, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bad_request(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)
|
||||
@@ -60,6 +67,11 @@ impl AdminError {
|
||||
Self::new(AdminErrorKind::Conflict, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn bad_gateway(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::BadGateway, message)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn unavailable(message: impl Into<String>) -> Self {
|
||||
Self::new(AdminErrorKind::Unavailable, message)
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
|
||||
use std::{num::NonZeroU32, str::FromStr};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use chrono::{DateTime, TimeDelta, Timelike as _, Utc};
|
||||
|
||||
use super::{AdminModelError, PageSize};
|
||||
|
||||
/// 观测日界所用的东八区(UTC+8)固定偏移秒数。
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
|
||||
/// 将 UTC 时刻截断到东八区(UTC+8)当日零点,返回值仍为 UTC。
|
||||
#[must_use]
|
||||
pub fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - TimeDelta::seconds(elapsed) - TimeDelta::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
/// 外部观测查询的 UTC 时间范围。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TimeRange {
|
||||
@@ -194,42 +204,22 @@ impl DecimalAmount {
|
||||
/// 精确相加两个 `numeric(20,10)` 金额。
|
||||
#[must_use]
|
||||
pub fn checked_add(&self, other: &Self) -> Option<Self> {
|
||||
let sum = scaled_amount(&self.0)?.checked_add(scaled_amount(&other.0)?)?;
|
||||
Self::from_scaled(sum)
|
||||
Self::from_decimal(self.to_decimal()?.checked_add(other.to_decimal()?)?)
|
||||
}
|
||||
|
||||
/// 将金额按非零请求数均分,保留最多十位小数。
|
||||
#[must_use]
|
||||
pub fn checked_div_u64(&self, divisor: u64) -> Option<Self> {
|
||||
let divisor = u128::from(divisor);
|
||||
(divisor != 0)
|
||||
.then(|| scaled_amount(&self.0)?.checked_div(divisor))
|
||||
.flatten()
|
||||
.and_then(Self::from_scaled)
|
||||
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
|
||||
}
|
||||
|
||||
fn from_scaled(value: u128) -> Option<Self> {
|
||||
if value >= 10_u128.pow(20) {
|
||||
return None;
|
||||
}
|
||||
let whole = value / 10_u128.pow(10);
|
||||
let fraction = value % 10_u128.pow(10);
|
||||
let value = if fraction == 0 {
|
||||
whole.to_string()
|
||||
} else {
|
||||
format!("{whole}.{fraction:010}")
|
||||
.trim_end_matches('0')
|
||||
.to_owned()
|
||||
};
|
||||
Some(Self(value))
|
||||
fn to_decimal(&self) -> Option<gateway_core::accounting::Decimal> {
|
||||
self.0.parse().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled_amount(value: &str) -> Option<u128> {
|
||||
let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
|
||||
let whole = whole.parse::<u128>().ok()?;
|
||||
let fraction = format!("{fraction:0<10}").parse::<u128>().ok()?;
|
||||
whole.checked_mul(10_u128.pow(10))?.checked_add(fraction)
|
||||
fn from_decimal(value: gateway_core::accounting::Decimal) -> Option<Self> {
|
||||
Some(Self(value.canonical()))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for DecimalAmount {
|
||||
@@ -320,6 +310,7 @@ pub struct LatencyPercentiles {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderBillingInput {
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
@@ -335,6 +326,7 @@ pub struct UsageCalculatedBillingFact {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub provider_kind: String,
|
||||
pub upstream_model_id: String,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
@@ -447,18 +439,15 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 账号池容量统计。
|
||||
/// 账号池的统一五态统计。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
pub enabled: u64,
|
||||
pub unavailable: u64,
|
||||
pub active: u64,
|
||||
pub expired: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub refreshing: Option<u64>,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub banned: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的模型级用量事实。
|
||||
@@ -489,7 +478,7 @@ pub struct DashboardAccountRequestBucket {
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 中一个账号的完整用量与公共状态事实。
|
||||
/// Dashboard 中一个账号的完整用量事实。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardAccountUsage {
|
||||
pub account_id: String,
|
||||
@@ -498,8 +487,6 @@ pub struct DashboardAccountUsage {
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub availability: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub input_tokens: Option<u64>,
|
||||
@@ -526,18 +513,31 @@ pub struct DashboardAccountUsage {
|
||||
/// Dashboard 当前账号池的可重建运行时槽位事实。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DashboardRuntimeSlots {
|
||||
/// 与持久账号池指标相同谓词下可参与调度的账号数。
|
||||
pub active_accounts: u64,
|
||||
/// 继承运行参数默认并发上限的可调度账号数。
|
||||
pub inherited_accounts: u64,
|
||||
/// 已配置账号级并发上限的可调度账号槽位总数。
|
||||
pub overridden_slots: u64,
|
||||
/// Redis 可用时的实时 in-flight 槽位总数;不可用时为 `None`。
|
||||
pub used_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 仪表盘所需的公共观测事实。
|
||||
/// 仪表盘卡片脚注展示的全历史累计。
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct DashboardTotals {
|
||||
pub request_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub billing_usd: Option<DecimalAmount>,
|
||||
}
|
||||
|
||||
/// 仪表盘所需的当前区间事实及全历史累计。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DashboardObservation {
|
||||
pub range: TimeRange,
|
||||
pub requests: RequestMetrics,
|
||||
pub attempts: AttemptMetrics,
|
||||
pub totals: DashboardTotals,
|
||||
pub provider_accounts: AccountPoolMetrics,
|
||||
pub trend: Vec<RequestMetricPoint>,
|
||||
pub account_usage: Vec<DashboardAccountUsage>,
|
||||
@@ -550,6 +550,9 @@ pub struct UsageRecord {
|
||||
pub id: String,
|
||||
pub client_api_key_ref: String,
|
||||
pub config_revision: u64,
|
||||
pub routing_scope: String,
|
||||
pub routing_group_refs: Vec<String>,
|
||||
pub routing_group_names_snapshot: Vec<String>,
|
||||
pub protocol: String,
|
||||
pub operation: String,
|
||||
pub endpoint: String,
|
||||
@@ -564,6 +567,7 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明。
|
||||
pub provider_metadata_json: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
@@ -632,6 +636,9 @@ pub struct UsageAttempt {
|
||||
pub operation: String,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_send_state: Option<String>,
|
||||
@@ -692,6 +699,7 @@ pub struct UsageSummary {
|
||||
/// 单个诊断维度值的聚合结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiagnosticObservation {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
@@ -699,6 +707,7 @@ pub struct DiagnosticObservation {
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub cost_coverage: CostCoverage,
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
@@ -713,8 +722,12 @@ pub struct OpsError {
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub component: String,
|
||||
pub operation: String,
|
||||
pub endpoint: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
pub provider_account_name: Option<String>,
|
||||
pub provider_account_email: Option<String>,
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub failure_kind: String,
|
||||
@@ -1008,6 +1021,7 @@ pub struct UsageInsights {
|
||||
/// 已完成分母计算的诊断项。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DiagnosticsItem {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
@@ -1015,6 +1029,7 @@ pub struct DiagnosticsItem {
|
||||
pub error_rate: f64,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub estimated_cost: Option<DecimalAmount>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId},
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
|
||||
use super::{
|
||||
MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{AccountAvailability, AccountRecord, AccountStatus, AccountSummary, AccountUsage},
|
||||
AdminError, MutationActor, MutationContext, PageSize, Revision,
|
||||
accounts::{AccountRecord, AccountSummary, AccountUsage, CredentialState},
|
||||
};
|
||||
|
||||
/// Provider-owned JSON;公共层只搬运且 Debug 不输出值。
|
||||
@@ -51,24 +52,24 @@ pub struct CredentialCursor {
|
||||
/// Provider credential 列表查询。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialListQuery {
|
||||
pub availability: Option<CredentialAvailabilityFilter>,
|
||||
pub credential_state: Option<CredentialStateFilter>,
|
||||
pub enabled: Option<bool>,
|
||||
pub window: CredentialListWindow,
|
||||
}
|
||||
|
||||
/// Credential 可用性筛选;支持一个 wire 值对应多个持久状态。
|
||||
/// Credential 状态筛选。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CredentialAvailabilityFilter {
|
||||
Exact(AccountAvailability),
|
||||
AnyOf(Vec<AccountAvailability>),
|
||||
pub enum CredentialStateFilter {
|
||||
Exact(CredentialState),
|
||||
AnyOf(Vec<CredentialState>),
|
||||
}
|
||||
|
||||
impl CredentialAvailabilityFilter {
|
||||
impl CredentialStateFilter {
|
||||
#[must_use]
|
||||
pub fn matches(&self, availability: AccountAvailability) -> bool {
|
||||
pub fn matches(&self, credential_state: CredentialState) -> bool {
|
||||
match self {
|
||||
Self::Exact(expected) => *expected == availability,
|
||||
Self::AnyOf(expected) => expected.contains(&availability),
|
||||
Self::Exact(expected) => *expected == credential_state,
|
||||
Self::AnyOf(expected) => expected.contains(&credential_state),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +102,6 @@ pub struct CredentialDetails {
|
||||
/// Provider 正式文档批量导入命令。
|
||||
pub struct ImportCredentials {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub document: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -110,7 +110,6 @@ impl fmt::Debug for ImportCredentials {
|
||||
formatter
|
||||
.debug_struct("ImportCredentials")
|
||||
.field("context", &self.context)
|
||||
.field("expected_config_revision", &self.expected_config_revision)
|
||||
.field("document", &self.document)
|
||||
.finish()
|
||||
}
|
||||
@@ -144,7 +143,7 @@ pub struct PreparedCredentialCreate {
|
||||
pub provider_kind: ProviderKind,
|
||||
pub name: String,
|
||||
pub email: Option<String>,
|
||||
pub upstream_user_id: String,
|
||||
pub upstream_user_id: Option<String>,
|
||||
pub upstream_account_id: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
@@ -153,10 +152,8 @@ pub struct PreparedCredentialCreate {
|
||||
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 credential_state: CredentialState,
|
||||
pub credential_observed_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Provider 对一份导入文档的完整验证结果。
|
||||
@@ -169,17 +166,9 @@ pub struct PreparedCredentialImport {
|
||||
/// Admin 交给 Store 的导入事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialImportCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialImport,
|
||||
}
|
||||
|
||||
/// 可选的重新授权目标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReauthorizationTarget {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub credential_revision: Revision,
|
||||
}
|
||||
|
||||
/// OAuth pending owner 的中立身份;不编码具体 Provider 的 Redis key 或 JSON。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationOwner {
|
||||
@@ -240,21 +229,18 @@ impl fmt::Debug for AuthorizationOwnerBinding {
|
||||
}
|
||||
|
||||
/// OAuth 完成时应创建新账号还是 CAS 更新既有 credential。
|
||||
///
|
||||
/// 重新授权只绑定稳定的账号身份:credential revision 由服务端在临近写入时读取,
|
||||
/// 长流程期间的后台刷新不得让恢复操作失效。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AuthorizationMutationTarget {
|
||||
Create {
|
||||
name: String,
|
||||
},
|
||||
Reauthorize {
|
||||
account_id: ProviderAccountId,
|
||||
expected_credential_revision: Revision,
|
||||
},
|
||||
Create { name: String },
|
||||
Reauthorize { account_id: ProviderAccountId },
|
||||
}
|
||||
|
||||
/// 必须完整进入 Provider opaque pending payload、并在 complete 后原样恢复的事务信封。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PendingAuthorizationMutation {
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
@@ -263,24 +249,17 @@ pub struct PendingAuthorizationMutation {
|
||||
impl PendingAuthorizationMutation {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
expected_config_revision: Revision,
|
||||
provider_kind: ProviderKind,
|
||||
target: AuthorizationMutationTarget,
|
||||
owner_binding: AuthorizationOwnerBinding,
|
||||
) -> Self {
|
||||
Self {
|
||||
expected_config_revision,
|
||||
provider_kind,
|
||||
target,
|
||||
owner_binding,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn expected_config_revision(&self) -> Revision {
|
||||
self.expected_config_revision
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.provider_kind
|
||||
@@ -301,9 +280,8 @@ impl PendingAuthorizationMutation {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StartAuthorization {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub name: String,
|
||||
pub reauthorization: Option<ReauthorizationTarget>,
|
||||
pub reauthorization: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// OAuth 流程启动结果。
|
||||
@@ -322,6 +300,20 @@ 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),
|
||||
@@ -332,6 +324,7 @@ pub enum PreparedAuthorizationCredential {
|
||||
pub struct PreparedAuthorizationCommit {
|
||||
pub pending: PendingAuthorizationMutation,
|
||||
pub credential: PreparedAuthorizationCredential,
|
||||
authorization_guard: Option<Box<dyn AuthorizationCommitGuard>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PreparedAuthorizationCommit {
|
||||
@@ -358,9 +351,37 @@ pub struct AuthorizationCommit {
|
||||
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 into_commit(self) -> (AuthorizationCommit, Option<Box<dyn CredentialCommitGuard>>) {
|
||||
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)
|
||||
@@ -373,13 +394,27 @@ impl PreparedAuthorizationCommit {
|
||||
)
|
||||
}
|
||||
};
|
||||
(
|
||||
AuthorizationCommit {
|
||||
AuthorizationCommitSettlement {
|
||||
command: AuthorizationCommit {
|
||||
pending: self.pending,
|
||||
credential,
|
||||
},
|
||||
guard,
|
||||
)
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,7 +441,6 @@ impl fmt::Debug for CompleteAuthorization {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialMutation {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
@@ -422,7 +456,6 @@ pub struct CredentialMutationResult {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialDeletion {
|
||||
pub context: MutationContext,
|
||||
pub expected_config_revision: Revision,
|
||||
pub account_ids: Vec<ProviderAccountId>,
|
||||
}
|
||||
|
||||
@@ -436,14 +469,12 @@ pub struct CredentialDeletionResult {
|
||||
/// Provider-owned token 轮换命令。
|
||||
pub struct RotateCredential {
|
||||
pub mutation: CredentialMutation,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
/// Provider 校验手工轮换材料时所需的非事务输入。
|
||||
pub struct PrepareCredentialRotation {
|
||||
pub account: AccountRecord,
|
||||
pub expected_credential_revision: Revision,
|
||||
pub provider_material: ProviderDocument,
|
||||
}
|
||||
|
||||
@@ -452,10 +483,6 @@ impl fmt::Debug for PrepareCredentialRotation {
|
||||
formatter
|
||||
.debug_struct("PrepareCredentialRotation")
|
||||
.field("account", &self.account)
|
||||
.field(
|
||||
"expected_credential_revision",
|
||||
&self.expected_credential_revision,
|
||||
)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
@@ -467,6 +494,7 @@ 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>,
|
||||
@@ -520,7 +548,6 @@ impl fmt::Debug for PreparedCredentialRotation {
|
||||
/// Admin 交给 Store 的轮换或 refresh 事务命令。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CredentialRotationCommit {
|
||||
pub expected_config_revision: Revision,
|
||||
pub prepared: PreparedCredentialRotationFacts,
|
||||
}
|
||||
|
||||
@@ -529,15 +556,20 @@ impl fmt::Debug for RotateCredential {
|
||||
formatter
|
||||
.debug_struct("RotateCredential")
|
||||
.field("mutation", &self.mutation)
|
||||
.field(
|
||||
"expected_credential_revision",
|
||||
&self.expected_credential_revision,
|
||||
)
|
||||
.field("provider_material", &self.provider_material)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用账号用量能否可靠归属到一个 Provider quota 窗口。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuotaLocalUsageAttribution {
|
||||
/// 窗口覆盖账号的全部请求,可按账号与时间范围聚合。
|
||||
AccountWide,
|
||||
/// 窗口需要 Provider / 模型级归属,通用账号聚合不可用。
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// 一个 Provider quota 窗口的公共投影。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProviderQuotaWindow {
|
||||
@@ -545,9 +577,11 @@ pub struct ProviderQuotaWindow {
|
||||
pub group: String,
|
||||
pub label: String,
|
||||
pub source: Option<String>,
|
||||
pub local_usage_attribution: QuotaLocalUsageAttribution,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub used_percent: Option<f64>,
|
||||
pub reset_at: Option<DateTime<Utc>>,
|
||||
pub limit_reached: bool,
|
||||
pub local_usage: Option<AccountUsage>,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
@@ -566,35 +600,105 @@ pub struct ProviderQuota {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生。
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
impl ProviderQuota {
|
||||
/// 返回账号展开面板使用的当前额度窗口用量。
|
||||
///
|
||||
/// Provider 已提供账号级本地用量时,固定窗口和无固定重置点的滚动窗口
|
||||
/// 都可作为代表用量;优先级与 Dashboard 代表性额度窗口一致,
|
||||
/// 同一优先级保持 Provider 投影顺序。
|
||||
#[must_use]
|
||||
pub fn representative_window_usage(&self) -> Option<&AccountUsage> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, window)| {
|
||||
window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
&& window.window_seconds.is_some()
|
||||
&& window.local_usage.is_some()
|
||||
})
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.and_then(|(_, window)| window.local_usage.as_ref())
|
||||
}
|
||||
|
||||
/// 返回 Dashboard 使用的代表性额度比例。
|
||||
///
|
||||
/// 优先使用短周期窗口,再依次使用周、月和其它窗口;同一优先级取较高的已用比例。
|
||||
/// 这里只解释跨 Provider 共享的窗口语义,绝不读取 Provider 私有 JSON。
|
||||
#[must_use]
|
||||
pub fn representative_used_percent(&self) -> Option<f64> {
|
||||
if self.limit_reached {
|
||||
return Some(100.0);
|
||||
}
|
||||
self.representative_used_window()
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
}
|
||||
|
||||
/// 将已确认的账号级额度耗尽事实投影到展示窗口。
|
||||
///
|
||||
/// Provider 已指出具体触顶窗口时只归一化这些窗口;否则归一化 Dashboard
|
||||
/// 同样会选择的代表窗口,避免把多个独立额度窗口全部伪造成已用尽。
|
||||
pub fn apply_limit_reached_display(&mut self) {
|
||||
if !self.limit_reached {
|
||||
return;
|
||||
}
|
||||
let reached_windows = self
|
||||
.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, window)| window.limit_reached.then_some(index))
|
||||
.collect::<Vec<_>>();
|
||||
if !reached_windows.is_empty() {
|
||||
for index in reached_windows {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let representative = self
|
||||
.representative_used_window()
|
||||
.map(|(index, _)| index)
|
||||
.or_else(|| {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(index, window)| (quota_usage_priority(window), *index))
|
||||
.map(|(index, _)| index)
|
||||
});
|
||||
if let Some(index) = representative {
|
||||
self.windows[index].used_percent = Some(100.0);
|
||||
self.windows[index].limit_reached = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn representative_used_window(&self) -> Option<(usize, f64)> {
|
||||
self.windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|window| {
|
||||
let (index, window) = 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))
|
||||
Some((index, 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) =>
|
||||
{
|
||||
.fold(
|
||||
None::<(usize, u8, f64)>,
|
||||
|selected, candidate| match selected {
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
})
|
||||
.map(|(_, used_percent)| used_percent)
|
||||
if current.1 < candidate.1
|
||||
|| (current.1 == candidate.1 && current.2 >= candidate.2) =>
|
||||
{
|
||||
Some(current)
|
||||
}
|
||||
_ => Some(candidate),
|
||||
},
|
||||
)
|
||||
.map(|(index, _, used_percent)| (index, used_percent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,7 +765,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub status: AccountStatus,
|
||||
pub projection: gateway_core::engine::credential::AccountStatusProjection,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
}
|
||||
|
||||
@@ -11,14 +11,8 @@ use super::Revision;
|
||||
/// 客户端模型到上游模型的全局精确映射。
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// 账号调度策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationStrategy {
|
||||
Smart,
|
||||
QuotaResetPriority,
|
||||
RoundRobin,
|
||||
Sticky,
|
||||
}
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
|
||||
pub use gateway_core::engine::credential::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -39,7 +33,6 @@ pub struct RuntimeSettings {
|
||||
/// 原子替换运行设置的命令。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub expected_config_revision: Revision,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
//! 备份控制面所需的持久化与基础设施能力。
|
||||
//!
|
||||
//! 端口方法只接受/返回领域类型与既有错误体系:`BackupRepository` 复用
|
||||
//! `AdminStoreError`;`DatabaseDumpPort` 与 `BackupObjectStorePort` 返回只携带稳定
|
||||
//! 错误码的 [`BackupError`]。`gateway-store` 实现全部端口;`gateway-admin` 拥有
|
||||
//! 状态机、Cron、保留策略与 BackupTask 算法。
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::model::Revision;
|
||||
use crate::model::{
|
||||
MutationContext,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery, BackupRecordPage,
|
||||
BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
},
|
||||
};
|
||||
use crate::ports::store::AdminStoreResult;
|
||||
|
||||
/// Worker 状态迁移携带的字段更新;`None` 字段不写入。
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct StatusTransitionUpdate {
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 备份配置、任务与计划游标的 PostgreSQL 仓储端口。
|
||||
#[async_trait]
|
||||
pub trait BackupRepository: Send + Sync {
|
||||
/// 读取完整设置(含 Secret)。
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// 同事务更新 S3 存储配置、递增 revision、清空验证状态并写审计事件。
|
||||
///
|
||||
/// 已有记录时禁止切换 endpoint/region/bucket/path-style(存储身份锁定)。
|
||||
/// 返回更新后的设置与新的全局 `config_revision`。
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
command: UpdateBackupStorageCommand,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)>;
|
||||
|
||||
/// 同事务更新调度配置、推进计划游标并写审计事件。
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings>;
|
||||
|
||||
/// CAS 记录探针成功;`storage_revision` 与当前配置不一致时丢弃结果(返回 `false`)。
|
||||
async fn record_verification(
|
||||
&self,
|
||||
storage_revision: u64,
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 插入 queued 任务;存在活跃任务时返回 `backup.active_task_conflict`。
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord>;
|
||||
|
||||
/// 幂等插入 scheduled 任务;同一时间点或活跃冲突时返回 `false`(调用方推进游标即可)。
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 分页查询记录。
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage>;
|
||||
|
||||
/// 读取单条记录。
|
||||
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 读取中间状态记录(dumping/uploading),供启动恢复。
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取等待完成墓碑的记录(status = deleting)。
|
||||
async fn list_pending_deletions(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 读取 `expires_at` 已到期且非活跃的记录(completed/failed),供保留清理。
|
||||
async fn list_expired_records(&self, limit: u32) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
|
||||
/// 原子领取最旧的 queued 任务并迁移到 dumping;无任务返回 `None`。
|
||||
async fn claim_next_queued(&self, now: DateTime<Utc>)
|
||||
-> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 条件迁移状态:要求当前状态一致;不匹配返回 `None`。
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 迁移到删除流程(completed/failed → deleting);供管理员与保留策略调用。
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>>;
|
||||
|
||||
/// 远端删除成功后硬删除记录行。
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()>;
|
||||
|
||||
/// 条件推进计划游标;仅当计划表达式和时区仍是 `expected_*` 时才写入,返回是否推进。
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
expected_cron: &str,
|
||||
expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
/// 按完成时间倒序读取 completed 计划备份,供保留策略扫描(最多 `limit` 条)。
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>>;
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与本地暂存端口。
|
||||
#[async_trait]
|
||||
pub trait DatabaseDumpPort: Send + Sync {
|
||||
/// 创建归档;完成后原子落盘。取消时终止子进程并清理部分文件。
|
||||
async fn dump(&self, request: DumpRequest) -> Result<DumpArtifact, BackupError>;
|
||||
|
||||
/// 检查暂存目录是否存在完整归档。
|
||||
async fn inspect_staging(&self, backup_id: &str)
|
||||
-> Result<Option<StagedArtifact>, BackupError>;
|
||||
|
||||
/// 清理暂存目录中该备份的全部文件。
|
||||
async fn cleanup_staging(&self, backup_id: &str) -> Result<(), BackupError>;
|
||||
}
|
||||
|
||||
/// 一次 `pg_dump` 请求。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumpRequest {
|
||||
pub backup_id: String,
|
||||
pub cancellation: gateway_core::engine::CancellationToken,
|
||||
}
|
||||
|
||||
/// 已完成并落盘的归档事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DumpArtifact {
|
||||
pub path: PathBuf,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// 暂存目录中已存在的完整归档。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StagedArtifact {
|
||||
pub path: PathBuf,
|
||||
pub size_bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// S3 兼容对象存储适配器端口。
|
||||
///
|
||||
/// 方法接受已经校验的存储配置;SDK client 的构造与缓存属于 adapter 实现细节。
|
||||
#[async_trait]
|
||||
pub trait BackupObjectStorePort: Send + Sync {
|
||||
/// 对专用探针对象执行 Put/Head/Get/Delete。
|
||||
async fn test_connection(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, BackupError>;
|
||||
|
||||
/// 分片流式上传本地归档;取消时中止 multipart 并返回 `backup.cancelled`。
|
||||
async fn upload_file(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
request: UploadObjectRequest,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 读取对象大小与 metadata;对象不存在返回 `Ok(None)`。
|
||||
async fn head_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, BackupError>;
|
||||
|
||||
/// 删除对象;对象不存在视为成功。
|
||||
async fn delete_object(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<(), BackupError>;
|
||||
|
||||
/// 创建短时预签名下载 URL。
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
file_name: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<String, BackupError>;
|
||||
}
|
||||
|
||||
/// 一次对象上传请求。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadObjectRequest {
|
||||
pub object_key: String,
|
||||
pub source: PathBuf,
|
||||
pub metadata: BackupObjectMetadata,
|
||||
pub cancellation: gateway_core::engine::CancellationToken,
|
||||
}
|
||||
|
||||
/// 备份控制面所需的全部 Store 能力集合。
|
||||
#[derive(Clone)]
|
||||
pub struct BackupStorePorts {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
}
|
||||
|
||||
impl BackupStorePorts {
|
||||
/// 组合备份端口。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
dump: Arc<dyn DatabaseDumpPort>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
dump,
|
||||
object_store,
|
||||
}
|
||||
}
|
||||
|
||||
/// 备份任务/配置仓储。
|
||||
#[must_use]
|
||||
pub fn repository(&self) -> Arc<dyn BackupRepository> {
|
||||
self.repository.clone()
|
||||
}
|
||||
|
||||
/// `pg_dump` 导出与暂存。
|
||||
#[must_use]
|
||||
pub fn dump(&self) -> Arc<dyn DatabaseDumpPort> {
|
||||
self.dump.clone()
|
||||
}
|
||||
|
||||
/// S3/R2 对象存储适配器。
|
||||
#[must_use]
|
||||
pub fn object_store(&self) -> Arc<dyn BackupObjectStorePort> {
|
||||
self.object_store.clone()
|
||||
}
|
||||
|
||||
/// 测试与不可用路径使用的空实现;任何调用都返回不可用错误。
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
repository: Arc::new(UnavailableBackupRepository),
|
||||
dump: Arc::new(UnavailableDumpPort),
|
||||
object_store: Arc::new(UnavailableObjectStore),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableBackupRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl BackupRepository for UnavailableBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
_command: UpdateBackupStorageCommand,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
_command: UpdateBackupScheduleCommand,
|
||||
_next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn record_verification(
|
||||
&self,
|
||||
_storage_revision: u64,
|
||||
_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_backup_record(
|
||||
&self,
|
||||
_seed: BackupRecordSeed,
|
||||
) -> AdminStoreResult<BackupRecord> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn insert_scheduled_record(&self, _seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
_query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn load_backup_record(&self, _id: &str) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_pending_deletions(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_expired_records(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn claim_next_queued(
|
||||
&self,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn transition_status(
|
||||
&self,
|
||||
_id: &str,
|
||||
_from: BackupStatus,
|
||||
_to: BackupStatus,
|
||||
_update: StatusTransitionUpdate,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
_id: &str,
|
||||
_now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn delete_record(&self, _id: &str) -> AdminStoreResult<()> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
_next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
_limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Err(disabled_store())
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableDumpPort;
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseDumpPort for UnavailableDumpPort {
|
||||
async fn dump(&self, _request: DumpRequest) -> Result<DumpArtifact, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn inspect_staging(
|
||||
&self,
|
||||
_backup_id: &str,
|
||||
) -> Result<Option<StagedArtifact>, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn cleanup_staging(&self, _backup_id: &str) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
}
|
||||
|
||||
struct UnavailableObjectStore;
|
||||
|
||||
#[async_trait]
|
||||
impl BackupObjectStorePort for UnavailableObjectStore {
|
||||
async fn test_connection(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn upload_file(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_request: UploadObjectRequest,
|
||||
) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn head_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn delete_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
) -> Result<(), BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
_object_key: &str,
|
||||
_file_name: &str,
|
||||
_ttl: std::time::Duration,
|
||||
) -> Result<String, BackupError> {
|
||||
Err(disabled_infra())
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_store() -> crate::ports::store::AdminStoreError {
|
||||
crate::ports::store::AdminStoreError::new(
|
||||
crate::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"backup",
|
||||
"backup store is disabled in this configuration",
|
||||
)
|
||||
}
|
||||
|
||||
fn disabled_infra() -> BackupError {
|
||||
BackupError::new(
|
||||
crate::model::backup::code::STORE_UNAVAILABLE,
|
||||
"backup infrastructure is disabled in this configuration".to_owned(),
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 管理用例依赖的外部能力端口。
|
||||
|
||||
pub mod backup;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -64,6 +64,12 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
/// 成功之后,不参与事务成败,也不得恢复或改写已经提交的账号状态。
|
||||
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,
|
||||
|
||||
@@ -7,11 +7,17 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use super::backup::BackupStorePorts;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
AccountGroupListQuery, AccountGroupMutation, AccountGroupPage, DeleteAccountGroup,
|
||||
NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountPageItem, AccountUpdateResult, AccountUsage,
|
||||
AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -82,7 +88,7 @@ pub type AdminStoreResult<T> = Result<T, AdminStoreError>;
|
||||
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(&self, account_id: &str) -> AdminStoreResult<Option<AccountPageItem>>;
|
||||
|
||||
async fn load_account_usage(
|
||||
&self,
|
||||
@@ -137,11 +143,23 @@ pub trait AccountStore: Send + Sync {
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<CredentialMutationResult>;
|
||||
|
||||
async fn set_account_enabled(
|
||||
async fn update_account(
|
||||
&self,
|
||||
command: SetAccountEnabled,
|
||||
command: UpdateAccount,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<Revision>;
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &gateway_core::engine::credential::ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult>;
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
@@ -156,7 +174,7 @@ pub trait AccountStore: Send + Sync {
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// 管理员密码、会话、登录限流和安全审计。
|
||||
/// 管理员密码、会话和安全审计。
|
||||
#[async_trait]
|
||||
pub trait AuthStore: Send + Sync {
|
||||
async fn load_password_hash(&self, admin_user_id: &str) -> AdminStoreResult<Option<String>>;
|
||||
@@ -176,26 +194,10 @@ pub trait AuthStore: Send + Sync {
|
||||
|
||||
async fn delete_session(&self, session_id: &str) -> AdminStoreResult<Option<AdminSession>>;
|
||||
|
||||
async fn login_source_is_throttled(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
window_seconds: u64,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn record_login_failure(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
window_seconds: u64,
|
||||
) -> AdminStoreResult<bool>;
|
||||
|
||||
async fn clear_login_failures(&self, source: &str) -> AdminStoreResult<()>;
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
/// Client API Key 管理及其 revision CAS 写入。
|
||||
/// Client API Key 管理写入。
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn list_client_keys(&self, query: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage>;
|
||||
@@ -230,10 +232,48 @@ pub trait ClientKeyStore: Send + Sync {
|
||||
) -> AdminStoreResult<Revision>;
|
||||
}
|
||||
|
||||
/// Provider-neutral account group management transactions.
|
||||
#[async_trait]
|
||||
pub trait AccountGroupStore: Send + Sync {
|
||||
async fn list_account_groups(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage>;
|
||||
|
||||
async fn create_account_group(
|
||||
&self,
|
||||
command: NewAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn update_account_group(
|
||||
&self,
|
||||
command: UpdateAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
command: SetAccountGroupEnabled,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
|
||||
async fn delete_account_group(
|
||||
&self,
|
||||
command: DeleteAccountGroup,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation>;
|
||||
}
|
||||
|
||||
/// 用量、趋势、诊断与运维错误的只读能力。
|
||||
#[async_trait]
|
||||
pub trait ObservabilityStore: Send + Sync {
|
||||
async fn dashboard_summary(&self, range: TimeRange) -> AdminStoreResult<DashboardObservation>;
|
||||
/// 返回历史统计区间和指定观测时刻下的实时账号状态。
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation>;
|
||||
|
||||
/// 返回 Dashboard 可选的实时槽位事实。
|
||||
///
|
||||
@@ -281,7 +321,7 @@ pub trait ObservabilityStore: Send + Sync {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage>;
|
||||
}
|
||||
|
||||
/// Runtime settings 与管理员 API Key 的 revision CAS 写入。
|
||||
/// Runtime settings 与管理员 API Key 写入。
|
||||
#[async_trait]
|
||||
pub trait SettingsStore: Send + Sync {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings>;
|
||||
@@ -296,14 +336,12 @@ pub trait SettingsStore: Send + Sync {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
key: AdminApiKey,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation>;
|
||||
}
|
||||
@@ -314,27 +352,33 @@ pub trait SettingsStore: Send + Sync {
|
||||
#[derive(Clone)]
|
||||
pub struct AdminStorePorts {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
}
|
||||
|
||||
impl AdminStorePorts {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
account_groups: Arc<dyn AccountGroupStore>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
client_keys: Arc<dyn ClientKeyStore>,
|
||||
observability: Arc<dyn ObservabilityStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
backup: BackupStorePorts,
|
||||
) -> Self {
|
||||
Self {
|
||||
accounts,
|
||||
account_groups,
|
||||
auth,
|
||||
client_keys,
|
||||
observability,
|
||||
settings,
|
||||
backup,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +387,11 @@ impl AdminStorePorts {
|
||||
self.accounts.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn account_groups(&self) -> Arc<dyn AccountGroupStore> {
|
||||
self.account_groups.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn auth(&self) -> Arc<dyn AuthStore> {
|
||||
self.auth.clone()
|
||||
@@ -362,4 +411,9 @@ impl AdminStorePorts {
|
||||
pub fn settings(&self) -> Arc<dyn SettingsStore> {
|
||||
self.settings.clone()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn backup(&self) -> BackupStorePorts {
|
||||
self.backup.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Account group management use cases.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::{AccountGroupId, snapshot::SnapshotControl};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
account_groups::{
|
||||
AccountGroupListQuery, AccountGroupMutation, AccountGroupPage, CreateAccountGroup,
|
||||
DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
},
|
||||
ports::store::AccountGroupStore,
|
||||
};
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
|
||||
/// API-facing account group management service.
|
||||
#[async_trait]
|
||||
pub trait AccountGroupService: Send + Sync {
|
||||
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError>;
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetAccountGroupEnabled,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultAccountGroupService {
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultAccountGroupService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
store: Arc<dyn AccountGroupStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self { store, snapshot }
|
||||
}
|
||||
|
||||
async fn publish(
|
||||
&self,
|
||||
result: Result<AccountGroupMutation, crate::ports::store::AdminStoreError>,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let result = result.map_err(|error| map_store_error(error, "account group"))?;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountGroupService for DefaultAccountGroupService {
|
||||
async fn list(&self, query: AccountGroupListQuery) -> Result<AccountGroupPage, AdminError> {
|
||||
self.store
|
||||
.list_account_groups(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "account group"))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: CreateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
let id = AccountGroupId::new(format!("grp_{}", Uuid::now_v7().simple()))
|
||||
.map_err(|_| AdminError::internal("Failed to create account group ID"))?;
|
||||
self.publish(
|
||||
self.store
|
||||
.create_account_group(
|
||||
NewAccountGroup {
|
||||
id,
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
color: command.color,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.update_account_group(command, context).await)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetAccountGroupEnabled,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.set_account_group_enabled(command, context).await)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: DeleteAccountGroup,
|
||||
) -> Result<AccountGroupMutation, AdminError> {
|
||||
self.publish(self.store.delete_account_group(command, context).await)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -15,22 +15,20 @@ use gateway_core::{
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
AdminError, MutationContext,
|
||||
accounts::{
|
||||
AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery,
|
||||
AccountRecord, AccountStatus, AccountUsageWindowQuery,
|
||||
AccountPageItem, AccountUpdateResult, AccountUsageWindowQuery, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, UpdateAccount,
|
||||
},
|
||||
observability::TimeRange,
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
PrepareCredentialRefresh, ProviderModels, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow,
|
||||
ProviderQuotaWindow, QuotaLocalUsageAttribution,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
store::{AccountStore, SettingsStore},
|
||||
},
|
||||
ports::{provider::ProviderAdminRegistry, store::AccountStore},
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -54,10 +52,27 @@ pub trait AccountsService: Send + Sync {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError>;
|
||||
|
||||
async fn recover(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError>;
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccount,
|
||||
) -> Result<AccountUpdateResult, AdminError>;
|
||||
|
||||
async fn batch_update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: BatchUpdateAccounts,
|
||||
) -> Result<AccountsUpdateResult, AdminError>;
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -79,7 +94,6 @@ pub trait AccountsService: Send + Sync {
|
||||
|
||||
pub(crate) struct DefaultAccountsService {
|
||||
accounts: Arc<dyn AccountStore>,
|
||||
settings: Arc<dyn SettingsStore>,
|
||||
providers: ProviderAdminRegistry,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
@@ -89,14 +103,12 @@ 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,
|
||||
@@ -106,7 +118,7 @@ impl DefaultAccountsService {
|
||||
async fn load_account(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<AccountRecord, AdminError> {
|
||||
) -> Result<AccountPageItem, AdminError> {
|
||||
self.accounts
|
||||
.load_account(account_id.as_str())
|
||||
.await
|
||||
@@ -119,32 +131,32 @@ impl DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<
|
||||
(
|
||||
AccountRecord,
|
||||
AccountPageItem,
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
),
|
||||
AdminError,
|
||||
> {
|
||||
let account = self.load_account(account_id).await?;
|
||||
let item = self.load_account(account_id).await?;
|
||||
let provider = self
|
||||
.providers
|
||||
.require(&account.provider_kind)
|
||||
.require(&item.account.provider_kind)
|
||||
.map_err(|error| map_provider_error(error, "provider account"))?;
|
||||
Ok((account, provider))
|
||||
Ok((item, provider))
|
||||
}
|
||||
|
||||
async fn attach_quota_local_usage(
|
||||
&self,
|
||||
accounts: &[AccountRecord],
|
||||
accounts: &[AccountPageItem],
|
||||
quotas: &mut [ProviderQuota],
|
||||
) -> Result<(), AdminError> {
|
||||
let windows = accounts
|
||||
.iter()
|
||||
.zip(quotas.iter())
|
||||
.flat_map(|(account, quota)| {
|
||||
.flat_map(|(item, quota)| {
|
||||
quota
|
||||
.windows
|
||||
.iter()
|
||||
.filter_map(|window| quota_usage_window(&account.id, window))
|
||||
.filter_map(|window| quota_usage_window(&item.account.id, window))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if windows.is_empty() {
|
||||
@@ -158,10 +170,12 @@ impl DefaultAccountsService {
|
||||
.into_iter()
|
||||
.map(|result| ((result.account_id, result.key), result.usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
for (account, quota) in accounts.iter().zip(quotas) {
|
||||
for (item, 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 window.local_usage.is_none()
|
||||
&& window.local_usage_attribution == QuotaLocalUsageAttribution::AccountWide
|
||||
{
|
||||
let key = (item.account.id.clone(), window.key.clone());
|
||||
if let Some(usage) = usage_by_window.get(&key) {
|
||||
window.local_usage = Some(usage.clone());
|
||||
}
|
||||
@@ -176,33 +190,19 @@ impl DefaultAccountsService {
|
||||
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 (stored, provider) = self.provider_for_account(account_id).await?;
|
||||
let account = &stored.account;
|
||||
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 = 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 {
|
||||
@@ -213,14 +213,15 @@ impl DefaultAccountsService {
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
self.attach_quota_local_usage(
|
||||
std::slice::from_ref(&account),
|
||||
std::slice::from_ref(&stored),
|
||||
std::slice::from_mut(&mut quota),
|
||||
)
|
||||
.await?;
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
Ok(AccountDirectoryItem {
|
||||
status: account_status(&account, now),
|
||||
usage: usage.into_iter().next(),
|
||||
account,
|
||||
projection: stored.projection,
|
||||
usage,
|
||||
account: stored.account,
|
||||
quota,
|
||||
})
|
||||
}
|
||||
@@ -229,13 +230,12 @@ impl DefaultAccountsService {
|
||||
#[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 page = self
|
||||
.accounts
|
||||
.list_accounts(query)
|
||||
.await
|
||||
.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,
|
||||
@@ -243,36 +243,52 @@ impl AccountsService for DefaultAccountsService {
|
||||
let ids = page
|
||||
.items
|
||||
.iter()
|
||||
.map(|account| account.id.clone())
|
||||
.map(|item| item.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 = 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()
|
||||
.map(|usage| (usage.account_id.clone(), usage))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|account| async {
|
||||
let mut quotas = futures::future::join_all(page.items.iter().map(|item| async {
|
||||
let account = &item.account;
|
||||
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 投影失败(Provider 未注册或 quota 读取失败)不拖垮整页:
|
||||
// 该账号降级为空额度投影,其余账号与页面状态照常返回。
|
||||
let provider = match self.providers.require(&account.provider_kind) {
|
||||
Ok(provider) => provider,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory provider is not registered; showing empty quota"
|
||||
);
|
||||
return Ok(empty_quota());
|
||||
}
|
||||
};
|
||||
match 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"))
|
||||
{
|
||||
Ok(quota) => Ok(quota),
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id,
|
||||
error = %error,
|
||||
"account directory quota projection failed; showing empty quota"
|
||||
);
|
||||
Ok(empty_quota())
|
||||
}
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.into_iter()
|
||||
@@ -283,11 +299,14 @@ impl AccountsService for DefaultAccountsService {
|
||||
.items
|
||||
.into_iter()
|
||||
.zip(quotas)
|
||||
.map(|(account, quota)| AccountDirectoryItem {
|
||||
status: account_status(&account, now),
|
||||
usage: usage.get(&account.id).cloned(),
|
||||
account,
|
||||
quota,
|
||||
.map(|(item, quota)| {
|
||||
let usage = quota.representative_window_usage().cloned();
|
||||
AccountDirectoryItem {
|
||||
usage,
|
||||
account: item.account,
|
||||
projection: item.projection,
|
||||
quota,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok(AccountDirectoryPage {
|
||||
@@ -313,7 +332,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
for account_id in account_ids {
|
||||
let account = self.load_account(&account_id).await?;
|
||||
grouped
|
||||
.entry(account.provider_kind)
|
||||
.entry(account.account.provider_kind)
|
||||
.or_default()
|
||||
.push(account_id);
|
||||
}
|
||||
@@ -354,10 +373,10 @@ impl AccountsService for DefaultAccountsService {
|
||||
async fn refresh(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expected_config_revision: Revision,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (account, provider) = self.provider_for_account(&account_id).await?;
|
||||
let (stored, provider) = self.provider_for_account(&account_id).await?;
|
||||
let account = stored.account;
|
||||
let prepared = provider
|
||||
.prepare_refresh(PrepareCredentialRefresh {
|
||||
account: account.clone(),
|
||||
@@ -367,12 +386,14 @@ impl AccountsService for DefaultAccountsService {
|
||||
validate_prepared_rotation(&account, &prepared, "provider credential refresh")?;
|
||||
let result = commit_credential_refresh(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
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 {
|
||||
@@ -381,6 +402,98 @@ impl AccountsService for DefaultAccountsService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn recover(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
account_id: ProviderAccountId,
|
||||
) -> Result<AccountRefreshResult, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let result = self
|
||||
.accounts
|
||||
.recover_account(&account_id, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account recovery"))?;
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
let account = self.load_directory_item(&account_id, false).await?;
|
||||
Ok(AccountRefreshResult {
|
||||
config_revision: result.config_revision,
|
||||
account,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateAccount,
|
||||
) -> Result<AccountUpdateResult, AdminError> {
|
||||
let account_id = ProviderAccountId::new(command.account_id.clone())
|
||||
.map_err(|_| AdminError::invalid("Invalid provider account ID"))?;
|
||||
let (_, provider) = self.provider_for_account(&account_id).await?;
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
.accounts
|
||||
.update_account(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider account"))?;
|
||||
if !enabled {
|
||||
provider.account_unavailable(&account_id).await;
|
||||
}
|
||||
provider
|
||||
.account_facts_changed(std::slice::from_ref(&account_id))
|
||||
.await;
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn batch_update(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: BatchUpdateAccounts,
|
||||
) -> Result<AccountsUpdateResult, AdminError> {
|
||||
let account_ids = command
|
||||
.account_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
ProviderAccountId::new(id.clone())
|
||||
.map_err(|_| AdminError::invalid("Invalid provider account ID"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut providers = BTreeMap::<
|
||||
ProviderKind,
|
||||
(
|
||||
Arc<dyn crate::ports::provider::ProviderAdmin>,
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
for account_id in &account_ids {
|
||||
let (item, provider) = self.provider_for_account(account_id).await?;
|
||||
providers
|
||||
.entry(item.account.provider_kind)
|
||||
.or_insert_with(|| (provider, Vec::new()))
|
||||
.1
|
||||
.push(account_id.clone());
|
||||
}
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
.accounts
|
||||
.batch_update_accounts(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids) in providers.values() {
|
||||
if !enabled {
|
||||
for account_id in provider_ids {
|
||||
provider.account_unavailable(account_id).await;
|
||||
}
|
||||
}
|
||||
provider.account_facts_changed(provider_ids).await;
|
||||
}
|
||||
publish_committed(self.snapshot.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
@@ -406,8 +519,8 @@ impl AccountsService for DefaultAccountsService {
|
||||
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 (stored, provider) = self.provider_for_account(&account_id).await?;
|
||||
let account = stored.account;
|
||||
let model = upstream_model.as_str().to_owned();
|
||||
let operation = provider
|
||||
.connection_test_operation(&upstream_model, CONNECTION_TEST_INPUT)
|
||||
@@ -424,8 +537,6 @@ impl AccountsService for DefaultAccountsService {
|
||||
},
|
||||
];
|
||||
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 {
|
||||
@@ -435,29 +546,39 @@ impl AccountsService for DefaultAccountsService {
|
||||
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,
|
||||
}))
|
||||
.chain(std::iter::once(AccountConnectionTestEvent::Completed))
|
||||
.collect(),
|
||||
Err(error) => vec![AccountConnectionTestEvent::Failed {
|
||||
message: error.client_message().to_owned(),
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
provider_error_type: error.client_error_type().map(ToOwned::to_owned),
|
||||
account_status: status,
|
||||
}],
|
||||
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,
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
.flat_map(futures::stream::iter);
|
||||
@@ -465,32 +586,14 @@ impl AccountsService for DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
fn account_status(account: &AccountRecord, now: chrono::DateTime<Utc>) -> AccountStatus {
|
||||
if !account.enabled {
|
||||
AccountStatus::Disabled
|
||||
} else {
|
||||
match account.availability {
|
||||
crate::model::accounts::AccountAvailability::Banned => AccountStatus::Banned,
|
||||
crate::model::accounts::AccountAvailability::QuotaExhausted => {
|
||||
AccountStatus::QuotaExhausted
|
||||
}
|
||||
crate::model::accounts::AccountAvailability::Expired => AccountStatus::Expired,
|
||||
_ if account
|
||||
.access_token_expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now) =>
|
||||
{
|
||||
AccountStatus::Expired
|
||||
}
|
||||
_ => 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,
|
||||
/// 账号目录中单个账号 quota 读取失败时使用的空额度投影。
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: Vec::new(),
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,7 +601,9 @@ fn quota_usage_window(
|
||||
account_id: &str,
|
||||
window: &ProviderQuotaWindow,
|
||||
) -> Option<AccountUsageWindowQuery> {
|
||||
if window.local_usage.is_some() {
|
||||
if window.local_usage.is_some()
|
||||
|| window.local_usage_attribution != QuotaLocalUsageAttribution::AccountWide
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let reset_at = window.reset_at?;
|
||||
|
||||
@@ -22,9 +22,6 @@ use crate::{
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
const LOGIN_FAILURE_LIMIT: u32 = 5;
|
||||
const LOGIN_FAILURE_WINDOW_SECONDS: u64 = 15 * 60;
|
||||
|
||||
/// API 鉴权与管理员登录消费的统一服务。
|
||||
#[async_trait]
|
||||
pub trait AuthService: Send + Sync {
|
||||
@@ -39,6 +36,10 @@ pub trait AuthService: Send + Sync {
|
||||
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,
|
||||
@@ -54,8 +55,8 @@ impl DefaultAuthService {
|
||||
store: Arc<dyn AuthStore>,
|
||||
) -> Self {
|
||||
let minutes = i64::try_from(session_ttl_minutes)
|
||||
.unwrap_or(i64::MAX)
|
||||
.max(1);
|
||||
.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),
|
||||
@@ -63,19 +64,6 @@ impl DefaultAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn record_failed_login(&self, source: &str) -> Result<LoginError, LoginError> {
|
||||
let throttled = self
|
||||
.store
|
||||
.record_login_failure(source, LOGIN_FAILURE_LIMIT, LOGIN_FAILURE_WINDOW_SECONDS)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
Ok(if throttled {
|
||||
LoginError::Throttled
|
||||
} else {
|
||||
LoginError::InvalidCredentials
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_audit(&self, action: &str, occurred_at: chrono::DateTime<Utc>) -> AdminAuditEvent {
|
||||
AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
@@ -137,22 +125,13 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
|
||||
async fn login(&self, command: LoginCommand) -> Result<LoginResult, LoginError> {
|
||||
let source = normalized_login_source(&command.source);
|
||||
if self
|
||||
.store
|
||||
.login_source_is_throttled(source, LOGIN_FAILURE_LIMIT, LOGIN_FAILURE_WINDOW_SECONDS)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
{
|
||||
return Err(LoginError::Throttled);
|
||||
}
|
||||
if command
|
||||
.username
|
||||
.as_deref()
|
||||
.unwrap_or(&self.default_admin_user_id)
|
||||
!= self.default_admin_user_id
|
||||
{
|
||||
return Err(self.record_failed_login(source).await?);
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
let hash = self
|
||||
.store
|
||||
@@ -161,13 +140,9 @@ impl AuthService for DefaultAuthService {
|
||||
.map_err(|_| LoginError::Unavailable)?
|
||||
.ok_or(LoginError::InvalidCredentials)?;
|
||||
if !verify_admin_password(&command.password, &hash).map_err(|_| LoginError::Unavailable)? {
|
||||
return Err(self.record_failed_login(source).await?);
|
||||
return Err(LoginError::InvalidCredentials);
|
||||
}
|
||||
|
||||
self.store
|
||||
.clear_login_failures(source)
|
||||
.await
|
||||
.map_err(|_| LoginError::Unavailable)?;
|
||||
let session_id = random_session_token();
|
||||
let expires_at = Utc::now() + self.session_ttl;
|
||||
self.store
|
||||
@@ -246,8 +221,3 @@ fn valid_admin_api_key_shape(value: &str) -> bool {
|
||||
&& value.starts_with("admin-")
|
||||
&& value[6..].bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn normalized_login_source(source: &str) -> &str {
|
||||
let source = source.trim();
|
||||
if source.is_empty() { "unknown" } else { source }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
//! 备份配置、手动创建、下载与删除用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
auth::{AdminAuditEvent, AuditActorKind},
|
||||
backup::{
|
||||
BackupError, BackupRecord, BackupRecordListQuery, BackupRecordPage, BackupSettings,
|
||||
BackupStatus, BackupStorageConfig, BackupTriggerKind, ConnectionTestResult,
|
||||
DownloadUrlResult, UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
build_backup_seed, code,
|
||||
},
|
||||
};
|
||||
use crate::ports::{
|
||||
backup::{BackupObjectStorePort, BackupRepository},
|
||||
store::AuthStore,
|
||||
};
|
||||
|
||||
use super::map_store_error;
|
||||
|
||||
/// 默认下载地址有效期。
|
||||
const DOWNLOAD_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// API 消费的备份管理服务。
|
||||
#[async_trait]
|
||||
pub trait BackupService: Send + Sync {
|
||||
async fn load_settings(&self) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn update_storage(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupStorageCommand,
|
||||
) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn update_schedule(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError>;
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<ConnectionTestResult, AdminError>;
|
||||
|
||||
async fn create_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecord, AdminError>;
|
||||
|
||||
async fn list_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> Result<BackupRecordPage, AdminError>;
|
||||
|
||||
async fn download_url(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<DownloadUrlResult, AdminError>;
|
||||
|
||||
async fn delete_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<BackupRecord, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultBackupService {
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
}
|
||||
|
||||
impl DefaultBackupService {
|
||||
#[must_use]
|
||||
pub(crate) fn new(
|
||||
repository: Arc<dyn BackupRepository>,
|
||||
object_store: Arc<dyn BackupObjectStorePort>,
|
||||
auth: Arc<dyn AuthStore>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
object_store,
|
||||
auth,
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupService for DefaultBackupService {
|
||||
async fn load_settings(&self) -> Result<BackupSettings, AdminError> {
|
||||
self.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))
|
||||
}
|
||||
|
||||
async fn update_storage(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupStorageCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_storage(&command)?;
|
||||
let (settings, revision) = self
|
||||
.repository
|
||||
.update_storage_settings(command, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup storage"))?;
|
||||
super::publish_committed(self.snapshot.as_ref(), revision).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
async fn update_schedule(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
) -> Result<BackupSettings, AdminError> {
|
||||
validate_schedule(&command)?;
|
||||
let next_run_at = if command.schedule_enabled {
|
||||
Some(
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?
|
||||
.next_after(Utc::now())
|
||||
.ok_or_else(|| AdminError::bad_request("无法从计划推导下次执行时间"))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.repository
|
||||
.update_schedule_settings(command, next_run_at, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup schedule"))
|
||||
}
|
||||
|
||||
async fn test_connection(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<ConnectionTestResult, AdminError> {
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Ok(ConnectionTestResult {
|
||||
ok: false,
|
||||
stage: "putObject",
|
||||
code: Some(code::INVALID_CONFIG),
|
||||
message: "备份存储配置不完整,无法执行连接测试".to_owned(),
|
||||
});
|
||||
};
|
||||
let result = self
|
||||
.object_store
|
||||
.test_connection(&config)
|
||||
.await
|
||||
.map_err(map_backup_error)?;
|
||||
if result.ok {
|
||||
let _ = self
|
||||
.repository
|
||||
.record_verification(config.storage_revision, Utc::now())
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
}
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.s3_connection_tested",
|
||||
"backup_settings",
|
||||
vec![],
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn create_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
if !settings.storage_configured() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储配置不完整,请先保存存储配置",
|
||||
));
|
||||
}
|
||||
if !settings.storage_verified() {
|
||||
return Err(AdminError::bad_request(
|
||||
"备份存储尚未通过连接测试,请先测试存储配置",
|
||||
));
|
||||
}
|
||||
let prefix = settings.prefix.as_deref().unwrap_or_default();
|
||||
let seed = build_backup_seed(
|
||||
BackupTriggerKind::Manual,
|
||||
None,
|
||||
prefix,
|
||||
Utc::now(),
|
||||
expires_at,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
let record = self
|
||||
.repository
|
||||
.insert_backup_record(seed)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?;
|
||||
self.append_audit(context, "backup.created", "backup", vec![record.id.clone()])
|
||||
.await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn list_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> Result<BackupRecordPage, AdminError> {
|
||||
self.repository
|
||||
.list_backup_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup records"))
|
||||
}
|
||||
|
||||
async fn download_url(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<DownloadUrlResult, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if record.status != BackupStatus::Completed {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 的备份才能创建下载地址",
|
||||
));
|
||||
}
|
||||
let settings = self
|
||||
.repository
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup settings"))?;
|
||||
let Some(config) = BackupStorageConfig::from_settings(&settings) else {
|
||||
return Err(AdminError::bad_request("备份存储配置不完整"));
|
||||
};
|
||||
let file_name = crate::model::backup::build_download_file_name(backup_id);
|
||||
let url = self
|
||||
.object_store
|
||||
.presigned_download(&config, &record.object_key, &file_name, DOWNLOAD_TTL)
|
||||
.await
|
||||
.map_err(map_backup_error)?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.download_url_created",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
Ok(DownloadUrlResult {
|
||||
url,
|
||||
file_name,
|
||||
expires_in: DOWNLOAD_TTL,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_backup(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
backup_id: &str,
|
||||
) -> Result<BackupRecord, AdminError> {
|
||||
let record = self.require_record(backup_id).await?;
|
||||
if !record.status.can_be_deleted() {
|
||||
return Err(AdminError::conflict(
|
||||
"只有 completed 或 failed 的备份才能删除",
|
||||
));
|
||||
}
|
||||
let now = Utc::now();
|
||||
let deleting = self
|
||||
.repository
|
||||
.transition_to_deleting(backup_id, now)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::conflict("备份记录已进入删除流程"))?;
|
||||
self.append_audit(
|
||||
context,
|
||||
"backup.delete_requested",
|
||||
"backup",
|
||||
vec![backup_id.to_owned()],
|
||||
)
|
||||
.await?;
|
||||
Ok(deleting)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultBackupService {
|
||||
async fn require_record(&self, backup_id: &str) -> Result<BackupRecord, AdminError> {
|
||||
self.repository
|
||||
.load_backup_record(backup_id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup record"))?
|
||||
.ok_or_else(|| AdminError::not_found("备份记录不存在"))
|
||||
}
|
||||
|
||||
async fn append_audit(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
action: &str,
|
||||
entity_ref: &str,
|
||||
changed_fields: Vec<String>,
|
||||
) -> Result<(), AdminError> {
|
||||
let actor_kind = match context.actor {
|
||||
crate::model::MutationActor::AdminSession { .. } => AuditActorKind::AdminSession,
|
||||
crate::model::MutationActor::AdminApiKey => AuditActorKind::AdminApiKey,
|
||||
crate::model::MutationActor::System => AuditActorKind::System,
|
||||
};
|
||||
let actor_ref = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => admin_user_id.clone(),
|
||||
crate::model::MutationActor::AdminApiKey => "admin_api_key".to_owned(),
|
||||
crate::model::MutationActor::System => "system".to_owned(),
|
||||
};
|
||||
let actor_admin_user_id = match &context.actor {
|
||||
crate::model::MutationActor::AdminSession { admin_user_id } => {
|
||||
Some(admin_user_id.clone())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let event = AdminAuditEvent {
|
||||
id: format!("audit_{}", Uuid::now_v7().simple()),
|
||||
actor_kind,
|
||||
actor_admin_user_id,
|
||||
actor_ref,
|
||||
request_id: Some(context.request_id.clone()),
|
||||
action: action.to_owned(),
|
||||
entity_kind: "backup".to_owned(),
|
||||
entity_ref: entity_ref.to_owned(),
|
||||
config_revision: None,
|
||||
changed_fields,
|
||||
occurred_at: Utc::now(),
|
||||
};
|
||||
self.auth
|
||||
.append_audit_event(event)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "backup audit"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验存储配置;空串视为非法,Secret 缺省表示保留旧值。
|
||||
fn validate_storage(command: &UpdateBackupStorageCommand) -> Result<(), AdminError> {
|
||||
validate_endpoint(&command.endpoint)?;
|
||||
validate_nonempty("region", &command.region)?;
|
||||
validate_nonempty("bucket", &command.bucket)?;
|
||||
validate_nonempty("accessKeyId", &command.access_key_id)?;
|
||||
if let Some(secret) = command.secret_access_key.as_ref() {
|
||||
let value = secret.expose_secret();
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request(
|
||||
"secretAccessKey 不能为空字符串;缺省表示保留旧值",
|
||||
));
|
||||
}
|
||||
validate_length("secretAccessKey", value, 512)?;
|
||||
}
|
||||
crate::model::backup::normalize_prefix(&command.prefix).map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_endpoint(value: &str) -> Result<(), AdminError> {
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request("endpoint 不能为空"));
|
||||
}
|
||||
if !value.starts_with("https://") {
|
||||
return Err(AdminError::bad_request(
|
||||
"endpoint 必须使用 HTTPS;仅测试或显式开发配置允许 HTTP",
|
||||
));
|
||||
}
|
||||
if value.contains('@') || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(AdminError::bad_request("endpoint 格式非法"));
|
||||
}
|
||||
validate_length("endpoint", value, 2048)
|
||||
}
|
||||
|
||||
fn validate_nonempty(field: &str, value: &str) -> Result<(), AdminError> {
|
||||
if value.is_empty() {
|
||||
return Err(AdminError::bad_request(format!("{field} 不能为空")));
|
||||
}
|
||||
validate_length(field, value, 255)
|
||||
}
|
||||
|
||||
fn validate_length(field: &str, value: &str, max: usize) -> Result<(), AdminError> {
|
||||
if value.len() > max || value.bytes().any(|byte| byte.is_ascii_control()) {
|
||||
return Err(AdminError::bad_request(format!(
|
||||
"{field} 超出长度或包含非法字符"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验调度配置并解析 Cron 与时区。
|
||||
fn validate_schedule(command: &UpdateBackupScheduleCommand) -> Result<(), AdminError> {
|
||||
if command.cron_expression.trim().is_empty() {
|
||||
return Err(AdminError::bad_request("cronExpression 不能为空"));
|
||||
}
|
||||
crate::backup::policy::BackupSchedule::parse(
|
||||
&command.cron_expression,
|
||||
&command.schedule_timezone,
|
||||
)
|
||||
.map_err(map_backup_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 备份基础设施错误到既有 `AdminErrorKind` 的唯一边界映射。
|
||||
fn map_backup_error(error: BackupError) -> AdminError {
|
||||
match error.code() {
|
||||
code::INVALID_CONFIG | code::INVALID_CRON | code::INVALID_TIMEZONE => {
|
||||
AdminError::bad_request(error.message().to_owned())
|
||||
}
|
||||
code::RECORD_NOT_FOUND => AdminError::not_found(error.message().to_owned()),
|
||||
code::ACTIVE_TASK_CONFLICT | code::STATE_CONFLICT | code::STORAGE_IDENTITY_LOCKED => {
|
||||
AdminError::conflict(error.message().to_owned())
|
||||
}
|
||||
code::STORE_UNAVAILABLE => {
|
||||
AdminError::new(AdminErrorKind::Unavailable, error.message().to_owned())
|
||||
}
|
||||
_ => AdminError::bad_gateway(error.message().to_owned()),
|
||||
}
|
||||
}
|
||||
@@ -94,11 +94,10 @@ impl ClientKeyService for DefaultClientKeyService {
|
||||
.store
|
||||
.create_client_key(
|
||||
NewClientKey {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
id,
|
||||
name: command.name,
|
||||
label: command.label,
|
||||
provider_kind: command.provider_kind,
|
||||
group_ids: command.group_ids,
|
||||
limits: command.limits,
|
||||
plaintext: plaintext.clone(),
|
||||
},
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! 管理控制面的用例实现。
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
@@ -12,11 +14,11 @@ pub mod xai;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext,
|
||||
accounts::{DeleteAccounts, SetAccountEnabled},
|
||||
accounts::DeleteAccounts,
|
||||
provider_credentials::{
|
||||
AuthorizationMutationTarget, CredentialDeletion, CredentialDeletionResult,
|
||||
CredentialDetails, CredentialMutation, CredentialMutationResult,
|
||||
CredentialRotationCommit, PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
CredentialDetails, CredentialMutationResult, CredentialRotationCommit,
|
||||
PendingAuthorizationMutation, PreparedAuthorizationCommit,
|
||||
PreparedAuthorizationCredential, PreparedCredentialImport, PreparedCredentialRotation,
|
||||
StartAuthorization,
|
||||
},
|
||||
@@ -90,17 +92,10 @@ async fn pending_authorization(
|
||||
resource: &'static str,
|
||||
) -> Result<PendingAuthorizationMutation, AdminError> {
|
||||
let target = match &command.reauthorization {
|
||||
Some(target) => {
|
||||
let details =
|
||||
required_credential(accounts, provider_kind, &target.account_id, resource).await?;
|
||||
if details.credential.credential_revision != target.credential_revision {
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} reauthorization target is stale"
|
||||
)));
|
||||
}
|
||||
Some(account_id) => {
|
||||
required_credential(accounts, provider_kind, account_id, resource).await?;
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id: target.account_id.clone(),
|
||||
expected_credential_revision: target.credential_revision,
|
||||
account_id: account_id.clone(),
|
||||
}
|
||||
}
|
||||
None => AuthorizationMutationTarget::Create {
|
||||
@@ -108,7 +103,6 @@ async fn pending_authorization(
|
||||
},
|
||||
};
|
||||
Ok(PendingAuthorizationMutation::new(
|
||||
command.expected_config_revision,
|
||||
provider_kind.clone(),
|
||||
target,
|
||||
crate::model::provider_credentials::AuthorizationOwnerBinding::from_context(
|
||||
@@ -143,7 +137,6 @@ fn validate_prepared_rotation(
|
||||
let facts = prepared.facts();
|
||||
if facts.account_id.as_str() != account.id.as_str()
|
||||
|| facts.provider_kind != account.provider_kind
|
||||
|| facts.expected_credential_revision != account.credential_revision
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"{resource} prepared facts do not match the current credential"
|
||||
@@ -152,18 +145,27 @@ fn validate_prepared_rotation(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_authorization_commit(
|
||||
async fn validate_authorization_commit(
|
||||
provider_kind: &ProviderKind,
|
||||
context: &MutationContext,
|
||||
prepared: &PreparedAuthorizationCommit,
|
||||
prepared: PreparedAuthorizationCommit,
|
||||
resource: &'static str,
|
||||
) -> Result<(), AdminError> {
|
||||
) -> Result<PreparedAuthorizationCommit, AdminError> {
|
||||
if prepared.pending.provider_kind() != provider_kind
|
||||
|| !prepared.pending.owner_binding().matches_context(context)
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
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) {
|
||||
(
|
||||
@@ -171,25 +173,29 @@ fn validate_authorization_commit(
|
||||
PreparedAuthorizationCredential::Create(credential),
|
||||
) => credential.provider_kind == *provider_kind,
|
||||
(
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id,
|
||||
expected_credential_revision,
|
||||
},
|
||||
AuthorizationMutationTarget::Reauthorize { account_id },
|
||||
PreparedAuthorizationCredential::Reauthorize(credential),
|
||||
) => {
|
||||
let facts = credential.facts();
|
||||
facts.provider_kind == *provider_kind
|
||||
&& facts.account_id == *account_id
|
||||
&& facts.expected_credential_revision == *expected_credential_revision
|
||||
facts.provider_kind == *provider_kind && facts.account_id == *account_id
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !matches_target {
|
||||
return Err(AdminError::conflict(format!(
|
||||
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(())
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
async fn commit_authorization(
|
||||
@@ -198,16 +204,34 @@ async fn commit_authorization(
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (command, guard) = prepared.into_commit();
|
||||
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) = guard {
|
||||
if let Some(guard) = credential_guard {
|
||||
guard.finish();
|
||||
}
|
||||
if let Some(guard) = authorization_guard {
|
||||
guard.commit().await?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(error) => {
|
||||
drop(guard);
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -215,20 +239,13 @@ async fn commit_authorization(
|
||||
|
||||
async fn commit_credential_rotation(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_rotation(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_rotation(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -244,20 +261,13 @@ async fn commit_credential_rotation(
|
||||
|
||||
async fn commit_credential_refresh(
|
||||
accounts: &dyn AccountStore,
|
||||
expected_config_revision: crate::model::Revision,
|
||||
prepared: PreparedCredentialRotation,
|
||||
context: &MutationContext,
|
||||
resource: &'static str,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let (facts, guard) = prepared.into_parts();
|
||||
match accounts
|
||||
.commit_credential_refresh(
|
||||
CredentialRotationCommit {
|
||||
expected_config_revision,
|
||||
prepared: facts,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.commit_credential_refresh(CredentialRotationCommit { prepared: facts }, context)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
@@ -271,42 +281,6 @@ async fn commit_credential_refresh(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
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;
|
||||
}
|
||||
Ok(CredentialMutationResult {
|
||||
config_revision: revision,
|
||||
account_id,
|
||||
credential_revision: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_credentials(
|
||||
accounts: &dyn AccountStore,
|
||||
provider: &dyn ProviderAdmin,
|
||||
@@ -320,7 +294,6 @@ async fn delete_credentials(
|
||||
let revision = accounts
|
||||
.delete_accounts(
|
||||
DeleteAccounts {
|
||||
expected_config_revision: command.expected_config_revision,
|
||||
account_ids: account_ids
|
||||
.iter()
|
||||
.map(|account_id| account_id.as_str().to_owned())
|
||||
@@ -333,6 +306,7 @@ async fn delete_credentials(
|
||||
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,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
UsageCalculatedBillingFact, UsageDetail, UsageFilter, UsageInsights, UsageInsightsCost,
|
||||
UsageInsightsCostPoint, UsageInsightsHealth, UsageInsightsHealthPoint,
|
||||
UsageInsightsPerformance, UsageInsightsPerformancePoint, UsageOverview, UsagePage,
|
||||
UsageQuery, UsageSummary,
|
||||
UsageQuery, UsageSummary, china_day_start,
|
||||
},
|
||||
provider_credentials::ProviderQuotaRequest,
|
||||
},
|
||||
@@ -38,7 +38,6 @@ 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;
|
||||
const CHINA_OFFSET_SECONDS: i64 = 8 * 60 * 60;
|
||||
|
||||
/// API 消费的观测控制面服务。
|
||||
#[async_trait]
|
||||
@@ -99,13 +98,14 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
range: TimeRange,
|
||||
kind: TrendKind,
|
||||
) -> Result<DashboardResult, AdminError> {
|
||||
let observed_at = Utc::now();
|
||||
let (mut observation, settings, runtime_slots) = futures::try_join!(
|
||||
self.store.dashboard_summary(range),
|
||||
self.store.dashboard_summary(range, observed_at),
|
||||
self.settings.load_runtime_settings(),
|
||||
self.store.dashboard_runtime_slots(range.end),
|
||||
self.store.dashboard_runtime_slots(observed_at),
|
||||
)
|
||||
.map_err(|error| map_store_error(error, "dashboard"))?;
|
||||
self.enrich_billing(&mut observation.recent_requests)?;
|
||||
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);
|
||||
@@ -113,15 +113,10 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
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_billing_usd = observation.totals.billing_usd.clone();
|
||||
let total_cached_token_rate = rate_or_zero(
|
||||
observation.requests.cached_tokens,
|
||||
observation.requests.input_tokens,
|
||||
observation.totals.cached_tokens,
|
||||
observation.totals.input_tokens,
|
||||
);
|
||||
let average_first_token_latency_ms = average(
|
||||
observation.requests.first_token_latency_sum_ms,
|
||||
@@ -131,12 +126,20 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
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 total_slots = runtime_slots.as_ref().map_or_else(
|
||||
|| {
|
||||
observation
|
||||
.provider_accounts
|
||||
.normal
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
},
|
||||
|slots| {
|
||||
slots
|
||||
.inherited_accounts
|
||||
.saturating_mul(max_concurrent_per_account)
|
||||
.saturating_add(slots.overridden_slots)
|
||||
},
|
||||
);
|
||||
let used_slots = runtime_slots.and_then(|slots| slots.used_slots);
|
||||
Ok(DashboardResult {
|
||||
capacity: DashboardCapacity {
|
||||
@@ -177,7 +180,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.list_usage_records(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "usage records"))?;
|
||||
self.enrich_billing(&mut page.items)?;
|
||||
self.enrich_billing(&mut page.items);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
@@ -190,7 +193,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
.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))?;
|
||||
self.enrich_billing(std::slice::from_mut(&mut detail.request));
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
@@ -248,6 +251,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|item| DiagnosticsItem {
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
request_count: item.request_count,
|
||||
success_count: item.request_count.saturating_sub(item.failure_count),
|
||||
@@ -255,6 +259,7 @@ impl ObservabilityService for DefaultObservabilityService {
|
||||
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,
|
||||
latency_p95_ms: item.latency_p95_ms,
|
||||
estimated_cost: usd_cost(&item.costs),
|
||||
attempt_count: item.attempt_count,
|
||||
total_tokens: item.total_tokens,
|
||||
@@ -429,6 +434,7 @@ fn recover_standard_costs(
|
||||
};
|
||||
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,
|
||||
@@ -487,20 +493,18 @@ impl DefaultObservabilityService {
|
||||
});
|
||||
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));
|
||||
account.quota_used_percent = used_percent;
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_billing(
|
||||
&self,
|
||||
records: &mut [crate::model::observability::UsageRecord],
|
||||
) -> Result<(), AdminError> {
|
||||
/// 逐条尽力把可校验的总额升级为完整分解;单条脏数据(非法 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 source != "calculated" {
|
||||
if !matches!(source.as_str(), "calculated" | "provider_reported") {
|
||||
continue;
|
||||
}
|
||||
let (Some(provider), Some(upstream_model_id)) = (
|
||||
@@ -509,25 +513,22 @@ impl DefaultObservabilityService {
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let provider_kind = ProviderKind::new(provider.to_owned())
|
||||
.map_err(|_| AdminError::internal("Stored Provider kind is invalid"))?;
|
||||
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 Some(breakdown) = self
|
||||
.providers
|
||||
.calculated_billing(&provider_kind, &input)
|
||||
.map_err(|error| map_provider_error(error, "usage billing"))?
|
||||
{
|
||||
if let Ok(Some(breakdown)) = self.providers.calculated_billing(&provider_kind, &input) {
|
||||
record.billing = Some(UsageBilling::Calculated(Box::new(breakdown)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -833,11 +834,6 @@ fn service_failure_count(metrics: &RequestMetrics) -> u64 {
|
||||
.saturating_sub(metrics.caller_error_count)
|
||||
}
|
||||
|
||||
fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + CHINA_OFFSET_SECONDS).rem_euclid(24 * 60 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
fn quarter_hour_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = value
|
||||
.timestamp()
|
||||
|
||||
@@ -11,9 +11,9 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialDetails, CredentialImportCommit,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutation,
|
||||
CredentialMutationResult, CredentialPage, ImportCredentials, PrepareCredentialImport,
|
||||
PrepareCredentialRotation, ProviderQuotaRequest, RotateCredential, StartAuthorization,
|
||||
CredentialImportResult, CredentialListQuery, CredentialMutationResult, CredentialPage,
|
||||
ImportCredentials, PrepareCredentialImport, PrepareCredentialRotation,
|
||||
ProviderQuotaRequest, RotateCredential, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -22,8 +22,7 @@ use crate::{
|
||||
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,
|
||||
validate_authorization_commit, validate_prepared_import, validate_prepared_rotation,
|
||||
};
|
||||
|
||||
/// OpenAI 固定管理路由消费的服务。
|
||||
@@ -50,14 +49,6 @@ pub trait OpenAiService: Send + Sync {
|
||||
&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,
|
||||
@@ -83,6 +74,31 @@ impl DefaultOpenAiService {
|
||||
snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_initial_quotas(&self, account_ids: &[ProviderAccountId], request_id: &str) {
|
||||
let provider = Arc::clone(&self.provider);
|
||||
let account_ids = account_ids.to_vec();
|
||||
let request_id = request_id.to_owned();
|
||||
tokio::spawn(async move {
|
||||
for account_id in account_ids {
|
||||
if let Err(error) = 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]
|
||||
@@ -112,7 +128,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -127,27 +142,15 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.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?;
|
||||
// 导入已经提交,额度属于可重建观察事实;单个账号查询失败由周期任务重试。
|
||||
for account_id in &result.credential_ids {
|
||||
let _ = self
|
||||
.provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// 导入完成后立即做一次观察;失败不回滚已经提交的 credential。
|
||||
self.observe_initial_quotas(&result.credential_ids, &context.request_id);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -178,12 +181,13 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "OpenAI authorization"))?;
|
||||
validate_authorization_commit(
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
&prepared,
|
||||
prepared,
|
||||
"OpenAI authorization",
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
@@ -191,7 +195,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
"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,
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -200,7 +211,6 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
command: RotateCredential,
|
||||
) -> Result<CredentialMutationResult, AdminError> {
|
||||
let context = command.mutation.context;
|
||||
let expected_config_revision = command.mutation.expected_config_revision;
|
||||
let account_id = command.mutation.account_id;
|
||||
let details = required_credential(
|
||||
self.accounts.as_ref(),
|
||||
@@ -209,17 +219,11 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.await?;
|
||||
if details.credential.credential_revision != command.expected_credential_revision {
|
||||
return Err(AdminError::conflict(
|
||||
"OpenAI credential rotation revision is stale",
|
||||
));
|
||||
}
|
||||
let account = details.credential;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: account.clone(),
|
||||
expected_credential_revision: command.expected_credential_revision,
|
||||
provider_material: command.provider_material,
|
||||
})
|
||||
.await
|
||||
@@ -227,44 +231,14 @@ impl OpenAiService for DefaultOpenAiService {
|
||||
validate_prepared_rotation(&account, &prepared, "OpenAI credential rotation")?;
|
||||
let result = commit_credential_rotation(
|
||||
self.accounts.as_ref(),
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
&context,
|
||||
"OpenAI credential rotation",
|
||||
)
|
||||
.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?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -86,13 +86,12 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<RegeneratedAdminApiKey, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
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(settings.config_revision, key.clone(), context)
|
||||
.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?;
|
||||
@@ -103,10 +102,9 @@ impl SettingsService for DefaultSettingsService {
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
) -> Result<AdminApiKeyMutation, AdminError> {
|
||||
let settings = self.load().await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.delete_admin_api_key(settings.config_revision, context)
|
||||
.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?;
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AuthorizationStarted, CompleteAuthorization, CredentialDeletion,
|
||||
CredentialDeletionResult, CredentialImportCommit, CredentialImportResult,
|
||||
CredentialListQuery, CredentialMutation, CredentialMutationResult, CredentialPage,
|
||||
ImportCredentials, PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
|
||||
CredentialListQuery, CredentialMutationResult, CredentialPage, ImportCredentials,
|
||||
PrepareCredentialImport, ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
},
|
||||
ports::{provider::ProviderAdmin, store::AccountStore},
|
||||
@@ -20,8 +20,8 @@ use crate::{
|
||||
|
||||
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,
|
||||
pending_authorization, publish_committed, validate_authorization_commit,
|
||||
validate_prepared_import,
|
||||
};
|
||||
|
||||
/// xAI 固定管理路由消费的服务。
|
||||
@@ -40,14 +40,6 @@ pub trait XaiService: Send + Sync {
|
||||
&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,
|
||||
@@ -89,7 +81,6 @@ impl XaiService for DefaultXaiService {
|
||||
command: ImportCredentials,
|
||||
) -> Result<CredentialImportResult, AdminError> {
|
||||
let context = command.context;
|
||||
let expected_config_revision = command.expected_config_revision;
|
||||
let prepared = self
|
||||
.provider
|
||||
.prepare_import(PrepareCredentialImport {
|
||||
@@ -104,13 +95,7 @@ impl XaiService for DefaultXaiService {
|
||||
)?;
|
||||
let result = self
|
||||
.accounts
|
||||
.commit_credential_import(
|
||||
CredentialImportCommit {
|
||||
expected_config_revision,
|
||||
prepared,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.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?;
|
||||
@@ -155,12 +140,13 @@ impl XaiService for DefaultXaiService {
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "xAI authorization"))?;
|
||||
validate_authorization_commit(
|
||||
let prepared = validate_authorization_commit(
|
||||
self.provider.provider_kind(),
|
||||
&context,
|
||||
&prepared,
|
||||
prepared,
|
||||
"xAI authorization",
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
let result = commit_authorization(
|
||||
self.accounts.as_ref(),
|
||||
prepared,
|
||||
@@ -172,38 +158,6 @@ impl XaiService for DefaultXaiService {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! 备份模块测试:镜像 src/backup/ 的 policy 与 task。
|
||||
|
||||
mod policy;
|
||||
pub mod support;
|
||||
mod task;
|
||||
@@ -0,0 +1,171 @@
|
||||
//! 备份调度与保留策略(backup/policy.rs)的纯逻辑测试。
|
||||
|
||||
use chrono::{Duration, TimeZone as _, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
backup::policy::{BackupSchedule, RetentionReason, decide_retention},
|
||||
model::backup::{BackupRecord, BackupStatus, BackupTriggerKind},
|
||||
};
|
||||
|
||||
fn record(
|
||||
id: &str,
|
||||
trigger: BackupTriggerKind,
|
||||
status: BackupStatus,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
completed_at: Option<chrono::DateTime<Utc>>,
|
||||
) -> BackupRecord {
|
||||
BackupRecord {
|
||||
id: id.to_owned(),
|
||||
trigger_kind: trigger,
|
||||
status,
|
||||
scheduled_at: if trigger == BackupTriggerKind::Scheduled {
|
||||
Some(created_at)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
object_key: format!("k/{id}"),
|
||||
size_bytes: Some(1),
|
||||
sha256: Some("a".repeat(64)),
|
||||
attempt_count: 0,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
started_at: Some(created_at),
|
||||
completed_at,
|
||||
expires_at: None,
|
||||
created_at,
|
||||
updated_at: created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_bad_cron_and_timezone() {
|
||||
assert!(BackupSchedule::parse("61 * * * *", "Asia/Shanghai").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Not/AZone").is_err());
|
||||
assert!(BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_after_is_strictly_later() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 8, 2, 18, 0, 0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_firing_is_at_or_before() {
|
||||
let schedule = BackupSchedule::parse("0 2 * * *", "Asia/Shanghai").unwrap();
|
||||
// 北京时间 02:00 = 前一日 18:00 UTC。
|
||||
let before = Utc.with_ymd_and_hms(2026, 8, 1, 17, 59, 0).unwrap();
|
||||
assert_eq!(
|
||||
schedule.last_firing_at_or_before(before).unwrap(),
|
||||
Utc.with_ymd_and_hms(2026, 7, 31, 18, 0, 0).unwrap()
|
||||
);
|
||||
let exact = Utc.with_ymd_and_hms(2026, 8, 1, 18, 0, 0).unwrap();
|
||||
assert_eq!(schedule.last_firing_at_or_before(exact).unwrap(), exact);
|
||||
let after = Utc.with_ymd_and_hms(2026, 8, 1, 19, 0, 0).unwrap();
|
||||
assert_eq!(schedule.last_firing_at_or_before(after).unwrap(), exact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dst_spring_forward_skips_missing_hour() {
|
||||
let schedule = BackupSchedule::parse("30 2 * * *", "America/New_York").unwrap();
|
||||
let at = Utc.with_ymd_and_hms(2026, 3, 8, 6, 29, 0).unwrap(); // 01:29 EST
|
||||
let next = schedule.next_after(at).unwrap();
|
||||
assert_eq!(next, Utc.with_ymd_and_hms(2026, 3, 9, 6, 30, 0).unwrap());
|
||||
let at_after_spring = Utc.with_ymd_and_hms(2026, 3, 8, 10, 0, 0).unwrap(); // 06:00 EDT
|
||||
let last = schedule.last_firing_at_or_before(at_after_spring).unwrap();
|
||||
assert_eq!(last, Utc.with_ymd_and_hms(2026, 3, 7, 7, 30, 0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_count_keeps_newest_n() {
|
||||
let now = Utc::now();
|
||||
let base = now - Duration::days(10);
|
||||
let records = vec![
|
||||
record(
|
||||
"newest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base + Duration::days(2),
|
||||
Some(base + Duration::days(2)),
|
||||
),
|
||||
record(
|
||||
"middle",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base + Duration::days(1),
|
||||
Some(base + Duration::days(1)),
|
||||
),
|
||||
record(
|
||||
"oldest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
base,
|
||||
Some(base),
|
||||
),
|
||||
];
|
||||
let decisions = decide_retention(0, 2, now, &records);
|
||||
let ids: Vec<_> = decisions.iter().map(|d| d.record_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["oldest"]);
|
||||
assert_eq!(decisions[0].reason, RetentionReason::Count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retention_days_deletes_old_scheduled_but_keeps_newest() {
|
||||
let now = Utc::now();
|
||||
let records = vec![
|
||||
record(
|
||||
"recent",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(1),
|
||||
Some(now - Duration::days(1)),
|
||||
),
|
||||
record(
|
||||
"ancient",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(40),
|
||||
Some(now - Duration::days(40)),
|
||||
),
|
||||
];
|
||||
let decisions = decide_retention(30, 0, now, &records);
|
||||
let ids: Vec<_> = decisions.iter().map(|d| d.record_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["ancient"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_scheduled_never_auto_deleted_even_when_ancient() {
|
||||
let now = Utc::now();
|
||||
let records = vec![record(
|
||||
"sole",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(400),
|
||||
Some(now - Duration::days(400)),
|
||||
)];
|
||||
assert!(decide_retention(30, 1, now, &records).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_thresholds_delete_nothing() {
|
||||
let now = Utc::now();
|
||||
let records = vec![
|
||||
record(
|
||||
"newest",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(2),
|
||||
Some(now - Duration::days(2)),
|
||||
),
|
||||
record(
|
||||
"old",
|
||||
BackupTriggerKind::Scheduled,
|
||||
BackupStatus::Completed,
|
||||
now - Duration::days(90),
|
||||
Some(now - Duration::days(90)),
|
||||
),
|
||||
];
|
||||
assert!(decide_retention(0, 0, now, &records).is_empty());
|
||||
}
|
||||
@@ -0,0 +1,664 @@
|
||||
//! 备份测试共享 fixture:内存 fake 端口与辅助函数。
|
||||
//!
|
||||
//! 用内存 fake 端口驱动编排层,不依赖 MinIO/真实 S3。
|
||||
//! s3.rs / pg_dump.rs 是 SDK 薄适配器,协议正确性交给 aws-sdk / pg_dump 保证。
|
||||
//! 供 backup::task 与 use_case::backup 的测试复用。
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
MutationActor, MutationContext, Revision,
|
||||
auth::AdminAuditEvent,
|
||||
backup::{
|
||||
BackupError, BackupObjectMetadata, BackupRecord, BackupRecordListQuery,
|
||||
BackupRecordPage, BackupRecordSeed, BackupSettings, BackupStatus, BackupStorageConfig,
|
||||
BackupTriggerKind, ConnectionTestResult, UpdateBackupScheduleCommand,
|
||||
UpdateBackupStorageCommand,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
backup::{
|
||||
BackupObjectStorePort, BackupRepository, DatabaseDumpPort, DumpArtifact, DumpRequest,
|
||||
StagedArtifact, StatusTransitionUpdate, UploadObjectRequest,
|
||||
},
|
||||
store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult, AuthStore},
|
||||
},
|
||||
};
|
||||
use secrecy::SecretString;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 假归档内容;dump 与上传校验共用同一字节与校验值。
|
||||
pub(crate) const DUMP_CONTENT: &[u8] = b"custom-format-archive-bytes-0123456789";
|
||||
|
||||
/// 计算一段内容的 SHA-256 十六进制。
|
||||
pub(crate) fn sha256_hex(content: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(content))
|
||||
}
|
||||
|
||||
/// 返回 32 位十六进制的合法备份记录 id。
|
||||
pub(crate) fn backup_id(suffix: &str) -> String {
|
||||
format!("backup_{}", sha256_hex(suffix.as_bytes()))
|
||||
}
|
||||
|
||||
/// 已配置且已通过连接测试的设置快照。
|
||||
pub(crate) fn configured_settings() -> BackupSettings {
|
||||
let now = Utc::now();
|
||||
BackupSettings {
|
||||
storage_revision: 1,
|
||||
endpoint: Some("https://s3.example.com".to_owned()),
|
||||
region: Some("auto".to_owned()),
|
||||
bucket: Some("backup-bucket".to_owned()),
|
||||
access_key_id: Some("ak".to_owned()),
|
||||
secret_access_key: Some(SecretString::from("sk-value")),
|
||||
prefix: Some("codex".to_owned()),
|
||||
force_path_style: false,
|
||||
schedule_enabled: false,
|
||||
cron_expression: None,
|
||||
schedule_timezone: None,
|
||||
retention_days: 0,
|
||||
retention_count: 0,
|
||||
next_run_at: None,
|
||||
last_verified_at: Some(now),
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 seed 构造 queued 记录。
|
||||
pub(crate) fn queued_record(seed: &BackupRecordSeed) -> BackupRecord {
|
||||
let now = Utc::now();
|
||||
BackupRecord {
|
||||
id: seed.id.clone(),
|
||||
trigger_kind: seed.trigger_kind,
|
||||
status: BackupStatus::Queued,
|
||||
scheduled_at: seed.scheduled_at,
|
||||
object_key: seed.object_key.clone(),
|
||||
size_bytes: None,
|
||||
sha256: None,
|
||||
attempt_count: 0,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
expires_at: seed.expires_at,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存版备份仓储。
|
||||
pub(crate) struct FakeBackupRepository {
|
||||
settings: Mutex<BackupSettings>,
|
||||
records: Mutex<Vec<BackupRecord>>,
|
||||
}
|
||||
|
||||
impl FakeBackupRepository {
|
||||
pub(crate) fn new(settings: BackupSettings) -> Self {
|
||||
Self {
|
||||
settings: Mutex::new(settings),
|
||||
records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试辅助:读取当前全部记录。
|
||||
pub(crate) fn all_records(&self) -> Vec<BackupRecord> {
|
||||
self.records.lock().expect("records").clone()
|
||||
}
|
||||
|
||||
/// 测试辅助:强制把一条记录标记为指定时间完成的 completed。
|
||||
pub(crate) fn set_completed(&self, id: &str, completed_at: DateTime<Utc>) {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if let Some(record) = records.iter_mut().find(|record| record.id == id) {
|
||||
record.status = BackupStatus::Completed;
|
||||
record.completed_at = Some(completed_at);
|
||||
record.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试辅助:强制把一条记录迁移到指定状态。
|
||||
pub(crate) fn force_status(&self, id: &str, status: BackupStatus) {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if let Some(record) = records.iter_mut().find(|record| record.id == id) {
|
||||
record.status = status;
|
||||
if matches!(status, BackupStatus::Completed | BackupStatus::Failed) {
|
||||
record.completed_at = Some(Utc::now());
|
||||
}
|
||||
record.updated_at = Utc::now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupRepository for FakeBackupRepository {
|
||||
async fn load_settings(&self) -> AdminStoreResult<BackupSettings> {
|
||||
Ok(self.settings.lock().expect("settings").clone())
|
||||
}
|
||||
|
||||
async fn update_storage_settings(
|
||||
&self,
|
||||
command: UpdateBackupStorageCommand,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<(BackupSettings, Revision)> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.storage_revision += 1;
|
||||
settings.endpoint = Some(command.endpoint);
|
||||
settings.region = Some(command.region);
|
||||
settings.bucket = Some(command.bucket);
|
||||
settings.access_key_id = Some(command.access_key_id);
|
||||
if let Some(secret) = command.secret_access_key {
|
||||
settings.secret_access_key = Some(secret);
|
||||
}
|
||||
settings.prefix = Some(command.prefix);
|
||||
settings.force_path_style = command.force_path_style;
|
||||
settings.last_verified_at = None;
|
||||
settings.updated_at = Utc::now();
|
||||
let revision = Revision::new(settings.storage_revision).map_err(|_| {
|
||||
AdminStoreError::new(AdminStoreErrorKind::Invalid, "backup", "zero revision")
|
||||
})?;
|
||||
Ok((settings.clone(), revision))
|
||||
}
|
||||
|
||||
async fn update_schedule_settings(
|
||||
&self,
|
||||
command: UpdateBackupScheduleCommand,
|
||||
next_run_at: Option<DateTime<Utc>>,
|
||||
_context: &MutationContext,
|
||||
) -> AdminStoreResult<BackupSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.schedule_enabled = command.schedule_enabled;
|
||||
settings.cron_expression = Some(command.cron_expression);
|
||||
settings.schedule_timezone = Some(command.schedule_timezone);
|
||||
settings.retention_days = command.retention_days;
|
||||
settings.retention_count = command.retention_count;
|
||||
settings.next_run_at = next_run_at;
|
||||
settings.updated_at = Utc::now();
|
||||
Ok(settings.clone())
|
||||
}
|
||||
|
||||
async fn record_verification(
|
||||
&self,
|
||||
storage_revision: u64,
|
||||
at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
if settings.storage_revision != storage_revision {
|
||||
return Ok(false);
|
||||
}
|
||||
settings.last_verified_at = Some(at);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn insert_backup_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<BackupRecord> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active()) {
|
||||
return Err(AdminStoreError::new(
|
||||
AdminStoreErrorKind::Conflict,
|
||||
"backup record",
|
||||
"an active backup task already exists",
|
||||
));
|
||||
}
|
||||
let record = queued_record(&seed);
|
||||
records.push(record.clone());
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
async fn insert_scheduled_record(&self, seed: BackupRecordSeed) -> AdminStoreResult<bool> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
if records.iter().any(|record| record.status.is_active())
|
||||
|| records.iter().any(|record| {
|
||||
record.trigger_kind == BackupTriggerKind::Scheduled
|
||||
&& record.scheduled_at == seed.scheduled_at
|
||||
})
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
records.push(queued_record(&seed));
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn list_backup_records(
|
||||
&self,
|
||||
query: BackupRecordListQuery,
|
||||
) -> AdminStoreResult<BackupRecordPage> {
|
||||
let records = self.records.lock().expect("records");
|
||||
let mut items = records
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
query.status.is_none_or(|status| record.status == status)
|
||||
&& query
|
||||
.trigger
|
||||
.is_none_or(|trigger| record.trigger_kind == trigger)
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
let total = items.len() as u64;
|
||||
let offset =
|
||||
(u64::from(query.page.saturating_sub(1)) * u64::from(query.page_size.get())) as usize;
|
||||
let limit = usize::from(query.page_size.get());
|
||||
items = items.into_iter().skip(offset).take(limit).collect();
|
||||
Ok(BackupRecordPage {
|
||||
items,
|
||||
total,
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_backup_record(&self, id: &str) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.find(|record| record.id == id)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_intermediate_records(&self) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
matches!(
|
||||
record.status,
|
||||
BackupStatus::Dumping | BackupStatus::Uploading
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_pending_deletions(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| record.status == BackupStatus::Deleting)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_expired_records(&self, _limit: u32) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
let now = Utc::now();
|
||||
Ok(self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record
|
||||
.expires_at
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
&& matches!(
|
||||
record.status,
|
||||
BackupStatus::Completed | BackupStatus::Failed
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn claim_next_queued(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.status == BackupStatus::Queued)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = BackupStatus::Dumping;
|
||||
record.started_at = Some(now);
|
||||
record.attempt_count += 1;
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn transition_status(
|
||||
&self,
|
||||
id: &str,
|
||||
from: BackupStatus,
|
||||
to: BackupStatus,
|
||||
update: StatusTransitionUpdate,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.status == from)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = to;
|
||||
if let Some(size) = update.size_bytes {
|
||||
record.size_bytes = Some(size);
|
||||
}
|
||||
if let Some(sha256) = update.sha256 {
|
||||
record.sha256 = Some(sha256);
|
||||
}
|
||||
if let Some(code) = update.error_code {
|
||||
record.error_code = Some(code);
|
||||
}
|
||||
if let Some(message) = update.error_message {
|
||||
record.error_message = Some(message);
|
||||
}
|
||||
record.completed_at = update.completed_at.or_else(|| {
|
||||
matches!(to, BackupStatus::Completed | BackupStatus::Failed).then_some(now)
|
||||
});
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn transition_to_deleting(
|
||||
&self,
|
||||
id: &str,
|
||||
now: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<BackupRecord>> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
let Some(record) = records
|
||||
.iter_mut()
|
||||
.find(|record| record.id == id && record.status.can_be_deleted())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
record.status = BackupStatus::Deleting;
|
||||
record.updated_at = now;
|
||||
Ok(Some(record.clone()))
|
||||
}
|
||||
|
||||
async fn delete_record(&self, id: &str) -> AdminStoreResult<()> {
|
||||
let mut records = self.records.lock().expect("records");
|
||||
records.retain(|record| record.id != id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn advance_schedule_cursor(
|
||||
&self,
|
||||
next_run_at: DateTime<Utc>,
|
||||
_expected_cron: &str,
|
||||
_expected_timezone: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.next_run_at = Some(next_run_at);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn list_scheduled_completed_desc(
|
||||
&self,
|
||||
_limit: u32,
|
||||
) -> AdminStoreResult<Vec<BackupRecord>> {
|
||||
let mut records = self
|
||||
.records
|
||||
.lock()
|
||||
.expect("records")
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record.trigger_kind == BackupTriggerKind::Scheduled
|
||||
&& record.status == BackupStatus::Completed
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
records.sort_by(|a, b| {
|
||||
b.completed_at
|
||||
.cmp(&a.completed_at)
|
||||
.then_with(|| b.id.cmp(&a.id))
|
||||
});
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
/// 假 `pg_dump` 端口:把固定内容写入临时文件作为归档;可注入失败。
|
||||
pub(crate) struct FakeDumpPort {
|
||||
dir: TempDir,
|
||||
fail_dump: bool,
|
||||
}
|
||||
|
||||
impl FakeDumpPort {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
dir: tempfile::tempdir().expect("temp dir"),
|
||||
fail_dump: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让下一次 `dump` 返回 `backup.pg_dump_failed`。
|
||||
pub(crate) fn fail_next_dump(mut self) -> Self {
|
||||
self.fail_dump = true;
|
||||
self
|
||||
}
|
||||
|
||||
fn artifact(&self, backup_id: &str) -> DumpArtifact {
|
||||
let path = self.dir.path().join(format!("{backup_id}.dump"));
|
||||
std::fs::write(&path, DUMP_CONTENT).expect("write artifact");
|
||||
DumpArtifact {
|
||||
path,
|
||||
size_bytes: DUMP_CONTENT.len() as u64,
|
||||
sha256: sha256_hex(DUMP_CONTENT),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseDumpPort for FakeDumpPort {
|
||||
async fn dump(
|
||||
&self,
|
||||
request: DumpRequest,
|
||||
) -> Result<DumpArtifact, gateway_admin::model::backup::BackupError> {
|
||||
if self.fail_dump {
|
||||
return Err(gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::PG_DUMP_FAILED,
|
||||
"pg_dump failed".to_owned(),
|
||||
));
|
||||
}
|
||||
if request.cancellation.is_cancelled() {
|
||||
return Err(gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::CANCELLED,
|
||||
"cancelled".to_owned(),
|
||||
));
|
||||
}
|
||||
Ok(self.artifact(&request.backup_id))
|
||||
}
|
||||
|
||||
async fn inspect_staging(
|
||||
&self,
|
||||
backup_id: &str,
|
||||
) -> Result<Option<StagedArtifact>, gateway_admin::model::backup::BackupError> {
|
||||
let path = self.dir.path().join(format!("{backup_id}.dump"));
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(StagedArtifact {
|
||||
path,
|
||||
size_bytes: DUMP_CONTENT.len() as u64,
|
||||
sha256: sha256_hex(DUMP_CONTENT),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn cleanup_staging(
|
||||
&self,
|
||||
_backup_id: &str,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 内存版对象存储:把上传内容按 key 保存并支持 Head/Delete/预签名。
|
||||
pub(crate) struct FakeObjectStore {
|
||||
pub(crate) objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
|
||||
upload_error: Option<BackupError>,
|
||||
}
|
||||
|
||||
impl FakeObjectStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
objects: Mutex::new(std::collections::HashMap::new()),
|
||||
upload_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 让上传返回指定的脱敏 S3 错误。
|
||||
pub(crate) fn fail_upload(mut self, code: &'static str, message: impl Into<String>) -> Self {
|
||||
self.upload_error = Some(BackupError::new(code, message.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn object(&self, key: &str) -> Option<Vec<u8>> {
|
||||
self.objects.lock().expect("objects").get(key).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BackupObjectStorePort for FakeObjectStore {
|
||||
async fn test_connection(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
) -> Result<ConnectionTestResult, gateway_admin::model::backup::BackupError> {
|
||||
Ok(ConnectionTestResult {
|
||||
ok: true,
|
||||
stage: "deleteObject",
|
||||
code: None,
|
||||
message: "连接测试成功".to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
request: UploadObjectRequest,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
if let Some(error) = &self.upload_error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let content = std::fs::read(&request.source).map_err(|_| {
|
||||
gateway_admin::model::backup::BackupError::new(
|
||||
gateway_admin::model::backup::code::S3_UPLOAD_FAILED,
|
||||
"read source".to_owned(),
|
||||
)
|
||||
})?;
|
||||
self.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(request.object_key, content);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn head_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<Option<BackupObjectMetadata>, gateway_admin::model::backup::BackupError> {
|
||||
let Some(content) = self.object(object_key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(BackupObjectMetadata::new(
|
||||
"backup-test".to_owned(),
|
||||
sha256_hex(&content),
|
||||
Utc::now(),
|
||||
content.len() as u64,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn delete_object(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
) -> Result<(), gateway_admin::model::backup::BackupError> {
|
||||
self.objects.lock().expect("objects").remove(object_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn presigned_download(
|
||||
&self,
|
||||
_config: &BackupStorageConfig,
|
||||
object_key: &str,
|
||||
_file_name: &str,
|
||||
_ttl: std::time::Duration,
|
||||
) -> Result<String, gateway_admin::model::backup::BackupError> {
|
||||
Ok(format!("https://presigned.example.com/{object_key}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录审计事件的假 AuthStore。
|
||||
pub(crate) struct FakeAuthStore {
|
||||
audit: Mutex<Vec<AdminAuditEvent>>,
|
||||
}
|
||||
|
||||
impl FakeAuthStore {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
audit: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn audit_actions(&self) -> Vec<String> {
|
||||
self.audit
|
||||
.lock()
|
||||
.expect("audit")
|
||||
.iter()
|
||||
.map(|event| event.action.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthStore for FakeAuthStore {
|
||||
async fn load_password_hash(&self, _admin_user_id: &str) -> AdminStoreResult<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn create_password_hash_if_absent(
|
||||
&self,
|
||||
_admin_user_id: &str,
|
||||
_password_hash: &str,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
async fn load_admin_api_key(
|
||||
&self,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::settings::AdminApiKey>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn load_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn store_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
_session: &gateway_admin::model::auth::AdminSession,
|
||||
) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn delete_session(
|
||||
&self,
|
||||
_session_id: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::auth::AdminSession>> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audit.lock().expect("audit").push(event);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 系统发起者上下文。
|
||||
pub(crate) fn system_context() -> MutationContext {
|
||||
MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "backup-runtime-test".to_owned(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//! BackupTask 执行链测试:驱动内存 fake 端口跑完整 dump/upload/verify/retention 流程。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::{
|
||||
backup::task::BackupTask,
|
||||
model::backup::{
|
||||
BackupRecordSeed, BackupStatus, BackupTriggerKind, UpdateBackupScheduleCommand, code,
|
||||
},
|
||||
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
|
||||
};
|
||||
use gateway_core::engine::CancellationToken;
|
||||
|
||||
use super::support::{
|
||||
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
|
||||
configured_settings, sha256_hex, system_context,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_runs_full_dump_upload_verify_pipeline() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("manual"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/manual.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
assert_eq!(records.len(), 1);
|
||||
let finished = &records[0];
|
||||
assert_eq!(finished.status, BackupStatus::Completed);
|
||||
assert_eq!(finished.size_bytes, Some(DUMP_CONTENT.len() as u64));
|
||||
assert_eq!(
|
||||
finished.sha256.as_deref(),
|
||||
Some(sha256_hex(DUMP_CONTENT).as_str())
|
||||
);
|
||||
|
||||
// 对象已上传且内容与本地归档一致。
|
||||
let stored = object_store
|
||||
.object(&seed.object_key)
|
||||
.expect("uploaded object exists");
|
||||
assert_eq!(stored.as_slice(), DUMP_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_marks_failed_when_dump_port_fails() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new().fail_next_dump());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("fail"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/fail.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some("backup.pg_dump_failed"));
|
||||
assert!(failed.completed_at.is_some());
|
||||
// 归档未上传,对象不存在。
|
||||
assert!(object_store.object(&seed.object_key).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_marks_failed_with_the_upload_error() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store =
|
||||
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("upload-failure"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/upload-failure.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump, object_store);
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
|
||||
assert_eq!(
|
||||
failed.error_message.as_deref(),
|
||||
Some("上传到对象存储失败:对象存储拒绝上传")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_task_recovery_marks_failed_with_the_upload_error() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store =
|
||||
Arc::new(FakeObjectStore::new().fail_upload(code::S3_UPLOAD_FAILED, "对象存储拒绝上传"));
|
||||
let seed = BackupRecordSeed {
|
||||
id: backup_id("recovered-upload-failure"),
|
||||
trigger_kind: BackupTriggerKind::Manual,
|
||||
scheduled_at: None,
|
||||
object_key: "codex/2026/08/01/recovered-upload-failure.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_backup_record(seed.clone())
|
||||
.await
|
||||
.expect("insert queued");
|
||||
let cancellation = CancellationToken::new();
|
||||
dump.dump(DumpRequest {
|
||||
backup_id: seed.id.clone(),
|
||||
cancellation: cancellation.clone(),
|
||||
})
|
||||
.await
|
||||
.expect("stage dump");
|
||||
repository.force_status(&seed.id, BackupStatus::Dumping);
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump, object_store);
|
||||
task.run_cycle(&cancellation).await.expect("run cycle");
|
||||
|
||||
let records = repository.all_records();
|
||||
let failed = &records[0];
|
||||
assert_eq!(failed.status, BackupStatus::Failed);
|
||||
assert_eq!(failed.error_code.as_deref(), Some(code::S3_UPLOAD_FAILED));
|
||||
assert_eq!(
|
||||
failed.error_message.as_deref(),
|
||||
Some("上传到对象存储失败:对象存储拒绝上传")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retention_cleans_expired_scheduled_backups() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
|
||||
// 造一条旧 completed 计划备份(超过 retention_days)与一条新备份。
|
||||
let old_seed = BackupRecordSeed {
|
||||
id: backup_id("old"),
|
||||
trigger_kind: BackupTriggerKind::Scheduled,
|
||||
scheduled_at: Some(Utc::now() - Duration::days(40)),
|
||||
object_key: "codex/2026/06/01/old.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
let new_seed = BackupRecordSeed {
|
||||
id: backup_id("new"),
|
||||
trigger_kind: BackupTriggerKind::Scheduled,
|
||||
scheduled_at: Some(Utc::now() - Duration::days(1)),
|
||||
object_key: "codex/2026/07/01/new.dump".to_owned(),
|
||||
expires_at: None,
|
||||
};
|
||||
repository
|
||||
.insert_scheduled_record(old_seed.clone())
|
||||
.await
|
||||
.expect("insert old");
|
||||
// 旧备份 40 天前完成(释放活跃名额),再插入新备份。
|
||||
let now = Utc::now();
|
||||
repository.set_completed(&backup_id("old"), now - Duration::days(40));
|
||||
repository
|
||||
.insert_scheduled_record(new_seed.clone())
|
||||
.await
|
||||
.expect("insert new");
|
||||
// 新备份 1 天前完成。
|
||||
repository.set_completed(&backup_id("new"), now - Duration::days(1));
|
||||
// 两个对象都已上传。
|
||||
object_store
|
||||
.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(old_seed.object_key.clone(), DUMP_CONTENT.to_vec());
|
||||
object_store
|
||||
.objects
|
||||
.lock()
|
||||
.expect("objects")
|
||||
.insert(new_seed.object_key.clone(), DUMP_CONTENT.to_vec());
|
||||
|
||||
// 设置 retention_days = 30。
|
||||
repository
|
||||
.update_schedule_settings(
|
||||
UpdateBackupScheduleCommand {
|
||||
schedule_enabled: false,
|
||||
cron_expression: "0 2 * * *".to_owned(),
|
||||
schedule_timezone: "Asia/Shanghai".to_owned(),
|
||||
retention_days: 30,
|
||||
retention_count: 0,
|
||||
},
|
||||
None,
|
||||
&system_context(),
|
||||
)
|
||||
.await
|
||||
.expect("set retention");
|
||||
|
||||
let task = BackupTask::new(repository.clone(), dump.clone(), object_store.clone());
|
||||
task.run_cycle(&CancellationToken::new())
|
||||
.await
|
||||
.expect("run cycle");
|
||||
|
||||
// 旧备份被硬删除,对象被删除;新备份保留。
|
||||
let records = repository.all_records();
|
||||
let ids: Vec<_> = records.iter().map(|record| record.id.as_str()).collect();
|
||||
assert_eq!(ids, vec![backup_id("new").as_str()]);
|
||||
assert!(object_store.object(&old_seed.object_key).is_none());
|
||||
assert!(object_store.object(&new_seed.object_key).is_some());
|
||||
}
|
||||
@@ -6,17 +6,21 @@ use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
PageSize, Revision,
|
||||
accounts::AccountUsage,
|
||||
auth::LoginCommand,
|
||||
client_keys::ClientKeyPageSize,
|
||||
observability::{DecimalAmount, RequestOutcome, TimeRange},
|
||||
provider_credentials::{
|
||||
CredentialCommitGuard, ProviderDocument, ProviderQuota, ProviderQuotaWindow,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
settings::AdminApiKey,
|
||||
},
|
||||
ports::store::{AccountStore, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
mod backup;
|
||||
mod model;
|
||||
mod use_case;
|
||||
|
||||
#[test]
|
||||
@@ -87,7 +91,6 @@ fn login_command_debug_should_redact_password() {
|
||||
let command = LoginCommand {
|
||||
username: None,
|
||||
password: "secret-password".to_owned(),
|
||||
source: "127.0.0.1".to_owned(),
|
||||
};
|
||||
assert!(!format!("{command:?}").contains("secret-password"));
|
||||
}
|
||||
@@ -126,12 +129,102 @@ fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
quota_window("shortTerm", Some(18_000), Some(30.0)),
|
||||
quota_window("shortTerm", Some(14_400), Some(45.0)),
|
||||
],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(45.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn representative_window_usage_should_accept_account_wide_rolling_window_without_reset() {
|
||||
let usage = AccountUsage {
|
||||
account_id: "acct_xai".to_owned(),
|
||||
request_count: 5,
|
||||
success_count: 5,
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(20),
|
||||
cached_tokens: Some(80),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
image_request_count: 0,
|
||||
image_request_failed_count: 0,
|
||||
total_tokens: Some(120),
|
||||
cost_coverage: Default::default(),
|
||||
costs: Vec::new(),
|
||||
last_used_at: Some(Utc::now()),
|
||||
request_buckets: Vec::new(),
|
||||
models: Vec::new(),
|
||||
};
|
||||
let quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![ProviderQuotaWindow {
|
||||
key: "free-rolling-24h".to_owned(),
|
||||
group: "shortTerm".to_owned(),
|
||||
label: "日限额".to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds: Some(86_400),
|
||||
used_percent: None,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: Some(usage.clone()),
|
||||
provider_data: None,
|
||||
}],
|
||||
limit_reached: false,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_window_usage(), Some(&usage));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut 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(95.0)),
|
||||
],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
assert_eq!(quota.representative_used_percent(), Some(100.0));
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(99.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(80.0));
|
||||
assert_eq!(quota.windows[2].used_percent, Some(30.0));
|
||||
assert_eq!(quota.windows[3].used_percent, Some(100.0));
|
||||
assert!(quota.windows[3].limit_reached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
observed_at: None,
|
||||
refresh_token_expires_at: None,
|
||||
windows: vec![reached, quota_window("shortTerm", Some(18_000), Some(95.0))],
|
||||
limit_reached: true,
|
||||
provider_data: None,
|
||||
};
|
||||
|
||||
quota.apply_limit_reached_display();
|
||||
|
||||
assert_eq!(quota.windows[0].used_percent, Some(100.0));
|
||||
assert_eq!(quota.windows[1].used_percent, Some(95.0));
|
||||
assert!(!quota.windows[1].limit_reached);
|
||||
}
|
||||
|
||||
fn quota_window(
|
||||
group: &str,
|
||||
window_seconds: Option<u64>,
|
||||
@@ -142,9 +235,11 @@ fn quota_window(
|
||||
group: group.to_owned(),
|
||||
label: group.to_owned(),
|
||||
source: None,
|
||||
local_usage_attribution: QuotaLocalUsageAttribution::AccountWide,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at: None,
|
||||
limit_reached: false,
|
||||
local_usage: None,
|
||||
provider_data: None,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! 备份领域模型(model/backup.rs)的纯逻辑测试。
|
||||
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use secrecy::SecretString;
|
||||
|
||||
use gateway_admin::model::backup::{
|
||||
BackupSettings, BackupStatus, BackupStorageConfig, BackupTriggerKind, build_object_key,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn status_machine_rejects_invalid_transitions() {
|
||||
assert!(BackupStatus::Queued.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(BackupStatus::Dumping.allows_transition_to(BackupStatus::Uploading));
|
||||
assert!(BackupStatus::Uploading.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(BackupStatus::Completed.allows_transition_to(BackupStatus::Deleting));
|
||||
assert!(!BackupStatus::Queued.allows_transition_to(BackupStatus::Completed));
|
||||
assert!(!BackupStatus::Completed.allows_transition_to(BackupStatus::Dumping));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Failed));
|
||||
assert!(!BackupStatus::Deleting.allows_transition_to(BackupStatus::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_classification() {
|
||||
assert!(BackupStatus::Queued.is_active());
|
||||
assert!(BackupStatus::Uploading.is_active());
|
||||
assert!(!BackupStatus::Completed.is_active());
|
||||
assert!(BackupStatus::Completed.can_be_deleted());
|
||||
assert!(BackupStatus::Failed.can_be_deleted());
|
||||
assert!(!BackupStatus::Queued.can_be_deleted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_key_shape() {
|
||||
let at = Utc.with_ymd_and_hms(2026, 8, 1, 12, 30, 0).unwrap();
|
||||
let key = build_object_key("production/backups", "backup_abc", at).unwrap();
|
||||
assert_eq!(
|
||||
key,
|
||||
"production/backups/2026/08/01/codex-proxy-rs_20260801_123000_abc.dump"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_key_rejects_dangerous_prefixes() {
|
||||
let at = Utc::now();
|
||||
assert!(build_object_key("/leading", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a/../b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a\\b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("a\u{0001}b", "backup_x", at).is_err());
|
||||
assert!(build_object_key("", "backup_x", at).is_err());
|
||||
assert!(build_object_key("trailing//", "backup_x", at).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trigger_and_status_roundtrip() {
|
||||
for status in [
|
||||
BackupStatus::Queued,
|
||||
BackupStatus::Dumping,
|
||||
BackupStatus::Uploading,
|
||||
BackupStatus::Completed,
|
||||
BackupStatus::Failed,
|
||||
BackupStatus::Deleting,
|
||||
] {
|
||||
assert_eq!(BackupStatus::parse(status.as_str()), Some(status));
|
||||
}
|
||||
assert_eq!(
|
||||
BackupTriggerKind::parse("manual"),
|
||||
Some(BackupTriggerKind::Manual)
|
||||
);
|
||||
assert_eq!(
|
||||
BackupTriggerKind::parse("scheduled"),
|
||||
Some(BackupTriggerKind::Scheduled)
|
||||
);
|
||||
assert_eq!(BackupTriggerKind::parse("unknown"), None);
|
||||
assert_eq!(BackupStatus::parse("skipped"), None);
|
||||
assert_eq!(BackupStatus::parse("deleted"), None);
|
||||
assert_eq!(BackupStatus::parse("unknown"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_settings_redacts_secret_in_debug() {
|
||||
let mut settings = BackupSettings {
|
||||
storage_revision: 3,
|
||||
endpoint: Some("https://example.com".to_owned()),
|
||||
region: Some("auto".to_owned()),
|
||||
bucket: Some("b".to_owned()),
|
||||
access_key_id: Some("ak".to_owned()),
|
||||
secret_access_key: Some(SecretString::from("sk-secret")),
|
||||
prefix: Some("p".to_owned()),
|
||||
force_path_style: false,
|
||||
schedule_enabled: false,
|
||||
cron_expression: None,
|
||||
schedule_timezone: None,
|
||||
retention_days: 0,
|
||||
retention_count: 0,
|
||||
next_run_at: None,
|
||||
last_verified_at: None,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
assert!(!format!("{:?}", settings).contains("sk-secret"));
|
||||
let config = BackupStorageConfig::from_settings(&settings).unwrap();
|
||||
assert!(!format!("{config:?}").contains("sk-secret"));
|
||||
settings.endpoint = None;
|
||||
assert!(BackupStorageConfig::from_settings(&settings).is_none());
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! 模型模块测试:镜像 src/model/ 中带逻辑的模块。
|
||||
|
||||
mod backup;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
use std::{collections::BTreeMap, sync::Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
@@ -15,7 +16,6 @@ struct MemoryAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
failures: Mutex<u32>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -66,31 +66,6 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn login_source_is_throttled(
|
||||
&self,
|
||||
_: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(*self.failures.lock().expect("failures") >= failure_limit)
|
||||
}
|
||||
|
||||
async fn record_login_failure(
|
||||
&self,
|
||||
_: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut failures = self.failures.lock().expect("failures");
|
||||
*failures += 1;
|
||||
Ok(*failures >= failure_limit)
|
||||
}
|
||||
|
||||
async fn clear_login_failures(&self, _: &str) -> AdminStoreResult<()> {
|
||||
*self.failures.lock().expect("failures") = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
self.audits.lock().expect("audits").push(event);
|
||||
Ok(())
|
||||
@@ -107,7 +82,6 @@ async fn successful_login_should_create_expiring_session_and_audit() {
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "strong-test-password".to_owned(),
|
||||
source: "127.0.0.1".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect("login");
|
||||
@@ -142,9 +116,38 @@ async fn repeated_default_initialization_should_not_replace_password() {
|
||||
.login(LoginCommand {
|
||||
username: Some("admin".to_owned()),
|
||||
password: "first-strong-password".to_owned(),
|
||||
source: "127.0.0.1".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,104 @@
|
||||
//! BackupService 用例测试:通过 AdminHarness 注入 fake 备份端口构造真实服务。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::model::backup::BackupStatus;
|
||||
use gateway_admin::ports::backup::BackupStorePorts;
|
||||
|
||||
use crate::backup::support::{
|
||||
FakeAuthStore, FakeBackupRepository, FakeDumpPort, FakeObjectStore, configured_settings,
|
||||
system_context,
|
||||
};
|
||||
|
||||
use super::AdminHarness;
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_service_creates_downloads_and_deletes_with_audit() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
let auth = Arc::new(FakeAuthStore::new());
|
||||
|
||||
let services = AdminHarness::new()
|
||||
.auth(auth.clone())
|
||||
.backup(BackupStorePorts::new(
|
||||
repository.clone(),
|
||||
dump,
|
||||
object_store,
|
||||
))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// 创建手动备份 → queued + 审计。
|
||||
let created = services
|
||||
.backups()
|
||||
.create_backup(&system_context(), None)
|
||||
.await
|
||||
.expect("create backup");
|
||||
assert_eq!(created.status, BackupStatus::Queued);
|
||||
assert!(created.object_key.starts_with("codex/"));
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.created")
|
||||
);
|
||||
|
||||
// 下载地址要求 completed 记录。
|
||||
repository.force_status(&created.id, BackupStatus::Completed);
|
||||
let download = services
|
||||
.backups()
|
||||
.download_url(&system_context(), &created.id)
|
||||
.await
|
||||
.expect("download url");
|
||||
assert!(download.url.starts_with("https://presigned.example.com/"));
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.download_url_created")
|
||||
);
|
||||
|
||||
// 删除 → deleting + 审计。
|
||||
let deleting = services
|
||||
.backups()
|
||||
.delete_backup(&system_context(), &created.id)
|
||||
.await
|
||||
.expect("delete backup");
|
||||
assert_eq!(deleting.status, BackupStatus::Deleting);
|
||||
assert!(
|
||||
auth.audit_actions()
|
||||
.iter()
|
||||
.any(|action| action == "backup.delete_requested")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backup_service_rejects_download_of_non_completed_record() {
|
||||
let repository = Arc::new(FakeBackupRepository::new(configured_settings()));
|
||||
let dump = Arc::new(FakeDumpPort::new());
|
||||
let object_store = Arc::new(FakeObjectStore::new());
|
||||
let auth = Arc::new(FakeAuthStore::new());
|
||||
|
||||
let services = AdminHarness::new()
|
||||
.auth(auth.clone())
|
||||
.backup(BackupStorePorts::new(
|
||||
repository.clone(),
|
||||
dump,
|
||||
object_store,
|
||||
))
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let created = services
|
||||
.backups()
|
||||
.create_backup(&system_context(), None)
|
||||
.await
|
||||
.expect("create backup");
|
||||
|
||||
// queued 记录不能创建下载地址。
|
||||
let error = services
|
||||
.backups()
|
||||
.download_url(&system_context(), &created.id)
|
||||
.await
|
||||
.expect_err("non-completed record must be rejected");
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod backup;
|
||||
mod client_keys;
|
||||
mod observability;
|
||||
mod openai;
|
||||
@@ -13,14 +14,20 @@ use std::{
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
AccountGroupListQuery, AccountGroupMutation, AccountGroupPage, DeleteAccountGroup,
|
||||
NewAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountUpdateResult, AccountUsage,
|
||||
AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -46,10 +53,11 @@ use gateway_admin::{
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
},
|
||||
ports::{
|
||||
backup::BackupStorePorts,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
|
||||
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
AccountGroupStore, AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts,
|
||||
AdminStoreResult, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
},
|
||||
system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations,
|
||||
@@ -60,7 +68,7 @@ use gateway_admin::{
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeRequest, AccountProbeResult},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::ClientApiKeyId,
|
||||
@@ -69,11 +77,13 @@ use gateway_core::{
|
||||
|
||||
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>,
|
||||
backup: BackupStorePorts,
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
@@ -84,11 +94,13 @@ impl AdminHarness {
|
||||
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,
|
||||
backup: BackupStorePorts::disabled(),
|
||||
providers: vec![
|
||||
Arc::new(UnavailableProvider::new("openai")),
|
||||
Arc::new(UnavailableProvider::new("xai")),
|
||||
@@ -103,6 +115,11 @@ impl AdminHarness {
|
||||
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
|
||||
@@ -128,6 +145,11 @@ impl AdminHarness {
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn backup(mut self, backup: BackupStorePorts) -> Self {
|
||||
self.backup = backup;
|
||||
self
|
||||
}
|
||||
|
||||
pub(super) fn provider(mut self, provider: Arc<dyn ProviderAdmin>) -> Self {
|
||||
self.providers
|
||||
.retain(|registered| registered.provider_kind() != provider.provider_kind());
|
||||
@@ -148,16 +170,18 @@ impl AdminHarness {
|
||||
pub(super) async fn build(self) -> AdminServices {
|
||||
gateway_admin::initialize(
|
||||
AdminConfig {
|
||||
session_ttl_minutes: 60,
|
||||
session_ttl_minutes: self.session_ttl_minutes,
|
||||
default_username: "admin".to_owned(),
|
||||
default_password: InitialAdminPassword::new(self.default_password),
|
||||
},
|
||||
AdminStorePorts::new(
|
||||
self.accounts,
|
||||
Arc::new(UnavailableAccountGroupStore),
|
||||
self.auth,
|
||||
self.client_keys,
|
||||
self.observability,
|
||||
self.settings,
|
||||
self.backup,
|
||||
),
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
@@ -210,18 +234,6 @@ impl AuthStore for BootstrapAuthStore {
|
||||
Err(unavailable("admin session"))
|
||||
}
|
||||
|
||||
async fn login_source_is_throttled(&self, _: &str, _: u32, _: u64) -> AdminStoreResult<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
async fn record_login_failure(&self, _: &str, _: u32, _: u64) -> AdminStoreResult<bool> {
|
||||
Err(unavailable("login failure"))
|
||||
}
|
||||
|
||||
async fn clear_login_failures(&self, _: &str) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, _: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -229,13 +241,60 @@ impl AuthStore for BootstrapAuthStore {
|
||||
|
||||
struct UnavailableStore;
|
||||
|
||||
struct UnavailableAccountGroupStore;
|
||||
|
||||
#[async_trait]
|
||||
impl AccountGroupStore for UnavailableAccountGroupStore {
|
||||
async fn list_account_groups(
|
||||
&self,
|
||||
_: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage> {
|
||||
Err(unavailable("account groups"))
|
||||
}
|
||||
|
||||
async fn create_account_group(
|
||||
&self,
|
||||
_: NewAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unavailable("account group create"))
|
||||
}
|
||||
|
||||
async fn update_account_group(
|
||||
&self,
|
||||
_: UpdateAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unavailable("account group update"))
|
||||
}
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
_: SetAccountGroupEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unavailable("account group state"))
|
||||
}
|
||||
|
||||
async fn delete_account_group(
|
||||
&self,
|
||||
_: DeleteAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
Err(unavailable("account group delete"))
|
||||
}
|
||||
}
|
||||
|
||||
#[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>> {
|
||||
async fn load_account(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> AdminStoreResult<Option<gateway_admin::model::accounts::AccountPageItem>> {
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
@@ -310,14 +369,30 @@ impl AccountStore for UnavailableStore {
|
||||
Err(unavailable("credential refresh"))
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
async fn update_account(
|
||||
&self,
|
||||
_: SetAccountEnabled,
|
||||
_: UpdateAccount,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
) -> AdminStoreResult<AccountUpdateResult> {
|
||||
Err(unavailable("account enabled"))
|
||||
}
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult> {
|
||||
Err(unavailable("account recovery"))
|
||||
}
|
||||
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
_: BatchUpdateAccounts,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
Err(unavailable("account batch update"))
|
||||
}
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
_: DeleteAccounts,
|
||||
@@ -383,7 +458,11 @@ impl ClientKeyStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnavailableStore {
|
||||
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
_: TimeRange,
|
||||
_: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation> {
|
||||
Err(unavailable("dashboard"))
|
||||
}
|
||||
|
||||
@@ -454,7 +533,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -463,7 +541,6 @@ impl SettingsStore for UnavailableStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unavailable("admin API key"))
|
||||
@@ -654,12 +731,13 @@ impl AccountProbe for UnavailableProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, GatewayError>> {
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"test account probe is unavailable",
|
||||
))
|
||||
)
|
||||
.into())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::{
|
||||
num::NonZeroU32,
|
||||
str::FromStr as _,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
@@ -9,13 +10,14 @@ use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
MutationContext, PageSize, Revision,
|
||||
observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, CostCoverage, CurrencyCost, DashboardObservation,
|
||||
DashboardRuntimeSlots, DiagnosticDimension, DiagnosticObservation, Granularity,
|
||||
HealthStatus, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, RequestMetrics,
|
||||
TimeRange, TrendKind, UsageCalculatedBillingFact, UsageDetail, UsageFilter,
|
||||
UsageOverview, UsagePage, UsageQuery,
|
||||
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,
|
||||
@@ -181,7 +183,8 @@ 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,
|
||||
inherited_accounts: 2,
|
||||
overridden_slots: 5,
|
||||
used_slots: Some(2),
|
||||
}));
|
||||
let services = observability_services(store).await;
|
||||
@@ -193,9 +196,28 @@ async fn dashboard_summary_should_project_rebuildable_runtime_slots() {
|
||||
.expect("dashboard summary")
|
||||
.capacity;
|
||||
|
||||
assert_eq!(capacity.total_slots, 3);
|
||||
assert_eq!(capacity.total_slots, 7);
|
||||
assert_eq!(capacity.used_slots, Some(2));
|
||||
assert_eq!(capacity.available_slots, Some(1));
|
||||
assert_eq!(capacity.available_slots, Some(5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_share_one_current_observation_time_across_runtime_facts() {
|
||||
let historical_end = Utc::now() - Duration::hours(1);
|
||||
let store = Arc::new(FixtureObservabilityStore::new(observation_range(
|
||||
historical_end,
|
||||
)));
|
||||
let services = observability_services(store.clone()).await;
|
||||
|
||||
services
|
||||
.observability()
|
||||
.dashboard_summary(observation_range(historical_end), TrendKind::Usage)
|
||||
.await
|
||||
.expect("dashboard summary");
|
||||
|
||||
let (summary_observed_at, slots_observed_at) = store.observed_times();
|
||||
assert_eq!(summary_observed_at, slots_observed_at);
|
||||
assert!(summary_observed_at.is_some_and(|value| value > historical_end));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -247,6 +269,7 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
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),
|
||||
@@ -318,12 +341,82 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
|
||||
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>>,
|
||||
summary_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
slots_observed_at: Mutex<Option<DateTime<Utc>>>,
|
||||
usage_records: Mutex<Vec<UsageRecord>>,
|
||||
}
|
||||
|
||||
impl FixtureObservabilityStore {
|
||||
@@ -339,6 +432,9 @@ impl FixtureObservabilityStore {
|
||||
calculated_billing_facts: Mutex::new(Vec::new()),
|
||||
diagnostics: Mutex::new(Vec::new()),
|
||||
runtime_slots: Mutex::new(None),
|
||||
summary_observed_at: Mutex::new(None),
|
||||
slots_observed_at: Mutex::new(None),
|
||||
usage_records: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,15 +460,38 @@ impl FixtureObservabilityStore {
|
||||
fn replace_runtime_slots(&self, runtime_slots: Option<DashboardRuntimeSlots>) {
|
||||
*self.runtime_slots.lock().expect("runtime slots") = runtime_slots;
|
||||
}
|
||||
|
||||
fn observed_times(&self) -> (Option<DateTime<Utc>>, Option<DateTime<Utc>>) {
|
||||
(
|
||||
*self
|
||||
.summary_observed_at
|
||||
.lock()
|
||||
.expect("summary observed at"),
|
||||
*self.slots_observed_at.lock().expect("slots observed at"),
|
||||
)
|
||||
}
|
||||
|
||||
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> {
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation> {
|
||||
*self
|
||||
.summary_observed_at
|
||||
.lock()
|
||||
.expect("summary observed at") = Some(observed_at);
|
||||
Ok(DashboardObservation {
|
||||
range,
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: self.trend.lock().expect("trend").clone(),
|
||||
account_usage: Vec::new(),
|
||||
@@ -382,8 +501,9 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
|
||||
async fn dashboard_runtime_slots(
|
||||
&self,
|
||||
_: DateTime<Utc>,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> AdminStoreResult<Option<DashboardRuntimeSlots>> {
|
||||
*self.slots_observed_at.lock().expect("slots observed at") = Some(observed_at);
|
||||
Ok(*self.runtime_slots.lock().expect("runtime slots"))
|
||||
}
|
||||
|
||||
@@ -412,7 +532,13 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
Err(super::unavailable("usage records"))
|
||||
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> {
|
||||
@@ -471,7 +597,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -480,7 +605,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(super::unavailable("admin API key"))
|
||||
@@ -511,6 +635,101 @@ 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,
|
||||
routing_scope: "all".to_owned(),
|
||||
routing_group_refs: Vec::new(),
|
||||
routing_group_names_snapshot: Vec::new(),
|
||||
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,
|
||||
@@ -523,6 +742,7 @@ fn health_metric_point(bucket_start: DateTime<Utc>, metrics: RequestMetrics) ->
|
||||
|
||||
fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
DiagnosticObservation {
|
||||
key: name.to_owned(),
|
||||
name: name.to_owned(),
|
||||
request_count,
|
||||
success_count: request_count,
|
||||
@@ -530,16 +750,12 @@ fn diagnostic(name: &str, request_count: u64) -> DiagnosticObservation {
|
||||
attempt_count: request_count,
|
||||
total_tokens: request_count.saturating_mul(100),
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(200),
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn china_day_start(value: DateTime<Utc>) -> DateTime<Utc> {
|
||||
let elapsed = (value.timestamp() + 8 * 60 * 60).rem_euclid(24 * 60 * 60);
|
||||
value - Duration::seconds(elapsed) - Duration::nanoseconds(i64::from(value.nanosecond()))
|
||||
}
|
||||
|
||||
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()))
|
||||
|
||||
@@ -5,8 +5,8 @@ use gateway_core::engine::credential::ProviderAccountId;
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::provider_credentials::{
|
||||
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, CredentialMutation,
|
||||
ImportCredentials, ProviderQuotaRequest, ReauthorizationTarget, StartAuthorization,
|
||||
AuthorizationMutationTarget, CompleteAuthorization, CredentialDeletion, ImportCredentials,
|
||||
ProviderQuotaRequest, StartAuthorization,
|
||||
},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
@@ -15,50 +15,6 @@ 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"]
|
||||
);
|
||||
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",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-openai"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_delete_should_commit_then_release_provider_resources() {
|
||||
let events = events();
|
||||
@@ -78,31 +34,12 @@ async fn openai_delete_should_commit_then_release_provider_resources() {
|
||||
"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();
|
||||
@@ -114,17 +51,19 @@ async fn openai_import_should_prepare_before_atomic_store_commit() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import credential");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
"provider.prepare_import",
|
||||
"store.commit_import",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
@@ -151,12 +90,13 @@ async fn openai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("import credentials");
|
||||
|
||||
await_quota_requests(&provider, 2).await;
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
[
|
||||
@@ -193,12 +133,13 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
.expect("committed import remains successful");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
assert_eq!(
|
||||
result.credential_ids,
|
||||
[ProviderAccountId::new("acct_prepared").expect("account ID")]
|
||||
@@ -206,6 +147,141 @@ async fn openai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
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");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
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");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
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");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
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();
|
||||
@@ -218,7 +294,6 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
.openai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-openai-error"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -228,7 +303,7 @@ async fn openai_import_provider_error_should_not_touch_store_transaction() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_through_commit() {
|
||||
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());
|
||||
@@ -237,18 +312,13 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.openai()
|
||||
.start_authorization(StartAuthorization {
|
||||
context: context("oauth-start-openai"),
|
||||
expected_config_revision: revision(7),
|
||||
name: "reauthorize".to_owned(),
|
||||
reauthorization: Some(ReauthorizationTarget {
|
||||
account_id: ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
credential_revision: revision(1),
|
||||
}),
|
||||
reauthorization: Some(ProviderAccountId::new("acct_test").expect("account ID")),
|
||||
})
|
||||
.await
|
||||
.expect("start reauthorization");
|
||||
|
||||
let pending = provider.pending().expect("pending envelope");
|
||||
assert_eq!(pending.expected_config_revision(), revision(7));
|
||||
assert_eq!(pending.provider_kind().as_str(), "openai");
|
||||
assert_eq!(
|
||||
pending.owner_binding().started_request_id(),
|
||||
@@ -256,15 +326,14 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
);
|
||||
assert!(matches!(
|
||||
pending.target(),
|
||||
AuthorizationMutationTarget::Reauthorize {
|
||||
account_id,
|
||||
expected_credential_revision,
|
||||
} if account_id.as_str() == "acct_test"
|
||||
&& *expected_credential_revision == revision(1)
|
||||
AuthorizationMutationTarget::Reauthorize { account_id }
|
||||
if account_id.as_str() == "acct_test"
|
||||
));
|
||||
assert!(!format!("{pending:?}").contains("admin-test"));
|
||||
|
||||
services
|
||||
provider.set_current_credential_revision(revision(2));
|
||||
|
||||
let result = services
|
||||
.openai()
|
||||
.complete_authorization(CompleteAuthorization {
|
||||
context: context("oauth-complete-openai"),
|
||||
@@ -274,6 +343,10 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
.await
|
||||
.expect("complete reauthorization");
|
||||
|
||||
await_quota_requests(&provider, 1).await;
|
||||
|
||||
assert_eq!(result.credential_revision, Some(revision(3)));
|
||||
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
[
|
||||
@@ -282,9 +355,11 @@ async fn openai_reauthorization_should_restore_pending_envelope_and_hold_guard_t
|
||||
"provider.complete_authorization",
|
||||
"store.commit_authorization",
|
||||
"guard.finish",
|
||||
"provider.account_facts_changed",
|
||||
"provider.quota",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.authorization_revision(), Some(revision(7)));
|
||||
assert_eq!(provider.quota_requests().len(), 1);
|
||||
assert_eq!(store.audit_requests(), ["oauth-complete-openai"]);
|
||||
}
|
||||
|
||||
@@ -296,18 +371,19 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
.await
|
||||
}
|
||||
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
async fn await_quota_requests(provider: &FakeProviderAdmin, expected: usize) {
|
||||
for _ in 0..100 {
|
||||
if provider.quota_requests().len() == expected {
|
||||
return;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert_eq!(provider.quota_requests().len(), expected);
|
||||
}
|
||||
|
||||
fn deletion(account_id: &str) -> CredentialDeletion {
|
||||
CredentialDeletion {
|
||||
context: context("request-openai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_ids: vec![ProviderAccountId::new(account_id).expect("account ID")],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use async_trait::async_trait;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
AdminErrorKind, MutationContext, Revision,
|
||||
AdminErrorKind, MutationContext,
|
||||
settings::{
|
||||
AdminApiKey, AdminApiKeyMutation, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
@@ -33,7 +33,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: AdminApiKey,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
@@ -42,7 +41,6 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
_: Revision,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AdminApiKeyMutation> {
|
||||
Err(unused())
|
||||
@@ -63,7 +61,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
request_id: "request-settings".to_owned(),
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(1).expect("revision"),
|
||||
model_mappings: Default::default(),
|
||||
refresh_margin_seconds: 0,
|
||||
refresh_concurrency: 1,
|
||||
|
||||
@@ -4,37 +4,11 @@ use gateway_core::engine::credential::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
model::provider_credentials::{CredentialMutation, ImportCredentials, ProviderQuotaRequest},
|
||||
model::provider_credentials::{ImportCredentials, ProviderQuotaRequest},
|
||||
ports::provider::ProviderAdminErrorKind,
|
||||
};
|
||||
|
||||
use super::accounts::{
|
||||
FakeAccountStore, FakeProviderAdmin, context, document, events, recorded, revision,
|
||||
};
|
||||
|
||||
#[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",
|
||||
]
|
||||
);
|
||||
assert_eq!(store.audit_requests(), ["request-xai"]);
|
||||
}
|
||||
use super::accounts::{FakeAccountStore, FakeProviderAdmin, context, document, events, recorded};
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
@@ -47,7 +21,6 @@ async fn xai_import_should_prepare_before_atomic_store_commit() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai"),
|
||||
expected_config_revision: revision(3),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -84,7 +57,6 @@ async fn xai_import_should_refresh_quota_for_every_imported_account() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-batch"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -119,7 +91,6 @@ async fn xai_import_should_remain_successful_when_quota_refresh_fails() {
|
||||
.xai()
|
||||
.import_document(ImportCredentials {
|
||||
context: context("import-xai-quota-failure"),
|
||||
expected_config_revision: revision(1),
|
||||
document: document(),
|
||||
})
|
||||
.await
|
||||
@@ -136,11 +107,3 @@ async fn service(provider: Arc<FakeProviderAdmin>, store: Arc<FakeAccountStore>)
|
||||
.build()
|
||||
.await
|
||||
}
|
||||
|
||||
fn mutation(account_id: &str) -> CredentialMutation {
|
||||
CredentialMutation {
|
||||
context: context("request-xai"),
|
||||
expected_config_revision: revision(1),
|
||||
account_id: ProviderAccountId::new(account_id).expect("account ID"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,16 @@ path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = { version = "0.8.9", default-features = false, features = ["json", "query", "ws"] }
|
||||
base64 = "0.22.1"
|
||||
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" }
|
||||
secrecy = "0.10"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["preserve_order"] }
|
||||
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"] }
|
||||
@@ -31,7 +32,9 @@ uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["macros", "rt"] }
|
||||
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]
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Account group HTTP wire and fixed routes.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
PageSize,
|
||||
account_groups::{
|
||||
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor, AccountGroupListQuery,
|
||||
AccountGroupMutation, AccountGroupPage, AccountGroupRecord, AccountGroupUsage,
|
||||
CreateAccountGroup, DeleteAccountGroup, SetAccountGroupEnabled, UpdateAccountGroup,
|
||||
},
|
||||
};
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
const MAX_PAGE_SIZE: u32 = 200;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ListAccountGroupsQuery {
|
||||
page: Option<u32>,
|
||||
page_size: Option<u32>,
|
||||
search: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl ListAccountGroupsQuery {
|
||||
fn into_command(self) -> Result<AccountGroupListQuery, WireValidationError> {
|
||||
let page = self.page.unwrap_or(1);
|
||||
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
if page == 0 || page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("page"));
|
||||
}
|
||||
let search = self
|
||||
.search
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty());
|
||||
if search
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(WireValidationError::new("search"));
|
||||
}
|
||||
Ok(AccountGroupListQuery {
|
||||
page,
|
||||
page_size: PageSize::new(
|
||||
u16::try_from(page_size).map_err(|_| WireValidationError::new("pageSize"))?,
|
||||
)
|
||||
.map_err(|_| WireValidationError::new("pageSize"))?,
|
||||
search,
|
||||
enabled: self.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct CreateAccountGroupRequest {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct UpdateAccountGroupRequest {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct AccountGroupIdRequest {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupView {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
color: String,
|
||||
enabled: bool,
|
||||
member_count: u64,
|
||||
provider_counts: BTreeMap<String, u64>,
|
||||
client_key_count: u64,
|
||||
account_summary: AccountGroupAccountSummaryView,
|
||||
capacity: AccountGroupCapacityView,
|
||||
usage: AccountGroupUsageView,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupAccountSummaryView {
|
||||
available: u64,
|
||||
limited: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupCapacityView {
|
||||
used_slots: Option<u64>,
|
||||
total_slots: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupUsageView {
|
||||
today_usd: String,
|
||||
total_usd: String,
|
||||
}
|
||||
|
||||
impl From<AccountGroupRecord> for AccountGroupView {
|
||||
fn from(record: AccountGroupRecord) -> Self {
|
||||
Self {
|
||||
id: record.id.to_string(),
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
color: record.color.as_str().to_owned(),
|
||||
enabled: record.enabled,
|
||||
member_count: record.member_count,
|
||||
provider_counts: record.provider_counts,
|
||||
client_key_count: record.client_key_count,
|
||||
account_summary: account_summary_view(record.account_summary),
|
||||
capacity: capacity_view(record.capacity),
|
||||
usage: usage_view(record.usage),
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn account_summary_view(summary: AccountGroupAccountSummary) -> AccountGroupAccountSummaryView {
|
||||
AccountGroupAccountSummaryView {
|
||||
available: summary.available,
|
||||
limited: summary.limited,
|
||||
total: summary.total,
|
||||
}
|
||||
}
|
||||
|
||||
fn capacity_view(capacity: AccountGroupCapacity) -> AccountGroupCapacityView {
|
||||
AccountGroupCapacityView {
|
||||
used_slots: capacity.used_slots,
|
||||
total_slots: capacity.total_slots,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_view(usage: AccountGroupUsage) -> AccountGroupUsageView {
|
||||
AccountGroupUsageView {
|
||||
today_usd: usage.today_usd.to_string(),
|
||||
total_usd: usage.total_usd.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupPageData {
|
||||
items: Vec<AccountGroupView>,
|
||||
page: PageMeta,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountGroupPage> for AccountGroupPageData {
|
||||
fn from(page: AccountGroupPage) -> Self {
|
||||
let total_pages = if page.total == 0 {
|
||||
0
|
||||
} else {
|
||||
page.total.div_ceil(u64::from(page.page_size))
|
||||
};
|
||||
Self {
|
||||
items: page.items.into_iter().map(Into::into).collect(),
|
||||
page: PageMeta::new(
|
||||
page.page,
|
||||
u32::from(page.page_size),
|
||||
page.total,
|
||||
u32::try_from(total_pages).unwrap_or(u32::MAX),
|
||||
),
|
||||
config_revision: page.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AccountGroupMutationData {
|
||||
id: String,
|
||||
record: Option<AccountGroupView>,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountGroupMutation> for AccountGroupMutationData {
|
||||
fn from(mutation: AccountGroupMutation) -> Self {
|
||||
Self {
|
||||
id: mutation.id.to_string(),
|
||||
record: mutation.record.map(Into::into),
|
||||
config_revision: mutation.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct all fixed account-group management routes.
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/account-groups", get(list::<S>))
|
||||
.route("/api/admin/account-groups/create", post(create::<S>))
|
||||
.route("/api/admin/account-groups/update", post(update::<S>))
|
||||
.route("/api/admin/account-groups/enable", post(enable::<S>))
|
||||
.route("/api/admin/account-groups/disable", post(disable::<S>))
|
||||
.route("/api/admin/account-groups/delete", post(delete::<S>))
|
||||
}
|
||||
|
||||
async fn list<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<ListAccountGroupsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
.list(query.into_command().map_err(map_wire_error)?)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountGroupPageData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<CreateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
StatusCode::CREATED,
|
||||
state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
.create(
|
||||
&auth.context().mutation_context(),
|
||||
CreateAccountGroup {
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
color: group_color(&request.color)?,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn update<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<UpdateAccountGroupRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
validate_group_fields(&request.name, request.description.as_deref())?;
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
.update(
|
||||
&auth.context().mutation_context(),
|
||||
UpdateAccountGroup {
|
||||
id: group_id(request.id)?,
|
||||
name: request.name,
|
||||
description: request.description,
|
||||
color: group_color(&request.color)?,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn enable<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, true).await
|
||||
}
|
||||
|
||||
async fn disable<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
set_enabled(auth, state, request, false).await
|
||||
}
|
||||
|
||||
async fn set_enabled<S>(
|
||||
auth: AdminAuth,
|
||||
state: S,
|
||||
request: AccountGroupIdRequest,
|
||||
enabled: bool,
|
||||
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
.set_enabled(
|
||||
&auth.context().mutation_context(),
|
||||
SetAccountGroupEnabled {
|
||||
id: group_id(request.id)?,
|
||||
enabled,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
async fn delete<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<AccountGroupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
mutation_response(
|
||||
StatusCode::OK,
|
||||
state
|
||||
.admin_services()
|
||||
.account_groups()
|
||||
.delete(
|
||||
&auth.context().mutation_context(),
|
||||
DeleteAccountGroup {
|
||||
id: group_id(request.id)?,
|
||||
},
|
||||
)
|
||||
.await,
|
||||
)
|
||||
}
|
||||
|
||||
fn mutation_response(
|
||||
status: StatusCode,
|
||||
result: Result<AccountGroupMutation, gateway_admin::model::AdminError>,
|
||||
) -> Result<AdminResponse<AdminEnvelope<AccountGroupMutationData>>, AdminError> {
|
||||
let result = result.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
status,
|
||||
AdminEnvelope::ok(AccountGroupMutationData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
fn group_id(value: String) -> Result<AccountGroupId, AdminError> {
|
||||
AccountGroupId::new(value).map_err(|_| AdminError::bad_request("Invalid account group ID"))
|
||||
}
|
||||
|
||||
fn group_color(value: &str) -> Result<AccountGroupColor, AdminError> {
|
||||
AccountGroupColor::parse(value)
|
||||
.ok_or_else(|| AdminError::bad_request("Invalid account group color"))
|
||||
}
|
||||
|
||||
fn validate_group_fields(name: &str, description: Option<&str>) -> Result<(), AdminError> {
|
||||
if name.trim() != name
|
||||
|| name.is_empty()
|
||||
|| name.chars().count() > 100
|
||||
|| name.chars().any(char::is_control)
|
||||
|| description
|
||||
.is_some_and(|value| value.len() > 4096 || value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(AdminError::bad_request("Invalid account group request"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_wire_error(_: WireValidationError) -> AdminError {
|
||||
AdminError::bad_request("Invalid account group query")
|
||||
}
|
||||
|
||||
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
map_admin_service_error(error, "Account group repository unavailable")
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
use std::fmt;
|
||||
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
Json, Router,
|
||||
extract::{FromRequestParts, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
@@ -14,6 +14,7 @@ use gateway_admin::{
|
||||
model::auth::{AdminPrincipal, AdminRequestContext, LoginCommand, LoginError},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::request_id::RequestId;
|
||||
|
||||
use super::{AdminEnvelope, AdminError, AdminResponse};
|
||||
|
||||
@@ -51,22 +52,32 @@ where
|
||||
|
||||
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 = parts
|
||||
.headers
|
||||
.get(REQUEST_ID_HEADER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
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.to_owned(),
|
||||
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,
|
||||
@@ -185,22 +196,6 @@ impl Default for AdminLogoutData {
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求上下文解析出的客户端登录来源。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminLoginSource(String);
|
||||
|
||||
impl AdminLoginSource {
|
||||
#[must_use]
|
||||
pub fn new(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 管理员认证路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
@@ -214,24 +209,16 @@ where
|
||||
|
||||
async fn login<S>(
|
||||
State(state): State<S>,
|
||||
source: Option<Extension<AdminLoginSource>>,
|
||||
Json(payload): Json<AdminLoginRequest>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let source = source
|
||||
.as_ref()
|
||||
.map_or("unknown", |Extension(source)| source.as_str());
|
||||
let (username, password) = payload.into_parts();
|
||||
let session = state
|
||||
.admin_services()
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
username,
|
||||
password,
|
||||
source: source.to_owned(),
|
||||
})
|
||||
.login(LoginCommand { username, password })
|
||||
.await
|
||||
.map_err(map_login_error)?;
|
||||
let mut response = AdminResponse::new(
|
||||
@@ -300,7 +287,6 @@ fn admin_session_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
fn map_login_error(error: LoginError) -> AdminError {
|
||||
match error {
|
||||
LoginError::InvalidCredentials => AdminError::invalid_admin_credentials(),
|
||||
LoginError::Throttled => AdminError::too_many_login_attempts(),
|
||||
LoginError::Unavailable => AdminError::internal("Failed to create admin session"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
//! 备份设置、计划、手动创建、记录、下载与删除路由。
|
||||
//!
|
||||
//! 路径统一位于 `/api/admin/settings/backups/*`,内部由独立 `BackupService` 承担业务。
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::backup::{
|
||||
BackupRecord, BackupSettings, BackupStatus, BackupTriggerKind, ConnectionTestResult,
|
||||
UpdateBackupScheduleCommand, UpdateBackupStorageCommand,
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminPageData, AdminResponse, AdminSessionState,
|
||||
PageMeta, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
/// 备份设置视图;`secretAccessKey` 返回已保存的明文凭据,由前端掩码显示。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupSettingsView {
|
||||
pub storage_revision: u64,
|
||||
pub endpoint: Option<String>,
|
||||
pub region: Option<String>,
|
||||
pub bucket: Option<String>,
|
||||
pub access_key_id: Option<String>,
|
||||
pub secret_access_key: Option<String>,
|
||||
pub prefix: Option<String>,
|
||||
pub force_path_style: bool,
|
||||
pub verified: bool,
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: Option<String>,
|
||||
pub schedule_timezone: Option<String>,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
pub next_run_at: Option<DateTime<Utc>>,
|
||||
pub last_verified_at: Option<DateTime<Utc>>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<BackupSettings> for BackupSettingsView {
|
||||
fn from(settings: BackupSettings) -> Self {
|
||||
let verified = settings.storage_verified();
|
||||
Self {
|
||||
storage_revision: settings.storage_revision,
|
||||
endpoint: settings.endpoint,
|
||||
region: settings.region,
|
||||
bucket: settings.bucket,
|
||||
access_key_id: settings.access_key_id,
|
||||
secret_access_key: settings
|
||||
.secret_access_key
|
||||
.map(|secret| secret.expose_secret().to_owned()),
|
||||
prefix: settings.prefix,
|
||||
force_path_style: settings.force_path_style,
|
||||
verified,
|
||||
schedule_enabled: settings.schedule_enabled,
|
||||
cron_expression: settings.cron_expression,
|
||||
schedule_timezone: settings.schedule_timezone,
|
||||
retention_days: settings.retention_days,
|
||||
retention_count: settings.retention_count,
|
||||
next_run_at: settings.next_run_at,
|
||||
last_verified_at: settings.last_verified_at,
|
||||
updated_at: settings.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新 S3 存储配置请求;`secretAccessKey` 缺省表示保留旧值。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateBackupStorageRequest {
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key_id: String,
|
||||
#[serde(default)]
|
||||
pub secret_access_key: Option<String>,
|
||||
pub prefix: String,
|
||||
pub force_path_style: bool,
|
||||
}
|
||||
|
||||
/// 更新调度配置请求。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateBackupScheduleRequest {
|
||||
pub schedule_enabled: bool,
|
||||
pub cron_expression: String,
|
||||
pub schedule_timezone: String,
|
||||
pub retention_days: u32,
|
||||
pub retention_count: u32,
|
||||
}
|
||||
|
||||
/// 备份记录 wire 视图。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupRecordView {
|
||||
pub id: String,
|
||||
pub trigger_kind: String,
|
||||
pub status: String,
|
||||
pub scheduled_at: Option<DateTime<Utc>>,
|
||||
pub object_key: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub sha256: Option<String>,
|
||||
pub attempt_count: u32,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<BackupRecord> for BackupRecordView {
|
||||
fn from(record: BackupRecord) -> Self {
|
||||
Self {
|
||||
id: record.id,
|
||||
trigger_kind: record.trigger_kind.to_string(),
|
||||
status: record.status.to_string(),
|
||||
scheduled_at: record.scheduled_at,
|
||||
object_key: record.object_key,
|
||||
size_bytes: record.size_bytes,
|
||||
sha256: record.sha256,
|
||||
attempt_count: record.attempt_count,
|
||||
error_code: record.error_code,
|
||||
error_message: record.error_message,
|
||||
started_at: record.started_at,
|
||||
completed_at: record.completed_at,
|
||||
expires_at: record.expires_at,
|
||||
created_at: record.created_at,
|
||||
updated_at: record.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录列表查询参数。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackupRecordsQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub status: Option<String>,
|
||||
pub trigger: Option<String>,
|
||||
}
|
||||
|
||||
/// 下载地址请求。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BackupIdRequest {
|
||||
pub backup_id: String,
|
||||
}
|
||||
|
||||
/// 下载地址响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownloadUrlView {
|
||||
pub url: String,
|
||||
pub file_name: String,
|
||||
pub expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
impl From<gateway_admin::model::backup::DownloadUrlResult> for DownloadUrlView {
|
||||
fn from(result: gateway_admin::model::backup::DownloadUrlResult) -> Self {
|
||||
Self {
|
||||
url: result.url,
|
||||
file_name: result.file_name,
|
||||
expires_in_seconds: result.expires_in.as_secs(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接测试响应。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectionTestView {
|
||||
pub ok: bool,
|
||||
pub stage: String,
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl From<ConnectionTestResult> for ConnectionTestView {
|
||||
fn from(result: ConnectionTestResult) -> Self {
|
||||
Self {
|
||||
ok: result.ok,
|
||||
stage: result.stage.to_owned(),
|
||||
code: result.code.map(str::to_owned),
|
||||
message: result.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造固定 GET/POST 备份路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/settings/backups", get(backup_settings::<S>))
|
||||
.route(
|
||||
"/api/admin/settings/backups/storage/update",
|
||||
post(update_backup_storage::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/storage/test",
|
||||
post(test_backup_storage::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/schedule/update",
|
||||
post(update_backup_schedule::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/records",
|
||||
get(backup_records::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/create",
|
||||
post(create_backup::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/download-url",
|
||||
post(download_backup_url::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/backups/delete",
|
||||
post(delete_backup::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
async fn backup_settings<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let settings = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.load_settings()
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_backup_storage<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<UpdateBackupStorageRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupStorageCommand {
|
||||
endpoint: request.endpoint,
|
||||
region: request.region,
|
||||
bucket: request.bucket,
|
||||
access_key_id: request.access_key_id,
|
||||
secret_access_key: request.secret_access_key.map(SecretString::from),
|
||||
prefix: request.prefix,
|
||||
force_path_style: request.force_path_style,
|
||||
};
|
||||
let settings = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.update_storage(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn test_backup_storage<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.test_connection(&auth.context().mutation_context())
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ConnectionTestView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_backup_schedule<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<UpdateBackupScheduleRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = UpdateBackupScheduleCommand {
|
||||
schedule_enabled: request.schedule_enabled,
|
||||
cron_expression: request.cron_expression,
|
||||
schedule_timezone: request.schedule_timezone,
|
||||
retention_days: request.retention_days,
|
||||
retention_count: request.retention_count,
|
||||
};
|
||||
let settings = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.update_schedule(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupSettingsView::from(settings)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn backup_records<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<BackupRecordsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let page_size = gateway_admin::model::PageSize::new(query.page_size.unwrap_or(20))
|
||||
.map_err(|_| AdminError::bad_request("Invalid pageSize"))?;
|
||||
let page = query.page.unwrap_or(1);
|
||||
if page == 0 {
|
||||
return Err(AdminError::bad_request("Invalid page"));
|
||||
}
|
||||
let status = match query.status.as_deref() {
|
||||
None => None,
|
||||
Some(value) => BackupStatus::parse(value)
|
||||
.map(Some)
|
||||
.ok_or_else(|| AdminError::bad_request("Invalid status"))?,
|
||||
};
|
||||
let trigger = match query.trigger.as_deref() {
|
||||
None => None,
|
||||
Some(value) => BackupTriggerKind::parse(value)
|
||||
.map(Some)
|
||||
.ok_or_else(|| AdminError::bad_request("Invalid trigger"))?,
|
||||
};
|
||||
let domain_query = gateway_admin::model::backup::BackupRecordListQuery {
|
||||
page,
|
||||
page_size,
|
||||
status,
|
||||
trigger,
|
||||
};
|
||||
let page_result = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.list_records(domain_query)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let meta = PageMeta::new(
|
||||
page_result.page,
|
||||
u32::from(page_result.page_size.get()),
|
||||
page_result.total,
|
||||
page_result.total_pages(),
|
||||
);
|
||||
let items: Vec<BackupRecordView> = page_result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(BackupRecordView::from)
|
||||
.collect();
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AdminPageData::new(items, meta)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_backup<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
body: Option<Json<CreateBackupRequest>>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let expires_at = body
|
||||
.and_then(|body| body.0.expires_in_days)
|
||||
.filter(|&days| days > 0)
|
||||
.map(|days| Utc::now() + chrono::Duration::days(i64::from(days)));
|
||||
let record = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.create_backup(&auth.context().mutation_context(), expires_at)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::ACCEPTED,
|
||||
AdminEnvelope::ok(BackupRecordView::from(record)),
|
||||
))
|
||||
}
|
||||
|
||||
/// 创建备份请求;`expiresInDays` 为 0 或缺省表示不设置过期时间。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateBackupRequest {
|
||||
pub expires_in_days: Option<u32>,
|
||||
}
|
||||
|
||||
async fn download_backup_url<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let result = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.download_url(&auth.context().mutation_context(), &request.backup_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(DownloadUrlView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_backup<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(request): Json<BackupIdRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let record = state
|
||||
.admin_services()
|
||||
.backups()
|
||||
.delete_backup(&auth.context().mutation_context(), &request.backup_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BackupRecordView::from(record)),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_service_error(error: gateway_admin::model::AdminError) -> AdminError {
|
||||
map_admin_service_error(error, "Backup service unavailable")
|
||||
}
|
||||
@@ -4,18 +4,15 @@ use std::fmt;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
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_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,
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -138,10 +135,9 @@ impl ClientKeySort {
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CreateClientKeyRequest {
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
group_ids: Vec<String>,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
}
|
||||
@@ -149,18 +145,15 @@ pub struct CreateClientKeyRequest {
|
||||
impl CreateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<CreateClientKey, WireValidationError> {
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
let group_ids = validate_group_ids(self.group_ids)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(CreateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
name: self.name,
|
||||
label: self.label,
|
||||
provider_kind: ProviderKind::new(self.provider_kind)
|
||||
.map_err(|_| WireValidationError::new("providerKind"))?,
|
||||
group_ids,
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
@@ -174,10 +167,9 @@ impl CreateClientKeyRequest {
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateClientKeyRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
group_ids: Vec<String>,
|
||||
max_concurrency: u64,
|
||||
requests_per_minute: u64,
|
||||
}
|
||||
@@ -186,19 +178,16 @@ impl UpdateClientKeyRequest {
|
||||
/// 校验 wire 边界并直接构造管理用例命令。
|
||||
pub fn into_command(self) -> Result<UpdateClientKey, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
validate_required_text(&self.name, "name")?;
|
||||
validate_optional_text(self.label.as_deref(), "label")?;
|
||||
validate_provider_kind(&self.provider_kind)?;
|
||||
let group_ids = validate_group_ids(self.group_ids)?;
|
||||
validate_limit(self.max_concurrency, "maxConcurrency")?;
|
||||
validate_limit(self.requests_per_minute, "requestsPerMinute")?;
|
||||
Ok(UpdateClientKey {
|
||||
expected_config_revision: revision(self.expected_config_revision)?,
|
||||
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"))?,
|
||||
group_ids,
|
||||
limits: RateLimits {
|
||||
max_concurrency: self.max_concurrency,
|
||||
requests_per_minute: self.requests_per_minute,
|
||||
@@ -207,12 +196,11 @@ impl UpdateClientKeyRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// 只携带 ID 与配置 revision 的 Client Key mutation 请求。
|
||||
/// 只携带 ID 的 Client Key mutation 请求。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ClientKeyRevisionRequest {
|
||||
pub struct ClientKeyMutationRequest {
|
||||
id: String,
|
||||
expected_config_revision: u64,
|
||||
}
|
||||
|
||||
/// 读取一次完整 Key 的 ID query。
|
||||
@@ -233,20 +221,15 @@ impl ClientKeyIdQuery {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientKeyRevisionRequest {
|
||||
/// 校验请求并取出 ID 与 revision。
|
||||
pub fn into_parts(self) -> Result<(String, u64), WireValidationError> {
|
||||
impl ClientKeyMutationRequest {
|
||||
/// 校验请求并取出 ID。
|
||||
pub fn into_id(self) -> Result<String, WireValidationError> {
|
||||
validate_required_text(&self.id, "id")?;
|
||||
validate_revision(self.expected_config_revision)?;
|
||||
Ok((self.id, self.expected_config_revision))
|
||||
Ok(self.id)
|
||||
}
|
||||
|
||||
fn into_command_parts(self) -> Result<(ClientApiKeyId, Revision), WireValidationError> {
|
||||
let (id, expected_config_revision) = self.into_parts()?;
|
||||
Ok((
|
||||
client_key_id(id, "clientKeyMutationNotFound")?,
|
||||
revision(expected_config_revision)?,
|
||||
))
|
||||
fn into_domain_id(self) -> Result<ClientApiKeyId, WireValidationError> {
|
||||
client_key_id(self.into_id()?, "clientKeyMutationNotFound")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +240,9 @@ pub struct ClientKeyView {
|
||||
id: String,
|
||||
name: String,
|
||||
label: Option<String>,
|
||||
provider_kind: String,
|
||||
routing_scope: &'static str,
|
||||
groups: Vec<ClientKeyGroupView>,
|
||||
provider_kinds: Vec<String>,
|
||||
prefix: String,
|
||||
enabled: bool,
|
||||
max_concurrency: u64,
|
||||
@@ -267,13 +252,42 @@ pub struct ClientKeyView {
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyGroupView {
|
||||
id: String,
|
||||
name: String,
|
||||
color: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl From<ClientKeyRecord> for ClientKeyView {
|
||||
fn from(record: ClientKeyRecord) -> Self {
|
||||
let routing_scope = if record.groups.is_empty() {
|
||||
"all"
|
||||
} else {
|
||||
"groups"
|
||||
};
|
||||
Self {
|
||||
id: record.id.to_string(),
|
||||
name: record.name,
|
||||
label: record.label,
|
||||
provider_kind: record.provider_kind.to_string(),
|
||||
routing_scope,
|
||||
groups: record
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|group| ClientKeyGroupView {
|
||||
id: group.id.to_string(),
|
||||
name: group.name,
|
||||
color: group.color.as_str().to_owned(),
|
||||
enabled: group.enabled,
|
||||
})
|
||||
.collect(),
|
||||
provider_kinds: record
|
||||
.provider_kinds
|
||||
.into_iter()
|
||||
.map(|provider| provider.to_string())
|
||||
.collect(),
|
||||
prefix: record.prefix,
|
||||
enabled: record.enabled,
|
||||
max_concurrency: record.limits.max_concurrency,
|
||||
@@ -289,7 +303,6 @@ impl From<ClientKeyRecord> for ClientKeyView {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientKeyListData {
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
@@ -298,14 +311,8 @@ pub struct ClientKeyListData {
|
||||
impl ClientKeyListData {
|
||||
/// 构造 Client Key 列表响应。
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
config_revision: u64,
|
||||
items: Vec<ClientKeyView>,
|
||||
next_cursor: Option<String>,
|
||||
total: u64,
|
||||
) -> Self {
|
||||
pub fn new(items: Vec<ClientKeyView>, next_cursor: Option<String>, total: u64) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
items,
|
||||
next_cursor,
|
||||
total,
|
||||
@@ -325,7 +332,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
.map(encode_client_key_cursor)
|
||||
.transpose()?;
|
||||
Ok(Self::new(
|
||||
page.config_revision.get(),
|
||||
page.items.into_iter().map(Into::into).collect(),
|
||||
next_cursor,
|
||||
page.total,
|
||||
@@ -337,7 +343,6 @@ impl TryFrom<ClientKeyPage> for ClientKeyListData {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
prefix: String,
|
||||
plaintext_key: String,
|
||||
@@ -346,9 +351,8 @@ pub struct CreatedClientKeyData {
|
||||
impl CreatedClientKeyData {
|
||||
/// 构造一次性创建响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
pub fn new(id: String, prefix: String, plaintext_key: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
prefix,
|
||||
plaintext_key,
|
||||
@@ -359,7 +363,6 @@ impl CreatedClientKeyData {
|
||||
impl From<CreatedClientKey> for CreatedClientKeyData {
|
||||
fn from(created: CreatedClientKey) -> Self {
|
||||
Self::new(
|
||||
created.config_revision.get(),
|
||||
created.secret.record.id.to_string(),
|
||||
created.secret.record.prefix.clone(),
|
||||
created.secret.expose_for_response().to_owned(),
|
||||
@@ -371,7 +374,6 @@ impl fmt::Debug for CreatedClientKeyData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("CreatedClientKeyData")
|
||||
.field("config_revision", &self.config_revision)
|
||||
.field("id", &self.id)
|
||||
.field("prefix", &self.prefix)
|
||||
.field("plaintext_key", &"[REDACTED]")
|
||||
@@ -417,24 +419,20 @@ impl fmt::Debug for RevealedClientKeyData {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MutatedClientKeyData {
|
||||
config_revision: u64,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl MutatedClientKeyData {
|
||||
/// 构造 mutation 响应。
|
||||
#[must_use]
|
||||
pub fn new(config_revision: u64, id: String) -> Self {
|
||||
Self {
|
||||
config_revision,
|
||||
id,
|
||||
}
|
||||
pub fn new(id: String) -> Self {
|
||||
Self { id }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientKeyMutation> for MutatedClientKeyData {
|
||||
fn from(mutation: ClientKeyMutation) -> Self {
|
||||
Self::new(mutation.config_revision.get(), mutation.id.to_string())
|
||||
Self::new(mutation.id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,18 +580,6 @@ fn wire_cursor(cursor: ClientKeyCursor) -> Result<ClientKeyCursorData, WireValid
|
||||
Ok(cursor)
|
||||
}
|
||||
|
||||
fn validate_revision(value: u64) -> Result<(), WireValidationError> {
|
||||
if value == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, WireValidationError> {
|
||||
validate_revision(value)?;
|
||||
Revision::new(value).map_err(|_| WireValidationError::new("expectedConfigRevision"))
|
||||
}
|
||||
|
||||
fn client_key_id(
|
||||
value: String,
|
||||
field: &'static str,
|
||||
@@ -615,21 +601,20 @@ fn validate_required_text(value: &str, field: &'static str) -> Result<(), WireVa
|
||||
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_group_ids(values: Vec<String>) -> Result<Vec<AccountGroupId>, WireValidationError> {
|
||||
if values.len() > 1000
|
||||
|| values
|
||||
.iter()
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len()
|
||||
!= values.len()
|
||||
{
|
||||
return Err(WireValidationError::new("groupIds"));
|
||||
}
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_optional_text(
|
||||
@@ -768,23 +753,19 @@ where
|
||||
async fn disable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
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 {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: false,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: false },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -793,23 +774,19 @@ where
|
||||
async fn enable_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
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 {
|
||||
expected_config_revision,
|
||||
id,
|
||||
enabled: true,
|
||||
},
|
||||
SetClientKeyEnabled { id, enabled: true },
|
||||
)
|
||||
.await,
|
||||
)
|
||||
@@ -818,23 +795,17 @@ where
|
||||
async fn delete_client_key<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Json(payload): Json<ClientKeyRevisionRequest>,
|
||||
Json(payload): Json<ClientKeyMutationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (id, expected_config_revision) = payload.into_command_parts().map_err(map_wire_error)?;
|
||||
let id = payload.into_domain_id().map_err(map_wire_error)?;
|
||||
mutation_response(
|
||||
state
|
||||
.admin_services()
|
||||
.client_keys()
|
||||
.delete(
|
||||
&auth.context().mutation_context(),
|
||||
DeleteClientKey {
|
||||
expected_config_revision,
|
||||
id,
|
||||
},
|
||||
)
|
||||
.delete(&auth.context().mutation_context(), DeleteClientKey { id })
|
||||
.await,
|
||||
)
|
||||
}
|
||||
@@ -854,9 +825,6 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
"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"),
|
||||
"expectedConfigRevision" => {
|
||||
AdminError::bad_request("expectedConfigRevision must be positive")
|
||||
}
|
||||
_ => AdminError::bad_request("Invalid client API key request"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backups;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod presenter;
|
||||
pub mod settings;
|
||||
pub mod system;
|
||||
pub mod wire;
|
||||
@@ -27,8 +30,10 @@ where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.merge(account_groups::router::<S>())
|
||||
.merge(accounts::router::<S>())
|
||||
.merge(auth::router::<S>())
|
||||
.merge(backups::router::<S>())
|
||||
.merge(client_keys::router::<S>())
|
||||
.merge(observability::router::<S>())
|
||||
.merge(settings::router::<S>())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
//! Dashboard、用量与诊断查询的 wire 映射和固定路由。
|
||||
|
||||
use std::{collections::BTreeMap, num::NonZeroU32};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
routing::get,
|
||||
};
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use gateway_admin::model::{PageSize as DomainPageSize, observability as domain};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminResponse, AdminSessionState, PageMeta,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
mod presenter;
|
||||
mod query;
|
||||
mod routes;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use presenter::*;
|
||||
pub use query::*;
|
||||
pub use routes::*;
|
||||
pub use wire::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,453 @@
|
||||
//! 查询 wire 类型、校验与领域命令映射。
|
||||
|
||||
use super::*;
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
/// 观测列表默认页大小。
|
||||
pub const DEFAULT_PAGE_SIZE: u16 = 50;
|
||||
/// 观测列表允许的最大页大小。
|
||||
pub const MAX_PAGE_SIZE: u16 = 100;
|
||||
/// 游标编码在 HTTP 层解码前允许的最大长度。
|
||||
pub const MAX_CURSOR_BYTES: usize = 1024;
|
||||
|
||||
/// Dashboard 查询参数。
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DashboardQuery {
|
||||
pub kind: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
|
||||
impl DashboardQuery {
|
||||
/// 解析 dashboard 趋势类型。
|
||||
pub fn trend_kind(&self) -> Result<TrendKind, WireValidationError> {
|
||||
TrendKind::parse(self.kind.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 逻辑请求列表、汇总和洞察查询参数。
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub cursor: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
pub attempt_index: Option<i64>,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
|
||||
impl UsageQuery {
|
||||
/// 校验分页字段,不改变仓储层的分页 owner。
|
||||
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let page = self.page.unwrap_or(1);
|
||||
if page == 0 {
|
||||
return Err(WireValidationError::new("page"));
|
||||
}
|
||||
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
if page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("pageSize"));
|
||||
}
|
||||
Ok((page, page_size))
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情查询参数。
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DetailQuery {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
impl DetailQuery {
|
||||
/// 校验详情 ID,错误不回显输入值。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_text(&self.id, "id")
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断聚合查询参数。
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct DiagnosticsQuery {
|
||||
pub dimension: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
impl DiagnosticsQuery {
|
||||
/// 解析诊断维度。
|
||||
pub fn dimension(&self) -> Result<DiagnosticDimension, WireValidationError> {
|
||||
DiagnosticDimension::parse(self.dimension.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// 运维错误查询参数。
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct OpsQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u16>,
|
||||
pub cursor: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub request_id: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub client_status_code: Option<i64>,
|
||||
pub upstream_status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
pub attempt_index: Option<i64>,
|
||||
pub failure_class: Option<String>,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub start_time: Option<String>,
|
||||
pub end_time: Option<String>,
|
||||
}
|
||||
|
||||
impl OpsQuery {
|
||||
/// 校验分页字段。
|
||||
pub fn validate_page(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let page = self.page.unwrap_or(1);
|
||||
if page == 0 {
|
||||
return Err(WireValidationError::new("page"));
|
||||
}
|
||||
let page_size = self.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
|
||||
if page_size == 0 || page_size > MAX_PAGE_SIZE {
|
||||
return Err(WireValidationError::new("pageSize"));
|
||||
}
|
||||
Ok((page, page_size))
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Dashboard 趋势类型。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TrendKind {
|
||||
Usage,
|
||||
Latency,
|
||||
Errors,
|
||||
}
|
||||
|
||||
impl TrendKind {
|
||||
/// 从 query 值解析趋势类型。
|
||||
pub fn parse(value: Option<&str>) -> Result<Self, WireValidationError> {
|
||||
match trimmed(value) {
|
||||
None | Some("usage") => Ok(Self::Usage),
|
||||
Some("latency") => Ok(Self::Latency),
|
||||
Some("errors") => Ok(Self::Errors),
|
||||
Some(_) => Err(WireValidationError::new("kind")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 诊断聚合维度。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiagnosticDimension {
|
||||
Model,
|
||||
Account,
|
||||
ApiKey,
|
||||
Provider,
|
||||
Transport,
|
||||
Failure,
|
||||
Status,
|
||||
}
|
||||
|
||||
impl DiagnosticDimension {
|
||||
/// 从 query 值解析诊断维度。
|
||||
pub fn parse(value: Option<&str>) -> Result<Self, WireValidationError> {
|
||||
match trimmed(value) {
|
||||
None | Some("model") => Ok(Self::Model),
|
||||
Some("account") => Ok(Self::Account),
|
||||
Some("apiKey" | "api_key") => Ok(Self::ApiKey),
|
||||
Some("provider") => Ok(Self::Provider),
|
||||
Some("transport") => Ok(Self::Transport),
|
||||
Some("failureClass" | "failure_class") => Ok(Self::Failure),
|
||||
Some("status") => Ok(Self::Status),
|
||||
Some(_) => Err(WireValidationError::new("dimension")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回终态响应中的稳定维度名称。
|
||||
#[must_use]
|
||||
pub const fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Model => "model",
|
||||
Self::Account => "account",
|
||||
Self::ApiKey => "apiKey",
|
||||
Self::Provider => "provider",
|
||||
Self::Transport => "transport",
|
||||
Self::Failure => "failureClass",
|
||||
Self::Status => "status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 RFC3339 时间;错误不回显原始值。
|
||||
pub fn parse_datetime(value: Option<&str>) -> Result<Option<DateTime<Utc>>, WireValidationError> {
|
||||
let Some(value) = trimmed(value) else {
|
||||
return Ok(None);
|
||||
};
|
||||
DateTime::parse_from_rfc3339(value)
|
||||
.map(|value| Some(value.with_timezone(&Utc)))
|
||||
.map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
/// 解析 HTTP 状态码。
|
||||
pub fn parse_status(value: Option<i64>) -> Result<Option<u16>, WireValidationError> {
|
||||
value
|
||||
.map(|value| {
|
||||
u16::try_from(value)
|
||||
.ok()
|
||||
.filter(|value| (100..=599).contains(value))
|
||||
.ok_or_else(|| WireValidationError::new("statusCode"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// 解析尝试序号。
|
||||
pub fn parse_attempt_index(value: Option<i64>) -> Result<Option<u32>, WireValidationError> {
|
||||
value
|
||||
.map(|value| {
|
||||
u32::try_from(value)
|
||||
.ok()
|
||||
.filter(|value| *value > 0 && *value <= i32::MAX as u32)
|
||||
.ok_or_else(|| WireValidationError::new("attemptIndex"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireValidationError> {
|
||||
if value.trim().is_empty() {
|
||||
Err(WireValidationError::new(field))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_cursor(value: Option<&str>) -> Result<(), WireValidationError> {
|
||||
if value.is_some_and(|value| value.is_empty() || value.len() > MAX_CURSOR_BYTES) {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn trimmed(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn domain_trend_kind(kind: TrendKind) -> domain::TrendKind {
|
||||
match kind {
|
||||
TrendKind::Usage => domain::TrendKind::Usage,
|
||||
TrendKind::Latency => domain::TrendKind::Latency,
|
||||
TrendKind::Errors => domain::TrendKind::Errors,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn domain_diagnostic_dimension(
|
||||
dimension: DiagnosticDimension,
|
||||
) -> domain::DiagnosticDimension {
|
||||
match dimension {
|
||||
DiagnosticDimension::Model => domain::DiagnosticDimension::Model,
|
||||
DiagnosticDimension::Account => domain::DiagnosticDimension::Account,
|
||||
DiagnosticDimension::ApiKey => domain::DiagnosticDimension::ApiKey,
|
||||
DiagnosticDimension::Provider => domain::DiagnosticDimension::Provider,
|
||||
DiagnosticDimension::Transport => domain::DiagnosticDimension::Transport,
|
||||
DiagnosticDimension::Failure => domain::DiagnosticDimension::Failure,
|
||||
DiagnosticDimension::Status => domain::DiagnosticDimension::Status,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_outcome(
|
||||
value: Option<String>,
|
||||
) -> Result<Option<domain::RequestOutcome>, WireValidationError> {
|
||||
non_empty(value)
|
||||
.map(|value| {
|
||||
domain::RequestOutcome::new(value).map_err(|_| WireValidationError::new("outcome"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn usage_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<domain::TimeRange, WireValidationError> {
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start = parse_datetime(start)?.unwrap_or(end - Duration::days(7));
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
pub(crate) fn dashboard_today_range(
|
||||
start: Option<&str>,
|
||||
end: Option<&str>,
|
||||
) -> Result<domain::TimeRange, WireValidationError> {
|
||||
let end = parse_datetime(end)?.unwrap_or_else(Utc::now);
|
||||
let start = parse_datetime(start)?.unwrap_or_else(|| domain::china_day_start(end));
|
||||
domain::TimeRange::new(start, end).map_err(|_| WireValidationError::new("timeRange"))
|
||||
}
|
||||
|
||||
pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, WireValidationError> {
|
||||
let outcome = query.outcome.clone().or_else(|| {
|
||||
non_empty(query.kind.clone()).filter(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"running" | "succeeded" | "failed" | "cancelled" | "incomplete"
|
||||
)
|
||||
})
|
||||
});
|
||||
Ok(domain::UsageFilter {
|
||||
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
|
||||
request_id: non_empty(query.request_id.clone()),
|
||||
provider_account_ref: non_empty(query.account_id.clone()),
|
||||
operation: non_empty(query.route.clone()),
|
||||
provider_kind: non_empty(query.provider.clone()),
|
||||
model: non_empty(query.model.clone()),
|
||||
outcome: request_outcome(outcome)?,
|
||||
status_code: parse_status(query.status_code)?,
|
||||
transport: non_empty(query.transport.clone()),
|
||||
attempt_index: parse_attempt_index(query.attempt_index)?,
|
||||
response_id: non_empty(query.response_id.clone()),
|
||||
upstream_request_id: non_empty(query.upstream_request_id.clone()),
|
||||
search: non_empty(query.search.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn usage_command(
|
||||
query: &UsageQuery,
|
||||
) -> Result<(domain::UsageQuery, u32, u16), WireValidationError> {
|
||||
let (page, page_size) = query.validate_page()?;
|
||||
query.validate_cursor()?;
|
||||
let page_number = NonZeroU32::new(page)
|
||||
.map(domain::PageNumber::new)
|
||||
.ok_or_else(|| WireValidationError::new("page"))?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
Ok((
|
||||
domain::UsageQuery {
|
||||
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
|
||||
filter: usage_filter(query)?,
|
||||
cursor: decode_observability_cursor(query.cursor.as_deref())?,
|
||||
page: page_number,
|
||||
page_size: page_size_value,
|
||||
},
|
||||
page,
|
||||
page_size,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn ops_command(
|
||||
query: &OpsQuery,
|
||||
) -> Result<(domain::OpsErrorQuery, u32, u16), WireValidationError> {
|
||||
let (page, page_size) = query.validate_page()?;
|
||||
query.validate_cursor()?;
|
||||
let page_number = NonZeroU32::new(page)
|
||||
.map(domain::PageNumber::new)
|
||||
.ok_or_else(|| WireValidationError::new("page"))?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
let status_code = parse_status(
|
||||
query
|
||||
.upstream_status_code
|
||||
.or(query.client_status_code)
|
||||
.or(query.status_code),
|
||||
)?;
|
||||
Ok((
|
||||
domain::OpsErrorQuery {
|
||||
range: usage_range(query.start_time.as_deref(), query.end_time.as_deref())?,
|
||||
filter: domain::OpsErrorFilter {
|
||||
client_api_key_ref: non_empty(query.client_api_key_id.clone()),
|
||||
request_id: non_empty(query.request_id.clone()),
|
||||
provider_kind: non_empty(query.provider.clone()),
|
||||
provider_account_ref: non_empty(query.account_id.clone()),
|
||||
operation: non_empty(query.route.clone()).or_else(|| non_empty(query.kind.clone())),
|
||||
model: non_empty(query.model.clone()),
|
||||
transport: non_empty(query.transport.clone()),
|
||||
attempt_index: parse_attempt_index(query.attempt_index)?,
|
||||
response_id: non_empty(query.response_id.clone()),
|
||||
upstream_request_id: non_empty(query.upstream_request_id.clone()),
|
||||
failure_kind: non_empty(query.failure_class.clone()),
|
||||
status_code,
|
||||
search: non_empty(query.search.clone()),
|
||||
},
|
||||
cursor: decode_observability_cursor(query.cursor.as_deref())?,
|
||||
page: page_number,
|
||||
page_size: page_size_value,
|
||||
},
|
||||
page,
|
||||
page_size,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn decode_observability_cursor(
|
||||
value: Option<&str>,
|
||||
) -> Result<Option<domain::ObservabilityCursor>, WireValidationError> {
|
||||
value
|
||||
.map(|encoded| {
|
||||
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 wire: CursorWire =
|
||||
serde_json::from_slice(&bytes).map_err(|_| WireValidationError::new("cursor"))?;
|
||||
if wire.stable_id.trim().is_empty() {
|
||||
return Err(WireValidationError::new("cursor"));
|
||||
}
|
||||
Ok(domain::ObservabilityCursor {
|
||||
observed_at: wire.observed_at,
|
||||
stable_id: wire.stable_id,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(crate) fn encode_observability_cursor(
|
||||
cursor: &domain::ObservabilityCursor,
|
||||
) -> Result<String, WireValidationError> {
|
||||
let bytes = serde_json::to_vec(&CursorWire {
|
||||
observed_at: cursor.observed_at,
|
||||
stable_id: cursor.stable_id.clone(),
|
||||
})
|
||||
.map_err(|_| WireValidationError::new("cursor"))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub(crate) fn non_empty(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 固定 `/api/admin` 观测路由与 handler。
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/dashboard/summary", get(dashboard_summary::<S>))
|
||||
.route("/api/admin/dashboard/trend", get(dashboard_trend::<S>))
|
||||
.route("/api/admin/usage/records", get(usage_records::<S>))
|
||||
.route(
|
||||
"/api/admin/usage/records/detail",
|
||||
get(usage_record_detail::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/usage/records/summary",
|
||||
get(usage_records_summary::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/usage/insights/overview",
|
||||
get(usage_insights_overview::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/usage/insights/diagnostics",
|
||||
get(usage_insights_diagnostics::<S>),
|
||||
)
|
||||
.route("/api/admin/operations/errors", get(ops_errors::<S>))
|
||||
}
|
||||
|
||||
pub(crate) async fn dashboard_summary<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
// 概览默认按中国时区当日统计,与单独趋势接口保持同一口径。
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.dashboard_summary(range, domain_trend_kind(kind))
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(dashboard_view(result, kind)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn dashboard_trend<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<DashboardQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let kind = query.trend_kind().map_err(map_wire_error)?;
|
||||
let range = dashboard_today_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.dashboard_trend(range, domain_trend_kind(kind))
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(trend_view(result, kind)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_records<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (command, page, page_size) = usage_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.usage_records(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = usage_page_view(result, page, page_size)
|
||||
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_record_detail<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<DetailQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
query.validate().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.usage_record_detail(query.id.trim())
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(usage_detail_view(result)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_records_summary<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = usage_filter(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.usage_summary(range, filter)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(usage_summary_view(result)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_insights_overview<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<UsageQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = usage_filter(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.usage_insights(range, filter)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(usage_insights_view(result)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn usage_insights_diagnostics<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<DiagnosticsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let dimension = query.dimension().map_err(map_wire_error)?;
|
||||
let range = usage_range(query.start_time.as_deref(), query.end_time.as_deref())
|
||||
.map_err(map_wire_error)?;
|
||||
let filter = domain::UsageFilter {
|
||||
provider_kind: non_empty(query.provider),
|
||||
model: non_empty(query.model),
|
||||
status_code: parse_status(query.status_code).map_err(map_wire_error)?,
|
||||
search: non_empty(query.search),
|
||||
..domain::UsageFilter::default()
|
||||
};
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.diagnostics(range, filter, domain_diagnostic_dimension(dimension))
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(diagnostics_view(result, dimension)),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn ops_errors<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
Query(query): Query<OpsQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (command, page, page_size) = ops_command(&query).map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.observability()
|
||||
.ops_errors(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ops_page_view(result, page, page_size)
|
||||
.map_err(|_| AdminError::internal("Failed to encode observability cursor"))?;
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
//! 响应 DTO 与固定 wire 形状。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 观测列表游标的稳定 wire 形状。
|
||||
#[derive(Clone, Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CursorWire {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
/// 观测列表响应数据。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PageData<T> {
|
||||
pub items: Vec<T>,
|
||||
pub page: PageMeta,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Token 详情展示。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TokenDetailsView {
|
||||
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 total_tokens: Option<u64>,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens_display: String,
|
||||
pub cache_write_tokens_display: String,
|
||||
pub reasoning_tokens_display: String,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens_display: String,
|
||||
pub total_tokens_display: String,
|
||||
}
|
||||
|
||||
/// 按货币展示的成本。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CostView {
|
||||
pub currency: String,
|
||||
pub estimated_amount: String,
|
||||
}
|
||||
|
||||
/// 成本覆盖状态计数。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CostCoverageView {
|
||||
pub known: u64,
|
||||
pub partial: u64,
|
||||
pub unknown: u64,
|
||||
pub not_billable: u64,
|
||||
}
|
||||
|
||||
/// Provider 受控价格规则生成的单次请求费用明细展示。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BillingView {
|
||||
pub input_amount_display: String,
|
||||
pub output_amount_display: String,
|
||||
pub cache_read_amount_display: String,
|
||||
pub cache_write_amount_display: String,
|
||||
pub standard_amount_display: String,
|
||||
pub total_amount_display: String,
|
||||
pub input_price_display: String,
|
||||
pub output_price_display: String,
|
||||
pub cache_read_price_display: String,
|
||||
pub cache_write_price_display: String,
|
||||
pub service_tier_display: String,
|
||||
pub multiplier_display: String,
|
||||
}
|
||||
|
||||
/// 单条逻辑请求展示。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordView {
|
||||
pub id: String,
|
||||
pub request_id: String,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub routing_scope: String,
|
||||
pub routing_group_refs: Vec<String>,
|
||||
pub routing_group_names_snapshot: Vec<String>,
|
||||
pub kind: String,
|
||||
pub provider: Option<String>,
|
||||
pub authentication_kind: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub route: String,
|
||||
pub model: String,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
pub protocol: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_status_code: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_status_code: Option<i64>,
|
||||
pub websocket_pool: Option<WebSocketPoolMetadataView>,
|
||||
pub image_generation_requested: bool,
|
||||
pub image_generation_succeeded: Option<bool>,
|
||||
pub latency_details: UsageLatencyDetailsView,
|
||||
pub attempt_index: Option<i64>,
|
||||
pub attempt_count: u64,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub first_token_ms: Option<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 message: String,
|
||||
/// Provider 安全观测;Core 字段由顶层字段提供,不再复制进 metadata。
|
||||
pub metadata: BTreeMap<String, Value>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_at_display: String,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub compact: Option<bool>,
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub token_details: TokenDetailsView,
|
||||
pub billing: Option<BillingView>,
|
||||
pub costs: Vec<CostView>,
|
||||
pub cost_coverage: CostCoverageView,
|
||||
pub first_token_latency_ms: Option<u64>,
|
||||
pub first_token_latency_ms_display: String,
|
||||
pub latency_ms_display: String,
|
||||
pub logical_outcome: String,
|
||||
}
|
||||
|
||||
/// WebSocket 池决策的稳定形状;与 Provider 选择逻辑无关。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebSocketPoolMetadataView {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// 逻辑请求在上游和输出阶段测得的时延事实。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageLatencyDetailsView {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub transport_decision_wait_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ws_connect_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub upstream_headers_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_event_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_reasoning_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_text_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub first_token_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub openai_processing_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// 单次上游尝试展示。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageAttemptView {
|
||||
pub id: String,
|
||||
pub attempt_index: u32,
|
||||
pub trigger: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub transport: String,
|
||||
pub send_state: String,
|
||||
pub outcome: String,
|
||||
pub downstream_committed: bool,
|
||||
pub status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub failure_class: Option<String>,
|
||||
pub cost_estimate_status: String,
|
||||
pub estimated_cost_amount: Option<String>,
|
||||
pub estimated_cost_currency: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub first_token_ms: Option<u64>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub credential_name: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub authentication_kind: Option<String>,
|
||||
pub started_at: DateTime<Utc>,
|
||||
pub completed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// 逻辑请求详情与其尝试列表。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordDetailView {
|
||||
#[serde(flatten)]
|
||||
pub request: UsageRecordView,
|
||||
pub attempts: Vec<UsageAttemptView>,
|
||||
/// 尝试列表是否完整;best-effort 下恒为 false。
|
||||
pub attempts_complete: bool,
|
||||
}
|
||||
|
||||
/// Dashboard 趋势数据。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrendData {
|
||||
pub kind: TrendKind,
|
||||
pub points: Vec<TrendPointView>,
|
||||
pub summary: Vec<TrendSummaryView>,
|
||||
}
|
||||
|
||||
/// Dashboard 单个趋势桶。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrendPointView {
|
||||
pub time: String,
|
||||
pub bucket: DateTime<Utc>,
|
||||
pub label: String,
|
||||
pub requests: String,
|
||||
pub requests_value: u64,
|
||||
pub input_tokens: String,
|
||||
pub input_tokens_value: u64,
|
||||
pub output_tokens: String,
|
||||
pub output_tokens_value: u64,
|
||||
pub cached_tokens: String,
|
||||
pub cached_tokens_value: u64,
|
||||
pub cache_hit_rate_value: f64,
|
||||
pub tokens_value: u64,
|
||||
pub errors: String,
|
||||
pub errors_value: u64,
|
||||
pub latency: String,
|
||||
pub latency_value: Option<u64>,
|
||||
pub max_latency: String,
|
||||
pub max_latency_value: Option<u64>,
|
||||
pub min_latency: String,
|
||||
pub min_latency_value: Option<u64>,
|
||||
pub success_rate: String,
|
||||
pub success_rate_value: Option<f64>,
|
||||
}
|
||||
|
||||
/// Dashboard 趋势摘要。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrendSummaryView {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub ratio: Option<String>,
|
||||
}
|
||||
|
||||
/// Dashboard 卡片集合。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardCardsView {
|
||||
pub credentials: DashboardCredentialsCardView,
|
||||
pub traffic: DashboardTrafficCardView,
|
||||
pub tokens: DashboardTokensCardView,
|
||||
pub cache: DashboardCacheCardView,
|
||||
}
|
||||
|
||||
/// Dashboard 上游凭据卡片。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardCredentialsCardView {
|
||||
pub total: String,
|
||||
pub total_value: u64,
|
||||
pub available: String,
|
||||
pub available_value: u64,
|
||||
pub unavailable: String,
|
||||
pub unavailable_value: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 流量卡片。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardTrafficCardView {
|
||||
pub today_requests: String,
|
||||
pub today_requests_value: u64,
|
||||
pub yesterday_requests_value: u64,
|
||||
pub total_requests: String,
|
||||
}
|
||||
|
||||
/// Dashboard token 卡片。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardTokensCardView {
|
||||
pub today_tokens: String,
|
||||
pub today_tokens_value: u64,
|
||||
pub yesterday_tokens_value: u64,
|
||||
pub total_tokens: String,
|
||||
pub total_billing_amount_usd: String,
|
||||
}
|
||||
|
||||
/// Dashboard 缓存卡片。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardCacheCardView {
|
||||
pub today_hit_rate: String,
|
||||
pub today_hit_rate_value: Option<f64>,
|
||||
pub yesterday_hit_rate_value: Option<f64>,
|
||||
pub total_hit_rate: String,
|
||||
pub total_cached_tokens: String,
|
||||
pub average_first_token_latency_ms: String,
|
||||
}
|
||||
|
||||
/// 旧 Dashboard 账号概览卡片所需的 Provider 安全投影。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardAccountUsageView {
|
||||
pub id: String,
|
||||
pub provider: String,
|
||||
pub authentication_kind: String,
|
||||
pub email: String,
|
||||
pub plan_type: Option<String>,
|
||||
pub tokens: String,
|
||||
pub request_count: u64,
|
||||
pub request_buckets: Vec<DashboardAccountRequestBucketView>,
|
||||
pub quota_used_percent: Option<f64>,
|
||||
pub last_used: String,
|
||||
}
|
||||
|
||||
/// Dashboard 账号单小时请求数。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardAccountRequestBucketView {
|
||||
pub bucket_start: DateTime<Utc>,
|
||||
pub request_count: u64,
|
||||
}
|
||||
|
||||
/// Provider 账号池的持久事实汇总。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardPoolSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// 同 target 账号调度容量;Redis 未提供聚合事实时运行中槽位保持空值。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardCapacityInfoView {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub total_slots: u64,
|
||||
pub used_slots: Option<u64>,
|
||||
pub available_slots: Option<u64>,
|
||||
}
|
||||
|
||||
/// 逻辑请求指标展示。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestMetricsView {
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub caller_error_count: u64,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub cache_write_tokens: u64,
|
||||
pub reasoning_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
/// 上游尝试指标展示。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttemptMetricsView {
|
||||
pub attempt_count: u64,
|
||||
pub success_count: u64,
|
||||
pub failure_count: u64,
|
||||
pub cancelled_count: u64,
|
||||
pub incomplete_count: u64,
|
||||
pub rate_limited_count: u64,
|
||||
pub auth_failure_count: u64,
|
||||
pub provider5xx_count: u64,
|
||||
pub cost_coverage: CostCoverageView,
|
||||
pub costs: Vec<CostView>,
|
||||
}
|
||||
|
||||
/// 健康时间线单点。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthTimelinePointView {
|
||||
pub time: String,
|
||||
pub status: String,
|
||||
pub reliability_display: String,
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
}
|
||||
|
||||
/// Dashboard 健康时间线。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HealthTimelineView {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub reliability_display: String,
|
||||
pub status: String,
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub points: Vec<HealthTimelinePointView>,
|
||||
}
|
||||
|
||||
/// Dashboard 展示的实际 Provider 上游请求身份。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardWireProfileView {
|
||||
pub provider: String,
|
||||
pub product: String,
|
||||
pub version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub build: Option<String>,
|
||||
pub target: DashboardWireTargetView,
|
||||
pub user_agent: String,
|
||||
pub attributes: Vec<DashboardWireAttributeView>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub verified_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub release: Option<DashboardDesktopReleaseView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardWireAttributeView {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardWireTargetView {
|
||||
pub os_type: String,
|
||||
pub os_version: String,
|
||||
pub arch: String,
|
||||
pub terminal: String,
|
||||
}
|
||||
|
||||
/// 发布检查与启动画像分离;未检查时使用明确的 `unchecked` 状态。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardDesktopReleaseView {
|
||||
pub status: DashboardDesktopReleaseStatusView,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checked_at: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_build: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Dashboard 发布检查的稳定 wire 状态。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DashboardDesktopReleaseStatusView {
|
||||
Unchecked,
|
||||
Aligned,
|
||||
ReviewRequired,
|
||||
CheckFailed,
|
||||
}
|
||||
|
||||
impl From<domain::DesktopReleaseStatus> for DashboardDesktopReleaseStatusView {
|
||||
fn from(status: domain::DesktopReleaseStatus) -> Self {
|
||||
match status {
|
||||
domain::DesktopReleaseStatus::Unchecked => Self::Unchecked,
|
||||
domain::DesktopReleaseStatus::Current => Self::Aligned,
|
||||
domain::DesktopReleaseStatus::UpdateAvailable => Self::ReviewRequired,
|
||||
domain::DesktopReleaseStatus::Failed => Self::CheckFailed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dashboard 汇总响应数据。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DashboardDataView {
|
||||
pub cards: DashboardCardsView,
|
||||
pub trend: TrendData,
|
||||
pub health_timeline: HealthTimelineView,
|
||||
pub wire_profiles: Vec<DashboardWireProfileView>,
|
||||
pub account_usage: Vec<DashboardAccountUsageView>,
|
||||
pub usage_records: Vec<UsageRecordView>,
|
||||
pub pool_summary: DashboardPoolSummaryView,
|
||||
pub capacity_info: DashboardCapacityInfoView,
|
||||
pub rotation_strategy: String,
|
||||
}
|
||||
|
||||
/// 用量汇总响应数据。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageSummaryView {
|
||||
pub total_requests: String,
|
||||
pub input_tokens: String,
|
||||
pub output_tokens: String,
|
||||
pub cached_tokens: String,
|
||||
pub cache_write_tokens: String,
|
||||
pub total_tokens: String,
|
||||
pub average_latency_ms: String,
|
||||
pub logical_requests: RequestMetricsView,
|
||||
pub attempts: AttemptMetricsView,
|
||||
}
|
||||
|
||||
/// 洞察健康趋势点。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewHealthPointView {
|
||||
pub bucket: DateTime<Utc>,
|
||||
pub label: String,
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub error_rate: f64,
|
||||
}
|
||||
|
||||
/// 洞察健康摘要。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewHealthView {
|
||||
pub total_requests: u64,
|
||||
pub success_requests: u64,
|
||||
pub failed_requests: u64,
|
||||
pub cancelled_requests: u64,
|
||||
pub incomplete_requests: u64,
|
||||
pub caller_error_requests: u64,
|
||||
pub success_rate: f64,
|
||||
pub request_change_rate: Option<f64>,
|
||||
pub success_rate_change: Option<f64>,
|
||||
pub points: Vec<OverviewHealthPointView>,
|
||||
}
|
||||
|
||||
/// 洞察性能趋势点。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewPerformancePointView {
|
||||
pub bucket: DateTime<Utc>,
|
||||
pub label: String,
|
||||
pub latency_p50_ms: Option<f64>,
|
||||
pub latency_p95_ms: Option<f64>,
|
||||
pub latency_p99_ms: Option<f64>,
|
||||
pub first_token_p50_ms: Option<f64>,
|
||||
pub first_token_p95_ms: Option<f64>,
|
||||
pub first_token_p99_ms: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察性能摘要。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewPerformanceView {
|
||||
pub latency_p50_ms: Option<f64>,
|
||||
pub latency_p95_ms: Option<f64>,
|
||||
pub latency_p99_ms: Option<f64>,
|
||||
pub first_token_p50_ms: Option<f64>,
|
||||
pub first_token_p95_ms: Option<f64>,
|
||||
pub first_token_p99_ms: Option<f64>,
|
||||
pub latency_coverage: f64,
|
||||
pub first_token_coverage: f64,
|
||||
pub points: Vec<OverviewPerformancePointView>,
|
||||
}
|
||||
|
||||
/// 洞察成本趋势点。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewCostPointView {
|
||||
pub bucket: DateTime<Utc>,
|
||||
pub label: String,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub estimated_cost: Option<String>,
|
||||
pub standard_cost: Option<String>,
|
||||
pub cached_token_rate: f64,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
}
|
||||
|
||||
/// 洞察成本摘要。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OverviewCostView {
|
||||
pub estimated_cost: Option<String>,
|
||||
pub standard_cost: Option<String>,
|
||||
pub cost_per_request: Option<String>,
|
||||
pub tokens_per_request: f64,
|
||||
pub cached_token_rate: f64,
|
||||
pub cache_hit_request_rate: Option<f64>,
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cached_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
pub points: Vec<OverviewCostPointView>,
|
||||
pub costs: Vec<CostView>,
|
||||
pub coverage: CostCoverageView,
|
||||
}
|
||||
|
||||
/// Provider 维度洞察摘要。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProviderOverviewView {
|
||||
pub provider: String,
|
||||
pub request_count: u64,
|
||||
pub attempt_count: u64,
|
||||
pub failure_count: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
/// 用量洞察总响应。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageInsightsOverviewView {
|
||||
pub granularity: String,
|
||||
pub health: OverviewHealthView,
|
||||
pub performance: OverviewPerformanceView,
|
||||
pub cost: OverviewCostView,
|
||||
pub attempts: AttemptMetricsView,
|
||||
pub providers: Vec<ProviderOverviewView>,
|
||||
}
|
||||
|
||||
/// 诊断聚合项目。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticItemView {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub request_count: u64,
|
||||
pub success_count: u64,
|
||||
pub error_count: u64,
|
||||
pub error_rate: f64,
|
||||
pub request_share: f64,
|
||||
pub average_latency_ms: Option<u64>,
|
||||
pub latency_p95_ms: Option<u64>,
|
||||
pub estimated_cost: Option<String>,
|
||||
pub attempt_count: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
/// 诊断聚合响应。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DiagnosticsView {
|
||||
pub dimension: String,
|
||||
pub items: Vec<DiagnosticItemView>,
|
||||
}
|
||||
|
||||
/// 运维错误项目。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpsErrorView {
|
||||
pub id: String,
|
||||
pub request_id: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub provider: Option<String>,
|
||||
pub authentication_kind: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub client_status_code: Option<i64>,
|
||||
pub upstream_status_code: Option<i64>,
|
||||
pub transport: Option<String>,
|
||||
pub attempt_index: Option<u32>,
|
||||
pub failure_class: String,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
pub metadata: OpsErrorMetadataView,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_at_display: String,
|
||||
}
|
||||
|
||||
/// 运维错误安全元数据;只保留可查询的标识,不回显秘密材料。
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpsErrorMetadataView {
|
||||
pub source: String,
|
||||
pub component: String,
|
||||
pub attempt_id: Option<String>,
|
||||
pub account_label: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Admin API 的展示层辅助:数值格式化。
|
||||
//!
|
||||
//! 按 docs/project-redundancy-boundary-audit.md BE-02 的目标,display 字段最终
|
||||
//! 迁移到前端;过渡期统一由本模块提供,避免 accounts/observability 各自维护。
|
||||
|
||||
use gateway_core::accounting::Decimal;
|
||||
|
||||
/// 千分位格式化。
|
||||
#[must_use]
|
||||
pub fn format_number(value: u64) -> String {
|
||||
let text = value.to_string();
|
||||
let mut output = String::with_capacity(text.len() + text.len() / 3);
|
||||
for (index, character) in text.chars().rev().enumerate() {
|
||||
if index > 0 && index % 3 == 0 {
|
||||
output.push(',');
|
||||
}
|
||||
output.push(character);
|
||||
}
|
||||
output.chars().rev().collect()
|
||||
}
|
||||
|
||||
/// 紧凑格式化:≥1000 时用 K/M/B/T/P 后缀,否则千分位。
|
||||
#[must_use]
|
||||
pub fn format_compact_number(value: u64) -> String {
|
||||
if value < 1_000 {
|
||||
return format_number(value);
|
||||
}
|
||||
for (suffix, threshold) in [
|
||||
("P", 1_000_000_000_000_000_u64),
|
||||
("T", 1_000_000_000_000_u64),
|
||||
("B", 1_000_000_000_u64),
|
||||
("M", 1_000_000_u64),
|
||||
("K", 1_000_u64),
|
||||
] {
|
||||
if value >= threshold {
|
||||
let scaled = value as f64 / threshold as f64;
|
||||
return format!("{scaled:.1}{suffix}").replace(".0", "");
|
||||
}
|
||||
}
|
||||
format_number(value)
|
||||
}
|
||||
|
||||
/// 货币展示格式化。USD 常规金额保留两位,小于 1 美元时最多保留四位。
|
||||
#[must_use]
|
||||
pub fn format_decimal_currency(amount: &str, currency: &str) -> String {
|
||||
if currency != "USD" {
|
||||
return format!("{currency} {amount}");
|
||||
}
|
||||
|
||||
const DECIMAL_SCALE: u128 = 10_000_000_000;
|
||||
let scaled = amount.parse::<Decimal>().unwrap_or_default().scaled();
|
||||
let precision = if scaled != 0 && scaled < DECIMAL_SCALE {
|
||||
4_u32
|
||||
} else {
|
||||
2_u32
|
||||
};
|
||||
let rounding_unit = 10_u128.pow(10 - precision);
|
||||
let rounded = (scaled + rounding_unit / 2) / rounding_unit;
|
||||
let display_scale = 10_u128.pow(precision);
|
||||
let whole = rounded / display_scale;
|
||||
let fraction = rounded % display_scale;
|
||||
let mut display = format!("{whole}.{fraction:0width$}", width = precision as usize);
|
||||
while display.ends_with('0')
|
||||
&& display
|
||||
.split_once('.')
|
||||
.is_some_and(|(_, fraction)| fraction.len() > 2)
|
||||
{
|
||||
display.pop();
|
||||
}
|
||||
format!("${display}")
|
||||
}
|
||||
@@ -10,12 +10,8 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy,
|
||||
RuntimeSettings,
|
||||
},
|
||||
use gateway_admin::model::settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
|
||||
};
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,7 +28,6 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -49,7 +44,6 @@ pub struct RuntimeSettingsView {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
pub expected_config_revision: u64,
|
||||
pub model_mappings: ModelMappings,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u64,
|
||||
@@ -64,9 +58,6 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
impl UpdateRuntimeSettingsRequest {
|
||||
/// 校验公共运行参数。
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.expected_config_revision == 0 {
|
||||
return Err(WireValidationError::new("expectedConfigRevision"));
|
||||
}
|
||||
validate_model_mappings(&self.model_mappings)?;
|
||||
for (value, field) in [
|
||||
(self.refresh_margin_seconds, "refreshMarginSeconds"),
|
||||
@@ -81,10 +72,7 @@ impl UpdateRuntimeSettingsRequest {
|
||||
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"
|
||||
) {
|
||||
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
|
||||
return Err(WireValidationError::new("rotationStrategy"));
|
||||
}
|
||||
Ok(())
|
||||
@@ -93,8 +81,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
fn into_command(self) -> Result<ReplaceRuntimeSettings, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ReplaceRuntimeSettings {
|
||||
expected_config_revision: Revision::new(self.expected_config_revision)
|
||||
.map_err(|_| WireValidationError::new("settingsRevisionOverflow"))?,
|
||||
model_mappings: domain_model_mappings(self.model_mappings)?,
|
||||
refresh_margin_seconds: self.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(self.refresh_concurrency)
|
||||
@@ -102,7 +88,7 @@ impl UpdateRuntimeSettingsRequest {
|
||||
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)
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
usage_retention_days: u32::try_from(self.usage_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
|
||||
@@ -117,13 +103,12 @@ impl UpdateRuntimeSettingsRequest {
|
||||
impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
fn from(settings: RuntimeSettings) -> Self {
|
||||
Self {
|
||||
config_revision: settings.config_revision.get(),
|
||||
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(),
|
||||
rotation_strategy: settings.rotation_strategy.as_str().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),
|
||||
@@ -331,25 +316,6 @@ fn wire_model_mappings(mappings: DomainModelMappings) -> ModelMappings {
|
||||
.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
|
||||
@@ -358,7 +324,6 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRevisionOverflow" => "expectedConfigRevision must be positive".to_owned(),
|
||||
"settingsRefreshConcurrencyOverflow" => "Invalid refreshConcurrency".to_owned(),
|
||||
"settingsMaxConcurrencyOverflow" => "Invalid maxConcurrentPerAccount".to_owned(),
|
||||
"settingsUsageRetentionOverflow" => "Invalid usageRetentionDays".to_owned(),
|
||||
|
||||
@@ -9,14 +9,14 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::http::{HeaderName, HeaderValue, Method, StatusCode};
|
||||
use axum::http::{HeaderName, HeaderValue, Method, Request, 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::{Any, CorsLayer};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
@@ -146,11 +146,17 @@ pub fn initialize(
|
||||
.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(Any)
|
||||
.allow_headers([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderName::from_static("x-api-key"),
|
||||
request_id_header.clone(),
|
||||
])
|
||||
.allow_credentials(true),
|
||||
);
|
||||
}
|
||||
@@ -160,10 +166,32 @@ pub fn initialize(
|
||||
Duration::from_secs(seconds),
|
||||
));
|
||||
}
|
||||
// Trace 必须在 SetRequestId 内侧,span 才能捕获本服务生成的 request_id;
|
||||
// 默认 DEBUG span 会被 info 日志过滤器丢弃,这里显式用 info_span。
|
||||
let trace_layer = TraceLayer::new_for_http().make_span_with({
|
||||
let request_id_header = request_id_header.clone();
|
||||
move |request: &Request<_>| {
|
||||
let request_id = request
|
||||
.headers()
|
||||
.get(&request_id_header)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
tracing::info_span!(
|
||||
"request",
|
||||
request_id = %request_id,
|
||||
method = %request.method().as_str(),
|
||||
uri = %request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(axum::http::uri::PathAndQuery::as_str)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let router = router
|
||||
.layer(PropagateRequestIdLayer::new(request_id_header.clone()))
|
||||
.layer(trace_layer)
|
||||
.layer(SetRequestIdLayer::new(request_id_header, MakeRequestUuid))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
Ok(ApiBundle { router })
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
body::Body,
|
||||
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{EngineError, UpstreamSendState},
|
||||
error::{GatewayError, GatewayErrorKind, ProviderErrorKind},
|
||||
engine::EngineError,
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -71,15 +72,6 @@ pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
|
||||
#[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 =>
|
||||
{
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request",
|
||||
)
|
||||
}
|
||||
EngineError::Provider(error) => GatewayError::from_provider(error),
|
||||
EngineError::Cancelled => {
|
||||
GatewayError::new(GatewayErrorKind::Cancelled, "request was cancelled")
|
||||
@@ -97,7 +89,9 @@ pub fn gateway_error_from_engine(error: &EngineError) -> GatewayError {
|
||||
| EngineError::ProviderMetadataMismatch
|
||||
| EngineError::ContinuationPinMismatch
|
||||
| EngineError::RequiredAccountMismatch
|
||||
| EngineError::AccountOutsideClientScope
|
||||
| EngineError::DownstreamCommitRequired
|
||||
| EngineError::NoActiveAttempt
|
||||
| EngineError::InvalidDeliveryState => {
|
||||
GatewayError::new(GatewayErrorKind::Internal, "gateway execution failed")
|
||||
}
|
||||
@@ -116,6 +110,33 @@ pub fn gateway_error_response(error: &GatewayError) -> Response {
|
||||
.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(
|
||||
@@ -152,6 +173,16 @@ pub const fn gateway_error_contract(
|
||||
"server_error",
|
||||
"no_available_provider",
|
||||
),
|
||||
GatewayErrorKind::AccountCapacityUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"account_capacity_unavailable",
|
||||
),
|
||||
GatewayErrorKind::ProviderInfrastructureUnavailable => (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
),
|
||||
GatewayErrorKind::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limit_error",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
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;
|
||||
@@ -19,29 +20,48 @@ use super::{
|
||||
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.";
|
||||
|
||||
/// `GET /v1/models`。
|
||||
pub(crate) async fn models(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||
#[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<_>>();
|
||||
let profiles = service.public_model_profiles(&client);
|
||||
|
||||
if !profiles.is_empty() {
|
||||
let models = profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, profile)| codex_model_json(profile, index))
|
||||
.collect::<Vec<_>>();
|
||||
return (StatusCode::OK, Json(json!({ "models": models }))).into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
@@ -169,6 +189,22 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
} 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,
|
||||
@@ -180,8 +216,8 @@ fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
"visibility": if presentation.hidden() { "hide" } else { "list" },
|
||||
"supported_in_api": true,
|
||||
"priority": index.saturating_add(1),
|
||||
"additional_speed_tiers": [],
|
||||
"service_tiers": [],
|
||||
"additional_speed_tiers": additional_speed_tiers,
|
||||
"service_tiers": service_tiers,
|
||||
"availability_nux": Value::Null,
|
||||
"upgrade": Value::Null,
|
||||
"base_instructions": CODEX_BASE_INSTRUCTIONS,
|
||||
|
||||
@@ -152,9 +152,6 @@ pub enum ResponseEncodeError {
|
||||
/// 协议原生流缺少可返回的终态 response object。
|
||||
#[error("responses wire stream has no terminal response")]
|
||||
MissingWireTerminal,
|
||||
/// 协议原生事件携带了不一致的响应身份。
|
||||
#[error("responses wire stream changed its response identity")]
|
||||
WireIdentityChanged,
|
||||
/// 终态 response object 无法序列化为完整 HTTP JSON 响应。
|
||||
#[error("responses wire terminal serialization failed")]
|
||||
Serialization,
|
||||
@@ -167,7 +164,7 @@ impl ResponseEncodeError {
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "server_error",
|
||||
code: "invalid_canonical_response",
|
||||
code: "invalid_upstream_response",
|
||||
message: "The gateway could not forward the upstream response.".to_owned(),
|
||||
param: None,
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{
|
||||
HeaderMap, HeaderValue, StatusCode,
|
||||
HeaderMap, HeaderName, HeaderValue, StatusCode,
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, USER_AGENT},
|
||||
},
|
||||
response::{IntoResponse, Response},
|
||||
@@ -25,8 +25,8 @@ use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{
|
||||
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
|
||||
protocol_error_response, runtime_unavailable_response,
|
||||
engine_error_response, gateway_error_contract, gateway_error_from_engine,
|
||||
gateway_error_response, protocol_error_response, runtime_unavailable_response,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -180,7 +180,7 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = gateway_error_response(&gateway_error_from_engine(&error));
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
@@ -206,7 +206,7 @@ pub async fn collect_execution_response(session: Box<dyn ExecutionSession>) -> R
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = gateway_error_response(&gateway_error_from_engine(&error));
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
@@ -237,9 +237,7 @@ fn encode_collected_events(
|
||||
) -> Result<Vec<u8>, BufferedResponseEncodeError> {
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
for event in events {
|
||||
encoder
|
||||
.push_sse(event)
|
||||
.map_err(BufferedResponseEncodeError::Canonical)?;
|
||||
encoder.push_sse(event);
|
||||
}
|
||||
let response = encoder
|
||||
.finish()
|
||||
@@ -253,23 +251,25 @@ fn json_body_response(encoded: Vec<u8>, response_headers: &[ProviderResponseHead
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
apply_safe_response_headers(response, response_headers)
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn apply_safe_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 {
|
||||
let Some(name) = super::safe_response_header_name(header.name()) else {
|
||||
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_str(header.value().as_str()) else {
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(axum::http::header::HeaderName::from_static(name), value);
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -295,7 +295,7 @@ pub async fn stream_execution_response(
|
||||
return response;
|
||||
}
|
||||
Err(error) => {
|
||||
let response = gateway_error_response(&gateway_error_from_engine(&error));
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
@@ -310,18 +310,7 @@ pub async fn stream_execution_response(
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let mut frames = Vec::new();
|
||||
for event in &first_events {
|
||||
match encoder.push_sse(event) {
|
||||
Ok(encoded) => frames.extend(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;
|
||||
}
|
||||
}
|
||||
frames.extend(encoder.push_sse(event));
|
||||
}
|
||||
if frames.is_empty() {
|
||||
let response = internal_gateway_response("gateway commit batch encoded no output");
|
||||
@@ -338,7 +327,7 @@ pub async fn stream_execution_response(
|
||||
.await
|
||||
{
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = gateway_error_response(&gateway_error_from_engine(&error));
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
let Some(session) = execution.into_session() else {
|
||||
@@ -371,7 +360,7 @@ fn event_stream_response(body: Body, response_headers: &[ProviderResponseHeader]
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
apply_safe_response_headers(response, response_headers)
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn internal_gateway_response(message: &'static str) -> Response {
|
||||
@@ -403,15 +392,10 @@ impl PendingExecution {
|
||||
}
|
||||
|
||||
async fn record_response_status(&mut self, response: Response) -> Response {
|
||||
let Some(session) = self.session.as_mut() else {
|
||||
return internal_gateway_response("gateway response session is unavailable");
|
||||
};
|
||||
if session
|
||||
.record_client_status(response.status().as_u16())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return internal_gateway_response("gateway client status could not be persisted");
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
let _ = session
|
||||
.record_client_status(response.status().as_u16())
|
||||
.await;
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -499,10 +483,16 @@ impl ResponsesStreamState {
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.is_finalized());
|
||||
self.finish_with_gateway_error(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway response ended without a terminal event",
|
||||
));
|
||||
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
|
||||
@@ -521,18 +511,11 @@ impl ResponsesStreamState {
|
||||
}
|
||||
|
||||
async fn push_event(&mut self, event: ProviderEvent) {
|
||||
match self.encoder.push_sse(&event) {
|
||||
Ok(frames) => {
|
||||
if self.encoder.is_completed() {
|
||||
self.finish_completed(frames).await;
|
||||
} else {
|
||||
self.pending.extend(frames);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.cancel_execution().await;
|
||||
self.finish_with_encode_error(error);
|
||||
}
|
||||
let frames = self.encoder.push_sse(&event);
|
||||
if self.encoder.is_completed() {
|
||||
self.finish_completed(frames).await;
|
||||
} else {
|
||||
self.pending.extend(frames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,20 +590,6 @@ impl ResponsesStreamState {
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
}
|
||||
|
||||
fn finish_with_encode_error(&mut self, error: ResponseEncodeError) {
|
||||
let protocol = error.protocol_body().error;
|
||||
self.pending
|
||||
.push_back(Bytes::from(response_failed_sse_event_with_id(
|
||||
self.encoder.response_id(),
|
||||
protocol.kind,
|
||||
protocol.code,
|
||||
&protocol.message,
|
||||
)));
|
||||
self.pending
|
||||
.push_back(Bytes::from_static(DONE_SSE_FRAME.as_bytes()));
|
||||
self.output_finished = true;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ResponsesStreamState {
|
||||
|
||||
@@ -17,13 +17,63 @@ pub use response::OpenAiResponsesEncoder;
|
||||
pub(crate) use websocket::responses_websocket;
|
||||
pub use websocket::{ResponseCreateFrameError, decode_response_create_with_context};
|
||||
|
||||
pub(super) fn safe_response_header_name(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"x-request-id" => Some("x-request-id"),
|
||||
"openai-model" => Some("openai-model"),
|
||||
"x-models-etag" => Some("x-models-etag"),
|
||||
"x-reasoning-included" => Some("x-reasoning-included"),
|
||||
"openai-processing-ms" => Some("openai-processing-ms"),
|
||||
_ => None,
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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,
|
||||
@@ -14,6 +15,7 @@ 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 {
|
||||
@@ -34,6 +36,7 @@ pub struct OpenAiRequestHeaders {
|
||||
responses_lite: Option<String>,
|
||||
memgen_request: Option<String>,
|
||||
subagent: Option<String>,
|
||||
passthrough_headers: Vec<Value>,
|
||||
}
|
||||
|
||||
impl OpenAiRequestHeaders {
|
||||
@@ -57,12 +60,8 @@ impl OpenAiRequestHeaders {
|
||||
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).filter(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"review" | "compact" | "memory_consolidation" | "collab_spawn"
|
||||
)
|
||||
}),
|
||||
subagent: header_string(headers, OPENAI_SUBAGENT_KEY),
|
||||
passthrough_headers: passthrough_headers(headers),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +107,12 @@ impl OpenAiRequestHeaders {
|
||||
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));
|
||||
}
|
||||
@@ -310,16 +315,26 @@ pub(super) fn decode_request_inner(
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
let value =
|
||||
serde_json::from_slice::<Value>(body).map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
let Value::Object(mut object) = value else {
|
||||
let Value::Object(object) = value else {
|
||||
return Err(RequestDecodeError::ExpectedObject);
|
||||
};
|
||||
// `use_websocket` 只影响本地 transport。无论值是否可识别,都不得进入上游 body。
|
||||
let use_websocket = object
|
||||
.remove("use_websocket")
|
||||
.and_then(|value| value.as_bool());
|
||||
decode_request_object(object, review, request_headers)
|
||||
}
|
||||
|
||||
let model = required_non_empty_string(&object, "model", "model")?.trim();
|
||||
if model.is_empty() {
|
||||
/// 解码已解析的顶层 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(),
|
||||
});
|
||||
@@ -336,7 +351,6 @@ pub(super) fn decode_request_inner(
|
||||
let continuation = object
|
||||
.get("previous_response_id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|response_id| !response_id.is_empty())
|
||||
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
|
||||
.unwrap_or(ContinuationIntent::None);
|
||||
|
||||
@@ -367,26 +381,107 @@ 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()));
|
||||
if !metadata.is_object() {
|
||||
*metadata = Value::Object(Map::new());
|
||||
}
|
||||
if let Some(metadata) = metadata.as_object_mut() {
|
||||
metadata.insert(
|
||||
OPENAI_SUBAGENT_KEY.to_owned(),
|
||||
Value::String(subagent.to_owned()),
|
||||
);
|
||||
}
|
||||
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(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.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"
|
||||
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent
|
||||
// 不能透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
|
||||
| "originator"
|
||||
| "user-agent"
|
||||
// 设备 attestation/integrity 头只能由官方客户端或网关自身生成,
|
||||
// 客户端注入的 x-oai-attestation / X-OAI-IS 不得透传上游。
|
||||
| "x-oai-attestation"
|
||||
| "x-oai-is"
|
||||
| "x-oai-is-update"
|
||||
)
|
||||
}
|
||||
|
||||
fn required_non_empty_string<'a>(
|
||||
object: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
|
||||
@@ -11,15 +11,14 @@ const OPENAI_PROTOCOL: &str = "openai";
|
||||
|
||||
/// OpenAI Responses wire 的转发器。
|
||||
///
|
||||
/// OpenAI Provider 与 xAI adapter 都必须交付 OpenAI wire。这里仅保留同一
|
||||
/// 响应的身份和终态校验,不再从 canonical facts 重建客户端 JSON;因此
|
||||
/// OpenAI 上游的未知字段、字段顺序和事件语义不会被 API 层二次解释。
|
||||
/// 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,
|
||||
canonical_completed: bool,
|
||||
}
|
||||
|
||||
impl OpenAiResponsesEncoder {
|
||||
@@ -30,53 +29,41 @@ impl OpenAiResponsesEncoder {
|
||||
response_id: None,
|
||||
wire_terminal: None,
|
||||
wire_failure: false,
|
||||
canonical_completed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 消费一个 Provider event,并返回 SSE frames。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 同一响应身份变化时返回错误。
|
||||
pub fn push_sse(&mut self, event: &ProviderEvent) -> Result<Vec<Bytes>, ResponseEncodeError> {
|
||||
self.observe_identity(event)?;
|
||||
pub fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
|
||||
self.observe_canonical_identity(event);
|
||||
let Some(wire) = openai_wire(event) else {
|
||||
return Ok(Vec::new());
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire_terminal(wire);
|
||||
self.observe_wire(wire);
|
||||
if let Some(raw_sse_frame) = wire.raw_sse_frame() {
|
||||
return Ok(vec![raw_sse_frame.clone()]);
|
||||
return vec![raw_sse_frame.clone()];
|
||||
}
|
||||
Ok(vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
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。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 同一响应身份变化时返回错误。
|
||||
pub fn push_websocket(
|
||||
&mut self,
|
||||
event: &ProviderEvent,
|
||||
) -> Result<Vec<String>, ResponseEncodeError> {
|
||||
self.observe_identity(event)?;
|
||||
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 Ok(Vec::new());
|
||||
return Vec::new();
|
||||
};
|
||||
self.observe_wire_terminal(wire);
|
||||
Ok(vec![wire.data().to_string()])
|
||||
self.observe_wire(wire);
|
||||
vec![wire.data().to_string()]
|
||||
}
|
||||
|
||||
/// 返回是否已经看到同一响应的 canonical 与 wire 终态。
|
||||
/// 返回是否已经看到客户端可见的 wire 终态。
|
||||
#[must_use]
|
||||
pub fn is_completed(&self) -> bool {
|
||||
self.wire_terminal.is_some() && self.canonical_completed
|
||||
self.wire_terminal.is_some()
|
||||
}
|
||||
|
||||
/// 返回是否已把 Provider 原生失败 event 交付给客户端。
|
||||
@@ -95,39 +82,31 @@ impl OpenAiResponsesEncoder {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// 缺少 wire 或 canonical 终态时返回错误。
|
||||
/// 缺少可转换为非流式响应的 wire 终态时返回错误。
|
||||
pub fn finish(self) -> Result<Value, ResponseEncodeError> {
|
||||
if self.canonical_completed {
|
||||
self.wire_terminal
|
||||
.ok_or(ResponseEncodeError::MissingWireTerminal)
|
||||
} else {
|
||||
Err(ResponseEncodeError::MissingWireTerminal)
|
||||
}
|
||||
self.wire_terminal
|
||||
.ok_or(ResponseEncodeError::MissingWireTerminal)
|
||||
}
|
||||
|
||||
fn observe_identity(&mut self, event: &ProviderEvent) -> Result<(), ResponseEncodeError> {
|
||||
fn observe_canonical_identity(&mut self, event: &ProviderEvent) {
|
||||
for fact in event.canonical_facts() {
|
||||
let metadata = match fact {
|
||||
GatewayEvent::Started(metadata) => metadata,
|
||||
GatewayEvent::Completed(metadata) => {
|
||||
self.canonical_completed = true;
|
||||
metadata
|
||||
}
|
||||
GatewayEvent::Started(metadata) | GatewayEvent::Completed(metadata) => metadata,
|
||||
_ => continue,
|
||||
};
|
||||
if self
|
||||
.response_id
|
||||
.as_deref()
|
||||
.is_some_and(|current| current != metadata.response_id())
|
||||
{
|
||||
return Err(ResponseEncodeError::WireIdentityChanged);
|
||||
}
|
||||
self.response_id = Some(metadata.response_id().to_owned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn observe_wire_terminal(&mut self, wire: &ProtocolWireEvent) {
|
||||
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));
|
||||
|
||||
@@ -9,7 +9,7 @@ use axum::{
|
||||
connect_info::ConnectInfo,
|
||||
ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade, close_code},
|
||||
},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, HeaderName, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
@@ -27,7 +27,6 @@ use crate::{
|
||||
openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{gateway_error_contract, gateway_error_from_engine, runtime_unavailable_response},
|
||||
router::MAX_CLIENT_REQUEST_BODY_BYTES,
|
||||
service::OpenAiService,
|
||||
},
|
||||
};
|
||||
@@ -102,8 +101,10 @@ impl ResponsesWebSocketAdapter {
|
||||
_connection_guard: connection_guard,
|
||||
};
|
||||
websocket
|
||||
.max_message_size(MAX_CLIENT_REQUEST_BODY_BYTES)
|
||||
.max_frame_size(MAX_CLIENT_REQUEST_BODY_BYTES)
|
||||
// 覆盖 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;
|
||||
})
|
||||
@@ -303,18 +304,7 @@ async fn forward_execution(
|
||||
if let Some(update) = event.take_session_update() {
|
||||
provider_state = Some(update);
|
||||
}
|
||||
match encoder.push_websocket(event) {
|
||||
Ok(messages) => first_messages.extend(messages),
|
||||
Err(error) => {
|
||||
return send_protocol_error(
|
||||
socket,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error.protocol_body(),
|
||||
&request_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
first_messages.extend(encoder.push_websocket(event));
|
||||
}
|
||||
if first_messages.is_empty() {
|
||||
let error = GatewayError::new(
|
||||
@@ -336,9 +326,7 @@ async fn forward_execution(
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
if let Err(error) = commit_connection_replay(replay, &encoder, provider_state.take()) {
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
commit_connection_replay(replay, &encoder, provider_state.take());
|
||||
if !send_text(
|
||||
socket,
|
||||
response_metadata_event(&request_id, &response_headers),
|
||||
@@ -378,18 +366,7 @@ async fn forward_execution(
|
||||
if let Some(update) = event.take_session_update() {
|
||||
provider_state = Some(update);
|
||||
}
|
||||
match encoder.push_websocket(event) {
|
||||
Ok(encoded) => messages.extend(encoded),
|
||||
Err(error) => {
|
||||
return send_protocol_error(
|
||||
socket,
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
error.protocol_body(),
|
||||
&request_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
messages.extend(encoder.push_websocket(event));
|
||||
}
|
||||
if encoder.is_completed() {
|
||||
if let Err(outcome) =
|
||||
@@ -397,11 +374,7 @@ async fn forward_execution(
|
||||
{
|
||||
return outcome;
|
||||
}
|
||||
if let Err(error) =
|
||||
commit_connection_replay(replay, &encoder, provider_state.take())
|
||||
{
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
commit_connection_replay(replay, &encoder, provider_state.take());
|
||||
if !send_messages(socket, messages).await {
|
||||
return ForwardOutcome::Disconnect;
|
||||
}
|
||||
@@ -413,9 +386,17 @@ async fn forward_execution(
|
||||
}
|
||||
}
|
||||
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 a terminal event",
|
||||
"gateway response ended without finalizing the execution",
|
||||
);
|
||||
return send_gateway_error(socket, &error, &request_id).await;
|
||||
}
|
||||
@@ -441,15 +422,10 @@ fn commit_connection_replay(
|
||||
replay: &mut ConnectionReplaySnapshot,
|
||||
encoder: &OpenAiResponsesEncoder,
|
||||
provider_state: Option<ProviderSessionState>,
|
||||
) -> Result<(), GatewayError> {
|
||||
let response_id = encoder.response_id().ok_or_else(|| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"gateway completed a response without an identity",
|
||||
)
|
||||
})?;
|
||||
replay.commit(response_id.to_owned(), provider_state);
|
||||
Ok(())
|
||||
) {
|
||||
if let Some(response_id) = encoder.response_id() {
|
||||
replay.commit(response_id.to_owned(), provider_state);
|
||||
}
|
||||
}
|
||||
|
||||
async fn confirm_completed_execution(
|
||||
@@ -549,9 +525,7 @@ fn decode_response_create_inner(
|
||||
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
|
||||
return Err(ResponseCreateFrameError::StreamingRequired);
|
||||
}
|
||||
let encoded = serde_json::to_vec(&Value::Object(body))
|
||||
.map_err(|_| ResponseCreateFrameError::InvalidJson)?;
|
||||
super::request::decode_request_inner(&encoded, false, request_headers)
|
||||
super::request::decode_request_object(body, false, request_headers)
|
||||
.map_err(ResponseCreateFrameError::Request)
|
||||
}
|
||||
|
||||
@@ -597,21 +571,24 @@ fn response_metadata_event(
|
||||
request_id: &str,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> String {
|
||||
let mut headers = response_headers
|
||||
.iter()
|
||||
.filter_map(|header| {
|
||||
super::safe_response_header_name(header.name()).map(|name| {
|
||||
(
|
||||
name.to_owned(),
|
||||
Value::String(header.value().as_str().to_owned()),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Map<String, Value>>();
|
||||
headers.insert(
|
||||
"x-request-id".to_owned(),
|
||||
Value::String(request_id.to_owned()),
|
||||
);
|
||||
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,
|
||||
|
||||
@@ -13,9 +13,6 @@ use super::{
|
||||
|
||||
use crate::ApiState;
|
||||
|
||||
/// OpenAI 请求正文上限。
|
||||
pub const MAX_CLIENT_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// 构造 OpenAI 客户端协议路由。
|
||||
pub(crate) fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
@@ -26,5 +23,7 @@ pub(crate) fn router() -> Router<ApiState> {
|
||||
.route("/v1/models/{model_id}/info", get(model_info))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
.layer(DefaultBodyLimit::max(MAX_CLIENT_REQUEST_BODY_BYTES))
|
||||
// Responses 正文属于 OpenAI/Codex 协议;代理不能用私有大小上限提前拒绝
|
||||
// 上游本可接受的未来 payload。
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
}
|
||||
|
||||
@@ -81,16 +81,10 @@ impl OpenAiService {
|
||||
"requested model was not found",
|
||||
)
|
||||
})?;
|
||||
if !self.execution.contains_public_model(&client, &public_model) {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::ModelNotFound,
|
||||
"requested model was not found",
|
||||
));
|
||||
}
|
||||
let previous_response_id = match metadata.continuation() {
|
||||
ContinuationIntent::None => None,
|
||||
ContinuationIntent::PreviousResponseId(value) => {
|
||||
PreviousResponseId::new(value.clone()).ok()
|
||||
Some(PreviousResponseId::new(value.clone()))
|
||||
}
|
||||
};
|
||||
self.execution
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
use axum::{
|
||||
Router,
|
||||
body::{Body, to_bytes},
|
||||
http::{Method, Request, StatusCode, header},
|
||||
};
|
||||
use gateway_api::admin::account_groups;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use super::{AdminTestFixture, AdminTestState, PRIMARY_GROUP_ID};
|
||||
|
||||
const SESSION_COOKIE: &str = "cpr_admin_session=valid-session";
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_route_should_keep_camel_case_group_and_page_wire() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::GET,
|
||||
"/api/admin/account-groups?page=1&pageSize=1&search=alpha&enabled=true",
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let value = response_json(response).await;
|
||||
assert_eq!(value["data"]["page"]["page"], 1);
|
||||
assert_eq!(value["data"]["page"]["pageSize"], 1);
|
||||
assert_eq!(value["data"]["page"]["total"], 1);
|
||||
assert_eq!(value["data"]["page"]["totalPages"], 1);
|
||||
assert_eq!(value["data"]["configRevision"], 7);
|
||||
assert_eq!(value["data"]["items"][0]["id"], PRIMARY_GROUP_ID);
|
||||
assert_eq!(value["data"]["items"][0]["memberCount"], 2);
|
||||
assert_eq!(value["data"]["items"][0]["providerCounts"]["openai"], 1);
|
||||
assert_eq!(value["data"]["items"][0]["clientKeyCount"], 2);
|
||||
assert_eq!(value["data"]["items"][0]["accountSummary"]["available"], 1);
|
||||
assert_eq!(value["data"]["items"][0]["accountSummary"]["limited"], 1);
|
||||
assert_eq!(value["data"]["items"][0]["accountSummary"]["total"], 2);
|
||||
assert_eq!(value["data"]["items"][0]["capacity"]["usedSlots"], 0);
|
||||
assert_eq!(value["data"]["items"][0]["capacity"]["totalSlots"], 1);
|
||||
assert_eq!(value["data"]["items"][0]["usage"]["todayUsd"], "1.25");
|
||||
assert_eq!(value["data"]["items"][0]["usage"]["totalUsd"], "5.5");
|
||||
assert!(value["data"]["items"][0].get("member_count").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_route_should_create_an_empty_group() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/create",
|
||||
Some(json!({
|
||||
"name": "Gamma routing",
|
||||
"description": "New traffic pool",
|
||||
"color": "#a855f780"
|
||||
})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let value = response_json(response).await;
|
||||
assert!(
|
||||
value["data"]["id"]
|
||||
.as_str()
|
||||
.is_some_and(|id| id.starts_with("grp_") && id.len() == 36)
|
||||
);
|
||||
assert_eq!(value["data"]["record"]["name"], "Gamma routing");
|
||||
assert_eq!(value["data"]["record"]["memberCount"], 0);
|
||||
assert_eq!(value["data"]["record"]["color"], "#A855F780");
|
||||
assert_eq!(value["data"]["configRevision"], 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_route_should_replace_group_fields() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/update",
|
||||
Some(json!({
|
||||
"id": PRIMARY_GROUP_ID,
|
||||
"name": "Renamed routing",
|
||||
"description": null,
|
||||
"color": "#F43F5ECC"
|
||||
})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let value = response_json(response).await;
|
||||
assert_eq!(value["data"]["record"]["name"], "Renamed routing");
|
||||
assert!(value["data"]["record"]["description"].is_null());
|
||||
assert_eq!(value["data"]["record"]["color"], "#F43F5ECC");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_and_disable_routes_should_set_explicit_group_state() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let disabled = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/disable",
|
||||
Some(json!({ "id": PRIMARY_GROUP_ID })),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let enabled = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/enable",
|
||||
Some(json!({ "id": PRIMARY_GROUP_ID })),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(disabled.status(), StatusCode::OK);
|
||||
assert!(
|
||||
!response_json(disabled).await["data"]["record"]["enabled"]
|
||||
.as_bool()
|
||||
.expect("disabled flag")
|
||||
);
|
||||
assert_eq!(enabled.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response_json(enabled).await["data"]["record"]["enabled"]
|
||||
.as_bool()
|
||||
.expect("enabled flag")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_route_should_return_deleted_id_without_a_live_record() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/delete",
|
||||
Some(json!({ "id": PRIMARY_GROUP_ID })),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let value = response_json(response).await;
|
||||
assert_eq!(value["data"]["id"], PRIMARY_GROUP_ID);
|
||||
assert!(value["data"]["record"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_route_should_reject_unknown_and_invalid_pagination_fields() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
for uri in [
|
||||
"/api/admin/account-groups?other=true",
|
||||
"/api/admin/account-groups?page=0",
|
||||
"/api/admin/account-groups?pageSize=0",
|
||||
"/api/admin/account-groups?pageSize=201",
|
||||
] {
|
||||
let response = request(router(&fixture), Method::GET, uri, None, true).await;
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutation_route_should_reject_unknown_json_fields() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/create",
|
||||
Some(json!({
|
||||
"name": "Unknown field",
|
||||
"description": null,
|
||||
"color": "#2563EBFF",
|
||||
"providerKind": "openai"
|
||||
})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_route_should_reject_six_digit_color_without_alpha() {
|
||||
let fixture = authenticated_fixture().await;
|
||||
let response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/create",
|
||||
Some(json!({
|
||||
"name": "Opaque legacy color",
|
||||
"description": null,
|
||||
"color": "#2563EB"
|
||||
})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_group_routes_should_require_admin_authentication() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
let get_response = request(
|
||||
router(&fixture),
|
||||
Method::GET,
|
||||
"/api/admin/account-groups",
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
let post_response = request(
|
||||
router(&fixture),
|
||||
Method::POST,
|
||||
"/api/admin/account-groups/create",
|
||||
Some(json!({ "name": "Unauthorized", "description": null, "color": "#2563EBFF" })),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(get_response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post_response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
async fn authenticated_fixture() -> AdminTestFixture {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
}
|
||||
|
||||
fn router(fixture: &AdminTestFixture) -> Router {
|
||||
account_groups::router::<AdminTestState>().with_state(fixture.state())
|
||||
}
|
||||
|
||||
async fn request(
|
||||
router: Router,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Option<Value>,
|
||||
authenticated: bool,
|
||||
) -> axum::response::Response {
|
||||
let mut builder = Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.header("x-request-id", "req_account_groups");
|
||||
if authenticated {
|
||||
builder = builder.header(header::COOKIE, SESSION_COOKIE);
|
||||
}
|
||||
let body = match body {
|
||||
Some(value) => {
|
||||
builder = builder.header(header::CONTENT_TYPE, "application/json");
|
||||
Body::from(serde_json::to_vec(&value).expect("serialize request body"))
|
||||
}
|
||||
None => Body::empty(),
|
||||
};
|
||||
router
|
||||
.oneshot(builder.body(body).expect("account group request"))
|
||||
.await
|
||||
.expect("account group response")
|
||||
}
|
||||
|
||||
async fn response_json(response: axum::response::Response) -> Value {
|
||||
let body = to_bytes(response.into_body(), 64 * 1024)
|
||||
.await
|
||||
.expect("account group response body");
|
||||
serde_json::from_slice(&body).expect("account group response JSON")
|
||||
}
|
||||
@@ -10,7 +10,7 @@ mod query {
|
||||
"pageSize": 20,
|
||||
"provider": "xai",
|
||||
"search": " operator ",
|
||||
"status": "active",
|
||||
"status": "normal",
|
||||
"sortBy": "lastUsedAt",
|
||||
"sortDirection": "desc"
|
||||
}))
|
||||
@@ -23,7 +23,7 @@ mod query {
|
||||
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.status, Some(AccountStatus::Normal));
|
||||
assert_eq!(
|
||||
query.sort.expect("sort").field,
|
||||
AccountSortField::LastUsedAt
|
||||
@@ -34,6 +34,19 @@ mod query {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_query_should_parse_rate_limited_status() {
|
||||
let query: ListQuery = serde_json::from_value(json!({
|
||||
"status": "rate_limited"
|
||||
}))
|
||||
.expect("deserialize account query");
|
||||
|
||||
assert_eq!(
|
||||
query.validate().expect("validate account query").status,
|
||||
Some(AccountStatus::RateLimited)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_query_should_reject_unbounded_page_size() {
|
||||
let query: ListQuery =
|
||||
@@ -57,8 +70,138 @@ mod query {
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_update {
|
||||
use gateway_api::admin::accounts::{BatchUpdateAccountsRequest, UpdateAccountRequest};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_accept_complete_atomic_payload() {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_openai", "acct_xai"],
|
||||
"enabled": false,
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": ["grp_00000000000000000000000000000001"]
|
||||
}))
|
||||
.expect("deserialize batch update");
|
||||
|
||||
request.validate().expect("validate batch update");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_reject_duplicate_or_invalid_account_ids() {
|
||||
for account_ids in [
|
||||
json!(["acct_same", "acct_same"]),
|
||||
json!(["not-an-account"]),
|
||||
json!([]),
|
||||
] {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": account_ids,
|
||||
"enabled": true,
|
||||
"concurrencyLimit": 8,
|
||||
"weight": 100,
|
||||
"groupIds": []
|
||||
}))
|
||||
.expect("deserialize invalid batch update");
|
||||
assert_eq!(
|
||||
request.validate().expect_err("reject account IDs").field(),
|
||||
"accountIds"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_require_enabled_and_reject_unknown_fields() {
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"weight": 1,
|
||||
"groupIds": [],
|
||||
"legacy": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_update_should_reject_invalid_scheduling_bounds_and_missing_fields() {
|
||||
for (concurrency_limit, weight, field) in [
|
||||
(json!(0), json!(1), "concurrencyLimit"),
|
||||
(json!(4294967296_u64), json!(1), "concurrencyLimit"),
|
||||
(json!(null), json!(0), "weight"),
|
||||
(json!(null), json!(101), "weight"),
|
||||
] {
|
||||
let request: BatchUpdateAccountsRequest = serde_json::from_value(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": concurrency_limit,
|
||||
"weight": weight,
|
||||
"groupIds": []
|
||||
}))
|
||||
.expect("deserialize invalid scheduling");
|
||||
assert_eq!(
|
||||
request.validate().expect_err("reject scheduling").field(),
|
||||
field
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<BatchUpdateAccountsRequest>(json!({
|
||||
"accountIds": ["acct_test"],
|
||||
"enabled": true,
|
||||
"concurrencyLimit": null,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_update_should_require_the_same_complete_scheduling_contract() {
|
||||
let request: UpdateAccountRequest = serde_json::from_value(json!({
|
||||
"accountId": "acct_test",
|
||||
"enabled": true,
|
||||
"concurrencyLimit": 4294967295_u64,
|
||||
"weight": 100,
|
||||
"groupIds": []
|
||||
}))
|
||||
.expect("deserialize single update");
|
||||
request.validate().expect("validate single update");
|
||||
|
||||
assert!(
|
||||
serde_json::from_value::<UpdateAccountRequest>(json!({
|
||||
"accountId": "acct_test",
|
||||
"enabled": true,
|
||||
"weight": 1,
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod response {
|
||||
use gateway_api::admin::accounts::AccountUsageView;
|
||||
use gateway_api::admin::presenter::format_decimal_currency;
|
||||
|
||||
#[test]
|
||||
fn account_usage_view_should_keep_unobserved_numbers_null() {
|
||||
@@ -99,18 +242,32 @@ mod response {
|
||||
assert!(value["totalTokens"].is_null());
|
||||
assert_eq!(value["createdTokensDisplay"], "-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_usage_currency_should_limit_usd_to_four_fraction_digits() {
|
||||
assert_eq!(format_decimal_currency("0.1204956", "USD"), "$0.1205");
|
||||
assert_eq!(format_decimal_currency("0.99996", "USD"), "$1.00");
|
||||
assert_eq!(format_decimal_currency("0.0300", "USD"), "$0.03");
|
||||
assert_eq!(format_decimal_currency("12.3456", "USD"), "$12.35");
|
||||
assert_eq!(format_decimal_currency("0.1204956", "CNY"), "CNY 0.1204956");
|
||||
}
|
||||
}
|
||||
|
||||
mod actions {
|
||||
use base64::Engine as _;
|
||||
use gateway_admin::model::{
|
||||
Revision,
|
||||
accounts::{AccountConnectionTestEvent as DomainConnectionTestEvent, AccountStatus},
|
||||
provider_credentials::{CredentialDeletionResult, CredentialImportResult},
|
||||
accounts::AccountConnectionTestEvent as DomainConnectionTestEvent,
|
||||
provider_credentials::{
|
||||
CredentialDeletionResult, CredentialImportResult, CredentialMutationResult,
|
||||
},
|
||||
};
|
||||
use gateway_api::admin::accounts::{
|
||||
AccountActionRequest, AccountConnectionTestEvent, AccountDeletionData,
|
||||
AccountDeletionRequest, AccountExportData, AccountExportQuery, AccountIdQuery,
|
||||
AccountImportData, AccountImportRequest, AccountRefreshRequest, AccountTestQuery,
|
||||
AccountImportData, AccountImportRequest, AccountMutationData, AccountRefreshRequest,
|
||||
AccountTestQuery, CompleteAccountAuthorizationRequest, RotateAccountRequest,
|
||||
StartAccountAuthorizationRequest,
|
||||
};
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use serde_json::json;
|
||||
@@ -147,7 +304,7 @@ mod actions {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_actions_should_require_frozen_account_ids_and_revision() {
|
||||
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());
|
||||
@@ -155,13 +312,69 @@ mod actions {
|
||||
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",
|
||||
"expectedConfigRevision": 0
|
||||
"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",
|
||||
"idToken": "id-header.id-payload.id-signature"
|
||||
}))
|
||||
.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!(
|
||||
refresh.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
serde_json::to_value(response).expect("serialize credential mutation"),
|
||||
json!({ "accountId": "acct_1" })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +382,6 @@ mod actions {
|
||||
fn account_import_should_use_provider_and_opaque_data_fields() {
|
||||
let valid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {
|
||||
"providerOwnedUnknownField": {"nested": [1, 2, 3]},
|
||||
"accounts": [{"credentials": {"access_token": "provider-validates-this"}}]
|
||||
@@ -180,7 +392,6 @@ mod actions {
|
||||
|
||||
let invalid: AccountImportRequest = serde_json::from_value(json!({
|
||||
"provider": "xai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": []
|
||||
}))
|
||||
.expect("decode invalid account import");
|
||||
@@ -188,27 +399,61 @@ mod actions {
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"document": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"expectedConfigRevision": 7,
|
||||
"data": {}
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<AccountImportRequest>(json!({
|
||||
"provider": "openai",
|
||||
"data": {},
|
||||
"groupIds": []
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth_complete_should_not_accept_group_assignment() {
|
||||
let flow_id = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7_u8; 32]);
|
||||
let request: CompleteAccountAuthorizationRequest = serde_json::from_value(json!({
|
||||
"provider": "openai",
|
||||
"flowId": flow_id,
|
||||
"callbackUrl": "http://localhost/callback?code=ok"
|
||||
}))
|
||||
.expect("decode OAuth completion");
|
||||
assert!(request.validate().is_ok());
|
||||
assert!(
|
||||
serde_json::from_value::<CompleteAccountAuthorizationRequest>(json!({
|
||||
"provider": "xai",
|
||||
"flowId": "flow_test",
|
||||
"callbackUrl": "http://localhost/callback?code=ok",
|
||||
"groupIds": []
|
||||
}))
|
||||
.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"],
|
||||
"expectedConfigRevision": 8
|
||||
"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"],
|
||||
"expectedConfigRevision": 8
|
||||
"accountIds": ["acct_1", "acct_1"]
|
||||
}))
|
||||
.expect("decode duplicate account deletion");
|
||||
assert_eq!(duplicate.validate().unwrap_err().field(), "accountIds");
|
||||
@@ -223,7 +468,6 @@ mod actions {
|
||||
assert_eq!(
|
||||
serde_json::to_value(response).expect("serialize account deletion"),
|
||||
json!({
|
||||
"configRevision": 9,
|
||||
"deletedCount": 2,
|
||||
"accountIds": ["acct_1", "acct_2"]
|
||||
})
|
||||
@@ -232,14 +476,13 @@ mod actions {
|
||||
|
||||
#[test]
|
||||
fn account_import_response_should_emit_account_ids() {
|
||||
let response = AccountImportData::from(CredentialImportResult {
|
||||
let response = AccountImportData::from_result(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!({
|
||||
"configRevision": 8,
|
||||
"importedCount": 1,
|
||||
"accountIds": ["acct_imported"]
|
||||
})
|
||||
@@ -271,14 +514,14 @@ mod actions {
|
||||
DomainConnectionTestEvent::Content {
|
||||
text: "OK".to_owned(),
|
||||
},
|
||||
DomainConnectionTestEvent::Completed {
|
||||
account_status: AccountStatus::Active,
|
||||
},
|
||||
DomainConnectionTestEvent::Completed {},
|
||||
DomainConnectionTestEvent::Failed {
|
||||
message: "upstream unavailable".to_owned(),
|
||||
provider_error_code: Some("usage_exhausted".to_owned()),
|
||||
provider_error_type: Some("invalid_request_error".to_owned()),
|
||||
account_status: AccountStatus::Active,
|
||||
upstream_status: Some(429),
|
||||
upstream_content_type: Some("application/json".to_owned()),
|
||||
upstream_body: Some(r#"{"error":{"type":"usage_limit_reached"}}"#.to_owned()),
|
||||
},
|
||||
]
|
||||
.map(|event| AccountConnectionTestEvent::from(event).data);
|
||||
@@ -303,13 +546,15 @@ mod actions {
|
||||
}
|
||||
}),
|
||||
json!({ "type": "content", "text": "OK" }),
|
||||
json!({ "type": "test_complete", "success": true, "accountStatus": "active" }),
|
||||
json!({ "type": "test_complete", "success": true }),
|
||||
json!({
|
||||
"type": "error",
|
||||
"error": "upstream unavailable",
|
||||
"providerErrorCode": "usage_exhausted",
|
||||
"providerErrorType": "invalid_request_error",
|
||||
"accountStatus": "active"
|
||||
"upstreamStatus": 429,
|
||||
"upstreamContentType": "application/json",
|
||||
"upstreamBody": r#"{"error":{"type":"usage_limit_reached"}}"#
|
||||
}),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -51,7 +51,6 @@ async fn default_auth_service_should_initialize_login_validate_and_logout() {
|
||||
let service = fixture.services.auth();
|
||||
let session = service
|
||||
.login(LoginCommand {
|
||||
source: "127.0.0.1".to_owned(),
|
||||
username: Some("admin_1".to_owned()),
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
@@ -84,42 +83,6 @@ async fn default_auth_service_should_initialize_login_validate_and_logout() {
|
||||
assert_eq!(fixture.auth.audit_count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_auth_service_should_throttle_repeated_invalid_identity() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
for attempt in 1..=5 {
|
||||
let error = fixture
|
||||
.services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
source: "shared-source".to_owned(),
|
||||
username: Some("wrong-user".to_owned()),
|
||||
password: "wrong-password".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("invalid login");
|
||||
if attempt < 5 {
|
||||
assert_eq!(error, LoginError::InvalidCredentials);
|
||||
} else {
|
||||
assert_eq!(error, LoginError::Throttled);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
fixture
|
||||
.services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
source: "shared-source".to_owned(),
|
||||
username: None,
|
||||
password: "anything".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("source remains throttled"),
|
||||
LoginError::Throttled
|
||||
);
|
||||
assert_eq!(fixture.auth.session_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_auth_service_should_verify_only_full_plaintext_admin_key() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
@@ -162,7 +125,6 @@ async fn audit_failure_should_revoke_new_session_before_returning_it() {
|
||||
.services
|
||||
.auth()
|
||||
.login(LoginCommand {
|
||||
source: "source".to_owned(),
|
||||
username: None,
|
||||
password: "strong-admin-password".to_owned(),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use gateway_api::admin::client_keys::{
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyRevisionRequest,
|
||||
self, ClientKeyCursorData, ClientKeyCursorValue, ClientKeyListData, ClientKeyMutationRequest,
|
||||
ClientKeySort, ClientKeySortDirection, ClientKeySortField, ClientKeyView,
|
||||
CreateClientKeyRequest, CreatedClientKeyData, ListClientKeysQuery, MutatedClientKeyData,
|
||||
RevealedClientKeyData, UpdateClientKeyRequest, decode_client_key_cursor,
|
||||
@@ -101,36 +101,24 @@ fn client_key_queries_should_reject_unknown_zero_and_oversized_fields() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields() {
|
||||
fn client_key_mutations_should_validate_text_limits_and_unknown_fields() {
|
||||
let valid = serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"label": "production",
|
||||
"providerKind": "openai",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.expect("deserialize create")
|
||||
.into_command()
|
||||
.expect("validate create");
|
||||
assert_eq!(valid.expected_config_revision.get(), 7);
|
||||
assert_eq!(valid.name, "terminal key");
|
||||
|
||||
for (payload, field) in [
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 0,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
"expectedConfigRevision",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": " ",
|
||||
"providerKind": "openai",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
@@ -138,9 +126,8 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": u64::MAX,
|
||||
"requestsPerMinute": 0
|
||||
}),
|
||||
@@ -163,21 +150,30 @@ fn client_key_mutations_should_validate_revision_text_limits_and_unknown_fields(
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 1,
|
||||
"name": "key",
|
||||
"providerKind": "openai",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 0,
|
||||
"requestsPerMinute": 0,
|
||||
"tokensPerMinute": 0
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let revision = serde_json::from_value::<ClientKeyRevisionRequest>(json!({
|
||||
"id": "key_1",
|
||||
"expectedConfigRevision": 3
|
||||
assert!(
|
||||
serde_json::from_value::<CreateClientKeyRequest>(json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"name": "terminal key",
|
||||
"groupIds": [],
|
||||
"maxConcurrency": 2,
|
||||
"requestsPerMinute": 60
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
let mutation_id = serde_json::from_value::<ClientKeyMutationRequest>(json!({
|
||||
"id": "key_1"
|
||||
}))
|
||||
.expect("deserialize revision mutation")
|
||||
.into_parts()
|
||||
.expect("validate revision mutation");
|
||||
assert_eq!(revision, ("key_1".to_owned(), 3));
|
||||
.expect("deserialize mutation")
|
||||
.into_id()
|
||||
.expect("validate mutation");
|
||||
assert_eq!(mutation_id, "key_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -214,7 +210,10 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
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"),
|
||||
groups: Vec::new(),
|
||||
provider_kinds: vec![
|
||||
gateway_core::routing::ProviderKind::new("openai").expect("Provider kind"),
|
||||
],
|
||||
prefix: "sk_visible12".to_owned(),
|
||||
enabled: true,
|
||||
limits: gateway_core::policy::RateLimits {
|
||||
@@ -225,12 +224,16 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
updated_at: created_at,
|
||||
last_used_at: Some(created_at),
|
||||
});
|
||||
let list = serde_json::to_value(ClientKeyListData::new(9, vec![view], None, 1))
|
||||
.expect("serialize list");
|
||||
assert_eq!(list["configRevision"], 9);
|
||||
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]["providerKinds"],
|
||||
serde_json::json!(["openai"])
|
||||
);
|
||||
assert_eq!(list["items"][0]["routingScope"], "all");
|
||||
assert_eq!(list["items"][0]["maxConcurrency"], 2);
|
||||
assert_eq!(list["items"][0]["requestsPerMinute"], 60);
|
||||
assert!(list["items"][0].get("tokensPerMinute").is_none());
|
||||
@@ -248,7 +251,6 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
|
||||
let plaintext = format!("sk_{}", "a".repeat(43));
|
||||
let created = CreatedClientKeyData::new(
|
||||
10,
|
||||
"key_created".to_owned(),
|
||||
"sk_aaaaaaaaa".to_owned(),
|
||||
plaintext.clone(),
|
||||
@@ -263,9 +265,9 @@ fn client_key_responses_should_keep_shape_and_redact_creation_debug() {
|
||||
json!({ "id": "key_created", "plaintextKey": plaintext })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(MutatedClientKeyData::new(11, "key_created".to_owned()))
|
||||
serde_json::to_value(MutatedClientKeyData::new("key_created".to_owned()))
|
||||
.expect("serialize mutation"),
|
||||
json!({ "configRevision": 11, "id": "key_created" })
|
||||
json!({ "id": "key_created" })
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,22 @@ use std::{
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::{
|
||||
AdminConfig, AdminServices, InitialAdminPassword,
|
||||
model::{
|
||||
MutationContext, Revision,
|
||||
account_groups::{
|
||||
AccountGroupAccountSummary, AccountGroupCapacity, AccountGroupColor,
|
||||
AccountGroupListQuery, AccountGroupMutation, AccountGroupPage, AccountGroupRecord,
|
||||
AccountGroupUsage, DeleteAccountGroup, NewAccountGroup, SetAccountGroupEnabled,
|
||||
UpdateAccountGroup,
|
||||
},
|
||||
accounts::{
|
||||
AccountListQuery, AccountPage, AccountRecord, AccountUsage, AccountUsageWindowQuery,
|
||||
AccountUsageWindowResult, DeleteAccounts, SetAccountEnabled,
|
||||
AccountListQuery, AccountPage, AccountPageItem, AccountUpdateResult, AccountUsage,
|
||||
AccountUsageWindowQuery, AccountUsageWindowResult, AccountsUpdateResult,
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_keys::{
|
||||
@@ -23,9 +30,9 @@ use gateway_admin::{
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
},
|
||||
observability::{
|
||||
DashboardObservation, DiagnosticDimension, DiagnosticObservation, OpsErrorPage,
|
||||
OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail, UsageFilter, UsageOverview,
|
||||
UsagePage, UsageQuery, UsageRecord,
|
||||
DashboardObservation, DecimalAmount, DiagnosticDimension, DiagnosticObservation,
|
||||
OpsError, OpsErrorPage, OpsErrorQuery, RequestMetricPoint, TimeRange, UsageDetail,
|
||||
UsageFilter, UsageOverview, UsagePage, UsageQuery, UsageRecord,
|
||||
},
|
||||
provider_credentials::{
|
||||
AuthorizationCommit, AuthorizationStarted, CompleteAuthorization, CredentialDetails,
|
||||
@@ -45,8 +52,8 @@ use gateway_admin::{
|
||||
ports::{
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts, AdminStoreResult,
|
||||
AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
AccountGroupStore, AccountStore, AdminStoreError, AdminStoreErrorKind, AdminStorePorts,
|
||||
AdminStoreResult, AuthStore, ClientKeyStore, ObservabilityStore, SettingsStore,
|
||||
},
|
||||
system::{
|
||||
SystemOperationError, SystemOperationErrorKind, SystemOperations,
|
||||
@@ -58,15 +65,15 @@ use gateway_api::admin::AdminSessionState;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeRequest, AccountProbeResult},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
error::GatewayError,
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::{
|
||||
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
|
||||
},
|
||||
};
|
||||
|
||||
mod account_groups;
|
||||
mod accounts;
|
||||
mod auth;
|
||||
mod client_keys;
|
||||
@@ -80,6 +87,11 @@ pub(super) struct AdminTestFixture {
|
||||
pub auth: Arc<MemoryAuthStore>,
|
||||
pub settings: Arc<MemorySettingsStore>,
|
||||
pub usage_records: Arc<Mutex<Vec<UsageRecord>>>,
|
||||
pub usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
pub diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
pub ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
pub dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
}
|
||||
|
||||
impl AdminTestFixture {
|
||||
@@ -92,16 +104,29 @@ impl AdminTestFixture {
|
||||
let auth = Arc::new(MemoryAuthStore::new(api_key.clone()));
|
||||
let settings = Arc::new(MemorySettingsStore::new(api_key));
|
||||
let client_keys = Arc::new(MemoryClientKeyStore);
|
||||
let account_groups = Arc::new(MemoryAccountGroupStore::new());
|
||||
let usage_records = Arc::new(Mutex::new(Vec::new()));
|
||||
let usage_detail = Arc::new(Mutex::new(None));
|
||||
let diagnostics = Arc::new(Mutex::new(Vec::new()));
|
||||
let ops_errors = Arc::new(Mutex::new(Vec::new()));
|
||||
let dashboard_observation = Arc::new(Mutex::new(None));
|
||||
let dashboard_summary_range = Arc::new(Mutex::new(None));
|
||||
let unused = Arc::new(UnusedStore {
|
||||
usage_records: Arc::clone(&usage_records),
|
||||
usage_detail: Arc::clone(&usage_detail),
|
||||
diagnostics: Arc::clone(&diagnostics),
|
||||
ops_errors: Arc::clone(&ops_errors),
|
||||
dashboard_observation: Arc::clone(&dashboard_observation),
|
||||
dashboard_summary_range: Arc::clone(&dashboard_summary_range),
|
||||
});
|
||||
let stores = AdminStorePorts::new(
|
||||
unused.clone(),
|
||||
account_groups.clone(),
|
||||
auth.clone(),
|
||||
client_keys.clone(),
|
||||
unused,
|
||||
settings.clone(),
|
||||
gateway_admin::ports::backup::BackupStorePorts::disabled(),
|
||||
);
|
||||
let providers: Vec<Arc<dyn ProviderAdmin>> = vec![
|
||||
Arc::new(UnusedProvider::new("openai")),
|
||||
@@ -126,6 +151,11 @@ impl AdminTestFixture {
|
||||
auth,
|
||||
settings,
|
||||
usage_records,
|
||||
usage_detail,
|
||||
diagnostics,
|
||||
ops_errors,
|
||||
dashboard_observation,
|
||||
dashboard_summary_range,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +176,6 @@ impl AdminSessionState for AdminTestState {
|
||||
pub(super) struct MemoryAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
sessions: Mutex<BTreeMap<String, AdminSession>>,
|
||||
failures: Mutex<BTreeMap<String, u32>>,
|
||||
audits: Mutex<Vec<AdminAuditEvent>>,
|
||||
api_key: Arc<Mutex<Option<AdminApiKey>>>,
|
||||
fail_audit: AtomicBool,
|
||||
@@ -157,7 +186,6 @@ impl MemoryAuthStore {
|
||||
Self {
|
||||
password_hash: Mutex::new(None),
|
||||
sessions: Mutex::new(BTreeMap::new()),
|
||||
failures: Mutex::new(BTreeMap::new()),
|
||||
audits: Mutex::new(Vec::new()),
|
||||
api_key,
|
||||
fail_audit: AtomicBool::new(false),
|
||||
@@ -239,37 +267,6 @@ impl AuthStore for MemoryAuthStore {
|
||||
Ok(self.sessions.lock().expect("sessions").remove(session_id))
|
||||
}
|
||||
|
||||
async fn login_source_is_throttled(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
) -> AdminStoreResult<bool> {
|
||||
Ok(self
|
||||
.failures
|
||||
.lock()
|
||||
.expect("failures")
|
||||
.get(source)
|
||||
.is_some_and(|count| *count >= failure_limit))
|
||||
}
|
||||
|
||||
async fn record_login_failure(
|
||||
&self,
|
||||
source: &str,
|
||||
failure_limit: u32,
|
||||
_: u64,
|
||||
) -> AdminStoreResult<bool> {
|
||||
let mut failures = self.failures.lock().expect("failures");
|
||||
let count = failures.entry(source.to_owned()).or_default();
|
||||
*count = count.saturating_add(1);
|
||||
Ok(*count >= failure_limit)
|
||||
}
|
||||
|
||||
async fn clear_login_failures(&self, source: &str) -> AdminStoreResult<()> {
|
||||
self.failures.lock().expect("failures").remove(source);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_audit_event(&self, event: AdminAuditEvent) -> AdminStoreResult<()> {
|
||||
if self.fail_audit.load(Ordering::SeqCst) {
|
||||
return Err(unavailable("auth audit"));
|
||||
@@ -312,8 +309,9 @@ impl SettingsStore for MemorySettingsStore {
|
||||
command: ReplaceRuntimeSettings,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<RuntimeSettings> {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
config_revision: next_revision(command.expected_config_revision),
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
model_mappings: command.model_mappings,
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
@@ -325,31 +323,31 @@ impl SettingsStore for MemorySettingsStore {
|
||||
audit_retention_days: command.audit_retention_days,
|
||||
updated_at: Utc::now(),
|
||||
};
|
||||
*self.settings.lock().expect("settings") = updated.clone();
|
||||
*settings = updated.clone();
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
async fn replace_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
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(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn delete_admin_api_key(
|
||||
&self,
|
||||
expected_config_revision: Revision,
|
||||
_: &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(expected_config_revision),
|
||||
config_revision: next_revision(revision),
|
||||
exists: false,
|
||||
})
|
||||
}
|
||||
@@ -357,6 +355,204 @@ impl SettingsStore for MemorySettingsStore {
|
||||
|
||||
pub(super) struct MemoryClientKeyStore;
|
||||
|
||||
pub(super) const PRIMARY_GROUP_ID: &str = "grp_11111111111111111111111111111111";
|
||||
pub(super) const SECONDARY_GROUP_ID: &str = "grp_22222222222222222222222222222222";
|
||||
|
||||
struct MemoryAccountGroupState {
|
||||
revision: Revision,
|
||||
groups: BTreeMap<gateway_core::routing::AccountGroupId, AccountGroupRecord>,
|
||||
}
|
||||
|
||||
pub(super) struct MemoryAccountGroupStore {
|
||||
state: Mutex<MemoryAccountGroupState>,
|
||||
}
|
||||
|
||||
impl MemoryAccountGroupStore {
|
||||
fn new() -> Self {
|
||||
let now = Utc::now();
|
||||
let primary_id = group_id(PRIMARY_GROUP_ID);
|
||||
let secondary_id = group_id(SECONDARY_GROUP_ID);
|
||||
let groups = BTreeMap::from([
|
||||
(
|
||||
primary_id.clone(),
|
||||
AccountGroupRecord {
|
||||
id: primary_id,
|
||||
name: "Alpha routing".to_owned(),
|
||||
description: Some("Primary traffic".to_owned()),
|
||||
color: group_color("#2563ebff"),
|
||||
enabled: true,
|
||||
member_count: 2,
|
||||
provider_counts: BTreeMap::from([
|
||||
("openai".to_owned(), 1),
|
||||
("xai".to_owned(), 1),
|
||||
]),
|
||||
client_key_count: 2,
|
||||
account_summary: account_summary(1, 1, 2),
|
||||
capacity: capacity(Some(0), 1),
|
||||
usage: usage("1.25", "5.5"),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
),
|
||||
(
|
||||
secondary_id.clone(),
|
||||
AccountGroupRecord {
|
||||
id: secondary_id,
|
||||
name: "Beta routing".to_owned(),
|
||||
description: None,
|
||||
color: group_color("#64748B80"),
|
||||
enabled: false,
|
||||
member_count: 0,
|
||||
provider_counts: BTreeMap::new(),
|
||||
client_key_count: 0,
|
||||
account_summary: account_summary(0, 0, 0),
|
||||
capacity: capacity(Some(0), 0),
|
||||
usage: usage("0", "0"),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
),
|
||||
]);
|
||||
Self {
|
||||
state: Mutex::new(MemoryAccountGroupState {
|
||||
revision: Revision::new(7).expect("revision"),
|
||||
groups,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountGroupStore for MemoryAccountGroupStore {
|
||||
async fn list_account_groups(
|
||||
&self,
|
||||
query: AccountGroupListQuery,
|
||||
) -> AdminStoreResult<AccountGroupPage> {
|
||||
let state = self.state.lock().expect("account groups");
|
||||
let search = query.search.as_ref().map(|value| value.to_lowercase());
|
||||
let mut matching = state
|
||||
.groups
|
||||
.values()
|
||||
.filter(|record| {
|
||||
query
|
||||
.enabled
|
||||
.is_none_or(|enabled| record.enabled == enabled)
|
||||
})
|
||||
.filter(|record| {
|
||||
search.as_ref().is_none_or(|search| {
|
||||
record.name.to_lowercase().contains(search)
|
||||
|| record
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|description| description.to_lowercase().contains(search))
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
matching.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
let total = matching.len() as u64;
|
||||
let page_size = usize::from(query.page_size.get());
|
||||
let offset = usize::try_from(query.page.saturating_sub(1))
|
||||
.unwrap_or(usize::MAX)
|
||||
.saturating_mul(page_size);
|
||||
let items = matching.into_iter().skip(offset).take(page_size).collect();
|
||||
Ok(AccountGroupPage {
|
||||
config_revision: state.revision,
|
||||
items,
|
||||
total,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_account_group(
|
||||
&self,
|
||||
command: NewAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
let mut state = self.state.lock().expect("account groups");
|
||||
let now = Utc::now();
|
||||
let record = AccountGroupRecord {
|
||||
id: command.id.clone(),
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
color: command.color,
|
||||
enabled: true,
|
||||
member_count: 0,
|
||||
provider_counts: BTreeMap::new(),
|
||||
client_key_count: 0,
|
||||
account_summary: account_summary(0, 0, 0),
|
||||
capacity: capacity(Some(0), 0),
|
||||
usage: usage("0", "0"),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.groups.insert(command.id.clone(), record);
|
||||
mutation(&mut state, command.id, true)
|
||||
}
|
||||
|
||||
async fn update_account_group(
|
||||
&self,
|
||||
command: UpdateAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
let mut state = self.state.lock().expect("account groups");
|
||||
let record = state
|
||||
.groups
|
||||
.get_mut(&command.id)
|
||||
.ok_or_else(|| not_found("account group"))?;
|
||||
record.name = command.name;
|
||||
record.description = command.description;
|
||||
record.color = command.color;
|
||||
record.updated_at = Utc::now();
|
||||
mutation(&mut state, command.id, true)
|
||||
}
|
||||
|
||||
async fn set_account_group_enabled(
|
||||
&self,
|
||||
command: SetAccountGroupEnabled,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
let mut state = self.state.lock().expect("account groups");
|
||||
let record = state
|
||||
.groups
|
||||
.get_mut(&command.id)
|
||||
.ok_or_else(|| not_found("account group"))?;
|
||||
record.enabled = command.enabled;
|
||||
record.updated_at = Utc::now();
|
||||
mutation(&mut state, command.id, true)
|
||||
}
|
||||
|
||||
async fn delete_account_group(
|
||||
&self,
|
||||
command: DeleteAccountGroup,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
let mut state = self.state.lock().expect("account groups");
|
||||
if state.groups.remove(&command.id).is_none() {
|
||||
return Err(not_found("account group"));
|
||||
}
|
||||
mutation(&mut state, command.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
fn group_id(value: &str) -> gateway_core::routing::AccountGroupId {
|
||||
gateway_core::routing::AccountGroupId::new(value).expect("account group ID")
|
||||
}
|
||||
|
||||
fn mutation(
|
||||
state: &mut MemoryAccountGroupState,
|
||||
id: gateway_core::routing::AccountGroupId,
|
||||
include_record: bool,
|
||||
) -> AdminStoreResult<AccountGroupMutation> {
|
||||
state.revision = next_revision(state.revision);
|
||||
Ok(AccountGroupMutation {
|
||||
config_revision: state.revision,
|
||||
record: include_record.then(|| state.groups.get(&id).expect("group record").clone()),
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for MemoryClientKeyStore {
|
||||
async fn list_client_keys(&self, _: ClientKeyListQuery) -> AdminStoreResult<ClientKeyPage> {
|
||||
@@ -378,7 +574,8 @@ impl ClientKeyStore for MemoryClientKeyStore {
|
||||
id: id.clone(),
|
||||
name: "revealed".to_owned(),
|
||||
label: None,
|
||||
provider_kind: ProviderKind::new("openai").expect("provider kind"),
|
||||
groups: Vec::new(),
|
||||
provider_kinds: vec![ProviderKind::new("openai").expect("provider kind")],
|
||||
prefix: "sk_aaaaaaaaa".to_owned(),
|
||||
enabled: true,
|
||||
limits: RateLimits::unlimited(),
|
||||
@@ -425,6 +622,11 @@ impl ClientKeyStore for MemoryClientKeyStore {
|
||||
|
||||
struct UnusedStore {
|
||||
usage_records: Arc<Mutex<Vec<UsageRecord>>>,
|
||||
usage_detail: Arc<Mutex<Option<UsageDetail>>>,
|
||||
diagnostics: Arc<Mutex<Vec<DiagnosticObservation>>>,
|
||||
ops_errors: Arc<Mutex<Vec<OpsError>>>,
|
||||
dashboard_observation: Arc<Mutex<Option<DashboardObservation>>>,
|
||||
dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -433,7 +635,7 @@ impl AccountStore for UnusedStore {
|
||||
Err(unavailable("account list"))
|
||||
}
|
||||
|
||||
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountRecord>> {
|
||||
async fn load_account(&self, _: &str) -> AdminStoreResult<Option<AccountPageItem>> {
|
||||
Err(unavailable("account"))
|
||||
}
|
||||
|
||||
@@ -508,14 +710,30 @@ impl AccountStore for UnusedStore {
|
||||
Err(unavailable("credential refresh"))
|
||||
}
|
||||
|
||||
async fn set_account_enabled(
|
||||
async fn update_account(
|
||||
&self,
|
||||
_: SetAccountEnabled,
|
||||
_: UpdateAccount,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<Revision> {
|
||||
) -> AdminStoreResult<AccountUpdateResult> {
|
||||
Err(unavailable("account enabled"))
|
||||
}
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
_: &ProviderAccountId,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult> {
|
||||
Err(unavailable("account recovery"))
|
||||
}
|
||||
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
_: BatchUpdateAccounts,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
Err(unavailable("account batch update"))
|
||||
}
|
||||
|
||||
async fn delete_accounts(
|
||||
&self,
|
||||
_: DeleteAccounts,
|
||||
@@ -535,8 +753,20 @@ impl AccountStore for UnusedStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ObservabilityStore for UnusedStore {
|
||||
async fn dashboard_summary(&self, _: TimeRange) -> AdminStoreResult<DashboardObservation> {
|
||||
Err(unavailable("dashboard"))
|
||||
async fn dashboard_summary(
|
||||
&self,
|
||||
range: TimeRange,
|
||||
_: DateTime<Utc>,
|
||||
) -> AdminStoreResult<DashboardObservation> {
|
||||
*self
|
||||
.dashboard_summary_range
|
||||
.lock()
|
||||
.expect("dashboard summary range") = Some(range);
|
||||
self.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation")
|
||||
.clone()
|
||||
.ok_or_else(|| unavailable("dashboard"))
|
||||
}
|
||||
|
||||
async fn dashboard_trend(&self, _: TimeRange) -> AdminStoreResult<Vec<RequestMetricPoint>> {
|
||||
@@ -570,7 +800,11 @@ impl ObservabilityStore for UnusedStore {
|
||||
}
|
||||
|
||||
async fn usage_record_detail(&self, _: &str) -> AdminStoreResult<UsageDetail> {
|
||||
Err(unavailable("usage detail"))
|
||||
self.usage_detail
|
||||
.lock()
|
||||
.expect("usage detail")
|
||||
.clone()
|
||||
.ok_or_else(|| unavailable("usage detail"))
|
||||
}
|
||||
|
||||
async fn usage_summary(&self, _: TimeRange, _: UsageFilter) -> AdminStoreResult<UsageOverview> {
|
||||
@@ -583,11 +817,16 @@ impl ObservabilityStore for UnusedStore {
|
||||
_: UsageFilter,
|
||||
_: DiagnosticDimension,
|
||||
) -> AdminStoreResult<Vec<DiagnosticObservation>> {
|
||||
Err(unavailable("usage diagnostics"))
|
||||
Ok(self.diagnostics.lock().expect("diagnostics").clone())
|
||||
}
|
||||
|
||||
async fn list_ops_errors(&self, _: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
Err(unavailable("ops errors"))
|
||||
let items = self.ops_errors.lock().expect("ops errors").clone();
|
||||
Ok(OpsErrorPage {
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,7 +946,7 @@ impl AccountProbe for NoopProbe {
|
||||
fn probe(
|
||||
&self,
|
||||
_: AccountProbeRequest,
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, GatewayError>> {
|
||||
) -> BoxFuture<'_, Result<AccountProbeResult, AccountProbeError>> {
|
||||
Box::pin(async { panic!("unused account probe") })
|
||||
}
|
||||
}
|
||||
@@ -778,6 +1017,32 @@ fn next_revision(revision: Revision) -> Revision {
|
||||
Revision::new(revision.get().saturating_add(1)).expect("next revision")
|
||||
}
|
||||
|
||||
fn account_summary(available: u64, limited: u64, total: u64) -> AccountGroupAccountSummary {
|
||||
AccountGroupAccountSummary {
|
||||
available,
|
||||
limited,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
fn group_color(value: &str) -> AccountGroupColor {
|
||||
AccountGroupColor::parse(value).expect("group color")
|
||||
}
|
||||
|
||||
fn capacity(used_slots: Option<u64>, total_slots: u64) -> AccountGroupCapacity {
|
||||
AccountGroupCapacity {
|
||||
used_slots,
|
||||
total_slots,
|
||||
}
|
||||
}
|
||||
|
||||
fn usage(today_usd: &str, total_usd: &str) -> AccountGroupUsage {
|
||||
AccountGroupUsage {
|
||||
today_usd: today_usd.parse::<DecimalAmount>().expect("today cost"),
|
||||
total_usd: total_usd.parse::<DecimalAmount>().expect("total cost"),
|
||||
}
|
||||
}
|
||||
|
||||
fn unavailable(resource: &'static str) -> AdminStoreError {
|
||||
AdminStoreError::new(
|
||||
AdminStoreErrorKind::Unavailable,
|
||||
@@ -786,6 +1051,10 @@ fn unavailable(resource: &'static str) -> AdminStoreError {
|
||||
)
|
||||
}
|
||||
|
||||
fn not_found(resource: &'static str) -> AdminStoreError {
|
||||
AdminStoreError::new(AdminStoreErrorKind::NotFound, resource, "not found")
|
||||
}
|
||||
|
||||
fn unsupported_provider() -> ProviderAdminError {
|
||||
ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)
|
||||
}
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
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()),
|
||||
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"],
|
||||
"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",
|
||||
"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,4 @@
|
||||
//! Admin 观测 wire/路由的固定合同测试。
|
||||
|
||||
mod query;
|
||||
mod response;
|
||||
@@ -0,0 +1,64 @@
|
||||
//! 查询 wire 校验的固定合同测试。
|
||||
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,993 @@
|
||||
//! 响应 DTO 与固定 wire 形状的合同测试。
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use gateway_admin::model::observability::DesktopReleaseStatus;
|
||||
use gateway_api::admin::PageMeta;
|
||||
use gateway_api::admin::observability::{
|
||||
BillingView, CostCoverageView, CursorWire, DashboardAccountRequestBucketView,
|
||||
DashboardAccountUsageView, DashboardDesktopReleaseStatusView, DashboardWireAttributeView,
|
||||
DashboardWireProfileView, DashboardWireTargetView, PageData, 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),
|
||||
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 dashboard_summary_should_include_quota_exhaustion_in_unavailable_headline_count() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, DashboardObservation, RequestMetrics, TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: AccountPoolMetrics {
|
||||
total: 807,
|
||||
normal: 15,
|
||||
quota_exhausted: 785,
|
||||
rate_limited: 0,
|
||||
disabled: 3,
|
||||
error: 4,
|
||||
},
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_account_summary")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
)
|
||||
.await
|
||||
.expect("dashboard response");
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("dashboard response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("dashboard response JSON");
|
||||
assert_eq!(status, StatusCode::OK, "{value}");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["cards"]["credentials"]["totalValue"],
|
||||
&value["data"]["cards"]["credentials"]["availableValue"],
|
||||
&value["data"]["cards"]["credentials"]["unavailableValue"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!(807),
|
||||
&serde_json::json!(15),
|
||||
&serde_json::json!(792)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_use_lifetime_totals_for_card_footers() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, DashboardObservation, DashboardTotals, RequestMetrics,
|
||||
TimeRange,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
let range_requests = RequestMetrics {
|
||||
request_count: 2,
|
||||
input_tokens: 8,
|
||||
cached_tokens: 2,
|
||||
total_tokens: 12,
|
||||
..RequestMetrics::default()
|
||||
};
|
||||
*fixture
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
requests: range_requests,
|
||||
attempts: AttemptMetrics::default(),
|
||||
totals: DashboardTotals {
|
||||
request_count: 42,
|
||||
input_tokens: 800,
|
||||
cached_tokens: 200,
|
||||
total_tokens: 840,
|
||||
billing_usd: None,
|
||||
},
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_lifetime_totals")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
)
|
||||
.await
|
||||
.expect("dashboard response");
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("dashboard response body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("dashboard response JSON");
|
||||
assert_eq!(status, StatusCode::OK, "{value}");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["cards"]["traffic"]["totalRequests"],
|
||||
&value["data"]["cards"]["tokens"]["totalTokens"],
|
||||
&value["data"]["cards"]["cache"]["totalHitRate"],
|
||||
&value["data"]["cards"]["cache"]["totalCachedTokens"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("42"),
|
||||
&serde_json::json!("840"),
|
||||
&serde_json::json!("25.0%"),
|
||||
&serde_json::json!("200"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dashboard_summary_should_default_to_current_china_day() {
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use gateway_admin::model::observability::{
|
||||
AccountPoolMetrics, AttemptMetrics, DashboardObservation, RequestMetrics, TimeRange,
|
||||
china_day_start,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
*fixture
|
||||
.dashboard_observation
|
||||
.lock()
|
||||
.expect("dashboard observation") = Some(DashboardObservation {
|
||||
range: TimeRange::new(now - Duration::hours(1), now).expect("dashboard range"),
|
||||
requests: RequestMetrics::default(),
|
||||
attempts: AttemptMetrics::default(),
|
||||
totals: Default::default(),
|
||||
provider_accounts: AccountPoolMetrics::default(),
|
||||
trend: Vec::new(),
|
||||
account_usage: Vec::new(),
|
||||
recent_requests: Vec::new(),
|
||||
});
|
||||
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/dashboard/summary")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_dashboard_today_range")
|
||||
.body(Body::empty())
|
||||
.expect("dashboard request"),
|
||||
)
|
||||
.await
|
||||
.expect("dashboard response");
|
||||
let range = fixture
|
||||
.dashboard_summary_range
|
||||
.lock()
|
||||
.expect("dashboard summary range")
|
||||
.take()
|
||||
.expect("recorded dashboard summary range");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(range.start, china_day_start(range.end));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::{UsageAttempt, UsageDetail};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
fixture
|
||||
.usage_detail
|
||||
.lock()
|
||||
.expect("usage detail")
|
||||
.replace(UsageDetail {
|
||||
request: usage_record_with_account(
|
||||
"req_detail",
|
||||
"acct_snap_a",
|
||||
"Snapshot Alpha",
|
||||
"alpha@example.invalid",
|
||||
"oauth",
|
||||
now,
|
||||
),
|
||||
attempts: vec![UsageAttempt {
|
||||
source: "ops_event".to_owned(),
|
||||
id: "ops_detail".to_owned(),
|
||||
attempt_index: 1,
|
||||
component: "routing".to_owned(),
|
||||
operation: "fallback".to_owned(),
|
||||
provider_kind: Some("openai".to_owned()),
|
||||
provider_account_ref: Some("acct_snap_b".to_owned()),
|
||||
provider_account_name: Some("Snapshot Beta".to_owned()),
|
||||
provider_account_email: Some("beta@example.invalid".to_owned()),
|
||||
provider_account_authentication_kind: Some("api_key".to_owned()),
|
||||
upstream_model_id: Some("upstream-b".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
upstream_send_state: Some("sent".to_owned()),
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Failed,
|
||||
downstream_committed: false,
|
||||
status_code: Some(429),
|
||||
provider_error_code: Some("rate_limit".to_owned()),
|
||||
failure_kind: Some("rate_limited".to_owned()),
|
||||
retry_after_ms: Some(1_000),
|
||||
upstream_request_id: None,
|
||||
latency_ms: Some(120),
|
||||
message: Some("limited".to_owned()),
|
||||
input_tokens: None,
|
||||
output_tokens: None,
|
||||
cached_tokens: None,
|
||||
cache_write_tokens: None,
|
||||
reasoning_tokens: None,
|
||||
total_tokens: None,
|
||||
cost_source: Some("unavailable".to_owned()),
|
||||
cost_amount: None,
|
||||
cost_currency: None,
|
||||
occurred_at: now,
|
||||
}],
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/records/detail?id=req_detail")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_usage_detail_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("usage detail request"),
|
||||
)
|
||||
.await
|
||||
.expect("usage detail response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("usage detail body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage detail JSON");
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"accountId": value["data"]["accountId"],
|
||||
"accountName": value["data"]["accountName"],
|
||||
"accountEmail": value["data"]["accountEmail"],
|
||||
"authenticationKind": value["data"]["authenticationKind"],
|
||||
"attemptCredentialName": value["data"]["attempts"][0]["credentialName"],
|
||||
"attemptAccountId": value["data"]["attempts"][0]["accountId"],
|
||||
"attemptAccountName": value["data"]["attempts"][0]["accountName"],
|
||||
"attemptAccountEmail": value["data"]["attempts"][0]["accountEmail"],
|
||||
"attemptAuthenticationKind": value["data"]["attempts"][0]["authenticationKind"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"accountId": "acct_snap_a",
|
||||
"accountName": "Snapshot Alpha",
|
||||
"accountEmail": "alpha@example.invalid",
|
||||
"authenticationKind": "oauth",
|
||||
"attemptCredentialName": "Snapshot Beta",
|
||||
"attemptAccountId": "acct_snap_b",
|
||||
"attemptAccountName": "Snapshot Beta",
|
||||
"attemptAccountEmail": "beta@example.invalid",
|
||||
"attemptAuthenticationKind": "api_key",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::OpsError;
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.ops_errors
|
||||
.lock()
|
||||
.expect("ops errors")
|
||||
.push(OpsError {
|
||||
source: "model_request".to_owned(),
|
||||
event_id: "err_snapshot".to_owned(),
|
||||
request_id: Some("req_err".to_owned()),
|
||||
attempt_index: Some(1),
|
||||
client_api_key_ref: Some("key_err".to_owned()),
|
||||
component: "model_request".to_owned(),
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: Some("/v1/responses".to_owned()),
|
||||
provider_kind: Some("openai".to_owned()),
|
||||
provider_account_ref: Some("acct_err".to_owned()),
|
||||
provider_account_name: None,
|
||||
provider_account_email: Some("err@example.invalid".to_owned()),
|
||||
provider_account_authentication_kind: Some("api_key".to_owned()),
|
||||
upstream_model_id: Some("upstream-err".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
failure_kind: "upstream_error".to_owned(),
|
||||
client_status_code: Some(502),
|
||||
upstream_status_code: Some(502),
|
||||
provider_error_code: Some("upstream".to_owned()),
|
||||
client_response_id: None,
|
||||
upstream_request_id: None,
|
||||
latency_ms: Some(90),
|
||||
message: "snapshot error".to_owned(),
|
||||
occurrence_count: 1,
|
||||
occurred_at: Utc::now(),
|
||||
stable_sort_id: "model_request:req_err".to_owned(),
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/operations/errors")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_ops_errors_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("ops errors request"),
|
||||
)
|
||||
.await
|
||||
.expect("ops errors response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("ops errors body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("ops errors JSON");
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"provider": value["data"]["items"][0]["provider"],
|
||||
"authenticationKind": value["data"]["items"][0]["authenticationKind"],
|
||||
"kind": value["data"]["items"][0]["kind"],
|
||||
"accountId": value["data"]["items"][0]["accountId"],
|
||||
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
|
||||
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"provider": "openai",
|
||||
"authenticationKind": "api_key",
|
||||
"kind": "model_request",
|
||||
"accountId": "acct_err",
|
||||
"accountLabel": "err@example.invalid",
|
||||
"clientStatusCode": 502,
|
||||
"route": "/v1/responses",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use gateway_admin::model::observability::{CostCoverage, DiagnosticObservation};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
fixture
|
||||
.diagnostics
|
||||
.lock()
|
||||
.expect("diagnostics")
|
||||
.push(DiagnosticObservation {
|
||||
key: "acct_diag".to_owned(),
|
||||
name: "diag@example.invalid".to_owned(),
|
||||
request_count: 2,
|
||||
success_count: 2,
|
||||
failure_count: 0,
|
||||
attempt_count: 2,
|
||||
total_tokens: 200,
|
||||
average_latency_ms: Some(100),
|
||||
latency_p95_ms: Some(3800),
|
||||
cost_coverage: CostCoverage::default(),
|
||||
costs: Vec::new(),
|
||||
});
|
||||
let response = observability::router::<AdminTestState>()
|
||||
.with_state(fixture.state())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/admin/usage/insights/diagnostics?dimension=account")
|
||||
.header(header::COOKIE, "cpr_admin_session=valid-session")
|
||||
.header("x-request-id", "req_diagnostics_snapshot")
|
||||
.body(Body::empty())
|
||||
.expect("diagnostics request"),
|
||||
)
|
||||
.await
|
||||
.expect("diagnostics response");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 1024 * 1024)
|
||||
.await
|
||||
.expect("diagnostics body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
|
||||
assert_eq!(
|
||||
(
|
||||
&value["data"]["items"][0]["key"],
|
||||
&value["data"]["items"][0]["name"],
|
||||
&value["data"]["items"][0]["latencyP95Ms"],
|
||||
&value["data"]["dimension"],
|
||||
),
|
||||
(
|
||||
&serde_json::json!("acct_diag"),
|
||||
&serde_json::json!("diag@example.invalid"),
|
||||
&serde_json::json!(3800),
|
||||
&serde_json::json!("account"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
fn usage_record_with_account(
|
||||
id: &str,
|
||||
account_id: &str,
|
||||
name: &str,
|
||||
email: &str,
|
||||
authentication_kind: &str,
|
||||
started_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> gateway_admin::model::observability::UsageRecord {
|
||||
use gateway_admin::model::observability::{RequestOutcome, UsageRecord};
|
||||
|
||||
UsageRecord {
|
||||
id: id.to_owned(),
|
||||
client_api_key_ref: "key_detail".to_owned(),
|
||||
config_revision: 1,
|
||||
routing_scope: "all".to_owned(),
|
||||
routing_group_refs: Vec::new(),
|
||||
routing_group_names_snapshot: Vec::new(),
|
||||
protocol: "openai".to_owned(),
|
||||
operation: "responses".to_owned(),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
client_transport: "http_sse".to_owned(),
|
||||
requested_model_id: "coding".to_owned(),
|
||||
provider_kind: Some("openai".to_owned()),
|
||||
provider_account_ref: Some(account_id.to_owned()),
|
||||
provider_account_name: Some(name.to_owned()),
|
||||
provider_account_email: Some(email.to_owned()),
|
||||
provider_account_authentication_kind: Some(authentication_kind.to_owned()),
|
||||
upstream_model_id: Some("upstream-model".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
http_version: Some("h2".to_owned()),
|
||||
websocket_pool: None,
|
||||
service_tier: None,
|
||||
provider_metadata_json: None,
|
||||
attempt_count: 1,
|
||||
upstream_send_state: "sent".to_owned(),
|
||||
downstream_committed_at: Some(started_at),
|
||||
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(1),
|
||||
output_tokens: Some(1),
|
||||
cached_tokens: Some(0),
|
||||
cache_write_tokens: Some(0),
|
||||
reasoning_tokens: Some(0),
|
||||
image_input_tokens: Some(0),
|
||||
image_output_tokens: Some(0),
|
||||
total_tokens: Some(2),
|
||||
cost_source: "unavailable".to_owned(),
|
||||
cost_amount: None,
|
||||
cost_currency: None,
|
||||
billing: None,
|
||||
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: None,
|
||||
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,
|
||||
deadline_at: started_at + chrono::Duration::seconds(30),
|
||||
completed_at: Some(started_at),
|
||||
}
|
||||
}
|
||||
|
||||
#[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 crate::admin::{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 std::str::FromStr as _;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use gateway_admin::model::observability::{
|
||||
CalculatedBillingBreakdown, CurrencyCost, DecimalAmount, RequestOutcome, UsageBilling,
|
||||
UsageRecord,
|
||||
};
|
||||
use gateway_api::admin::observability;
|
||||
use tower::ServiceExt as _;
|
||||
|
||||
use crate::admin::{AdminTestFixture, AdminTestState};
|
||||
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let usd = |amount: &str| CurrencyCost {
|
||||
currency: "USD".to_owned(),
|
||||
amount: DecimalAmount::from_str(amount).expect("USD amount"),
|
||||
};
|
||||
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,
|
||||
routing_scope: "groups".to_owned(),
|
||||
routing_group_refs: vec!["grp_usage".to_owned()],
|
||||
routing_group_names_snapshot: vec!["Usage group".to_owned()],
|
||||
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: Some("acct_snapshot".to_owned()),
|
||||
provider_account_name: Some("Snapshot Alpha".to_owned()),
|
||||
provider_account_email: Some("alpha@example.invalid".to_owned()),
|
||||
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: Some(UsageBilling::Calculated(Box::new(
|
||||
CalculatedBillingBreakdown {
|
||||
input_amount: usd("0.03"),
|
||||
output_amount: usd("0.07"),
|
||||
cache_read_amount: usd("0.00"),
|
||||
cache_write_amount: usd("0.00"),
|
||||
standard_amount: usd("0.10"),
|
||||
total_amount: usd("0.10"),
|
||||
input_price_per_million: usd("10.0000"),
|
||||
output_price_per_million: usd("60.0000"),
|
||||
cache_read_price_per_million: usd("1.0000"),
|
||||
cache_write_price_per_million: usd("12.5000"),
|
||||
service_tier: Some("priority".to_owned()),
|
||||
multiplier_percent: 100,
|
||||
},
|
||||
))),
|
||||
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!(
|
||||
value["data"]["items"][0]["billing"]["inputPriceDisplay"],
|
||||
"$10 / 1M Token"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["outputPriceDisplay"],
|
||||
"$60 / 1M Token"
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][0]["billing"]["cacheWritePriceDisplay"],
|
||||
"$12.5 / 1M Token"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::json!({
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"routingScope": value["data"]["items"][0]["routingScope"],
|
||||
"routingGroupRefs": value["data"]["items"][0]["routingGroupRefs"],
|
||||
"routingGroupNamesSnapshot": value["data"]["items"][0]["routingGroupNamesSnapshot"],
|
||||
"serviceTier": value["data"]["items"][0]["serviceTier"],
|
||||
"accountId": value["data"]["items"][0]["accountId"],
|
||||
"accountName": value["data"]["items"][0]["accountName"],
|
||||
"accountEmail": value["data"]["items"][0]["accountEmail"],
|
||||
"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]["websocketPool"],
|
||||
"imageGenerationRequested": value["data"]["items"][0]["imageGenerationRequested"],
|
||||
"imageGenerationSucceeded": value["data"]["items"][0]["imageGenerationSucceeded"],
|
||||
"requestedModel": value["data"]["items"][0]["requestedModel"],
|
||||
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
|
||||
"clientIp": value["data"]["items"][0]["clientIp"],
|
||||
"userAgent": value["data"]["items"][0]["userAgent"],
|
||||
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
|
||||
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
|
||||
"requestKind": value["data"]["items"][0]["requestKind"],
|
||||
"subagentKind": value["data"]["items"][0]["subagentKind"],
|
||||
"compact": value["data"]["items"][0]["compact"],
|
||||
"transport": value["data"]["items"][0]["transport"],
|
||||
"transportDecisionWaitMs": value["data"]["items"][0]["latencyDetails"]["transportDecisionWaitMs"],
|
||||
"wsConnectMs": value["data"]["items"][0]["latencyDetails"]["wsConnectMs"],
|
||||
"firstReasoningMs": value["data"]["items"][0]["latencyDetails"]["firstReasoningMs"],
|
||||
"firstTextMs": value["data"]["items"][0]["latencyDetails"]["firstTextMs"],
|
||||
"firstTokenMs": value["data"]["items"][0]["latencyDetails"]["firstTokenMs"],
|
||||
"effectiveModel": value["data"]["items"][0]["metadata"]["effectiveModel"],
|
||||
"requestSummary": value["data"]["items"][0]["metadata"]["requestSummary"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"route": "/v1/responses",
|
||||
"routingScope": "groups",
|
||||
"routingGroupRefs": ["grp_usage"],
|
||||
"routingGroupNamesSnapshot": ["Usage group"],
|
||||
"serviceTier": "priority",
|
||||
"accountId": "acct_snapshot",
|
||||
"accountName": "Snapshot Alpha",
|
||||
"accountEmail": "alpha@example.invalid",
|
||||
"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},
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
|
||||
|
||||
fn update_body() -> Value {
|
||||
json!({
|
||||
"expectedConfigRevision": 7,
|
||||
"modelMappings": {
|
||||
"gpt-5.4": "gpt-5.5",
|
||||
"grok-latest": "grok-4.5"
|
||||
@@ -64,22 +63,128 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_require_positive_revision() {
|
||||
let mut body = update_body();
|
||||
body["expectedConfigRevision"] = json!(0);
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
assert_eq!(
|
||||
request.validate().unwrap_err().field(),
|
||||
"expectedConfigRevision"
|
||||
);
|
||||
fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
let mut missing = update_body();
|
||||
missing
|
||||
.as_object_mut()
|
||||
.expect("settings object")
|
||||
.remove("expectedConfigRevision");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(missing).is_err());
|
||||
use chrono::{TimeZone, Utc};
|
||||
use gateway_admin::model::Revision;
|
||||
use gateway_admin::model::settings::RuntimeSettings;
|
||||
use gateway_api::admin::settings::RuntimeSettingsView;
|
||||
use gateway_core::engine::credential::RotationStrategy;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
let settings = RuntimeSettings {
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
model_mappings: BTreeMap::from_iter([
|
||||
(
|
||||
PublicModelId::new("gpt-5.4").expect("public model"),
|
||||
UpstreamModelId::new("gpt-5.5").expect("upstream model"),
|
||||
),
|
||||
(
|
||||
PublicModelId::new("grok-latest").expect("public model"),
|
||||
UpstreamModelId::new("grok-4.5").expect("upstream model"),
|
||||
),
|
||||
]),
|
||||
refresh_margin_seconds: 1800,
|
||||
refresh_concurrency: 4,
|
||||
max_concurrent_per_account: 5,
|
||||
request_interval_ms: 25,
|
||||
rotation_strategy: RotationStrategy::RoundRobin,
|
||||
usage_retention_days: 32,
|
||||
ops_event_retention_days: 31,
|
||||
audit_retention_days: 91,
|
||||
updated_at: Utc
|
||||
.with_ymd_and_hms(2026, 8, 2, 10, 30, 0)
|
||||
.single()
|
||||
.expect("timestamp"),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(RuntimeSettingsView::from(settings)).expect("serialize view");
|
||||
assert_eq!(
|
||||
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,
|
||||
"updatedAt": "2026-08-02T10:30:00Z"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use gateway_admin::model::Revision;
|
||||
use gateway_admin::model::settings::RuntimeSettings;
|
||||
use gateway_api::admin::settings::RuntimeSettingsView;
|
||||
use gateway_core::engine::credential::RotationStrategy;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(update_body()).expect("decode settings");
|
||||
request.validate().expect("fixture settings must validate");
|
||||
|
||||
let request_fields: BTreeSet<String> = update_body()
|
||||
.as_object()
|
||||
.expect("request body object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let settings = RuntimeSettings {
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
model_mappings: request
|
||||
.model_mappings
|
||||
.iter()
|
||||
.map(|(public, upstream)| {
|
||||
Ok((
|
||||
PublicModelId::new(public.clone())?,
|
||||
UpstreamModelId::new(upstream.clone())?,
|
||||
))
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, gateway_core::error::IdentifierError>>()
|
||||
.expect("valid model mappings"),
|
||||
refresh_margin_seconds: request.refresh_margin_seconds,
|
||||
refresh_concurrency: u32::try_from(request.refresh_concurrency).expect("u32"),
|
||||
max_concurrent_per_account: u32::try_from(request.max_concurrent_per_account).expect("u32"),
|
||||
request_interval_ms: request.request_interval_ms,
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
.expect("fixture rotation strategy"),
|
||||
usage_retention_days: u32::try_from(request.usage_retention_days).expect("u32"),
|
||||
ops_event_retention_days: u32::try_from(request.ops_event_retention_days).expect("u32"),
|
||||
audit_retention_days: u32::try_from(request.audit_retention_days).expect("u32"),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let response_fields: BTreeSet<String> =
|
||||
serde_json::to_value(RuntimeSettingsView::from(settings))
|
||||
.expect("serialize view")
|
||||
.as_object()
|
||||
.expect("view object")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut expected_fields = request_fields;
|
||||
expected_fields.insert("updatedAt".to_owned());
|
||||
|
||||
assert_eq!(response_fields, expected_fields);
|
||||
}
|
||||
|
||||
#[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]
|
||||
@@ -124,7 +229,7 @@ async fn settings_post_should_replace_global_model_mappings() {
|
||||
.expect("settings update response");
|
||||
let data = response_json(response).await["data"].clone();
|
||||
|
||||
assert_eq!(data["configRevision"], 8);
|
||||
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");
|
||||
}
|
||||
@@ -213,3 +318,26 @@ async fn settings_should_accept_admin_api_key_header() {
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -33,11 +33,18 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
let mut actual = rust_files(&root.join("src"));
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"src/admin/account_groups.rs",
|
||||
"src/admin/accounts.rs",
|
||||
"src/admin/auth.rs",
|
||||
"src/admin/backups.rs",
|
||||
"src/admin/client_keys.rs",
|
||||
"src/admin/mod.rs",
|
||||
"src/admin/observability.rs",
|
||||
"src/admin/observability/mod.rs",
|
||||
"src/admin/observability/presenter.rs",
|
||||
"src/admin/observability/query.rs",
|
||||
"src/admin/observability/routes.rs",
|
||||
"src/admin/observability/wire.rs",
|
||||
"src/admin/presenter.rs",
|
||||
"src/admin/settings.rs",
|
||||
"src/admin/system.rs",
|
||||
"src/admin/wire.rs",
|
||||
@@ -67,11 +74,14 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
let mut actual = rust_files(&root.join("tests"));
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"tests/admin/account_groups.rs",
|
||||
"tests/admin/accounts.rs",
|
||||
"tests/admin/auth.rs",
|
||||
"tests/admin/client_keys.rs",
|
||||
"tests/admin/mod.rs",
|
||||
"tests/admin/observability.rs",
|
||||
"tests/admin/observability/mod.rs",
|
||||
"tests/admin/observability/query.rs",
|
||||
"tests/admin/observability/response.rs",
|
||||
"tests/admin/settings.rs",
|
||||
"tests/admin/system.rs",
|
||||
"tests/admin/wire.rs",
|
||||
|
||||
@@ -58,6 +58,30 @@ fn no_available_provider_error_should_map_to_service_unavailable() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_capacity_error_should_map_to_service_unavailable() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::AccountCapacityUnavailable),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"account_capacity_unavailable",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_infrastructure_error_should_map_to_service_unavailable() {
|
||||
assert_eq!(
|
||||
gateway_error_contract(GatewayErrorKind::ProviderInfrastructureUnavailable),
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"server_error",
|
||||
"provider_infrastructure_unavailable",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_error_should_map_to_openai_retryable_status() {
|
||||
assert_eq!(
|
||||
@@ -122,15 +146,63 @@ fn engine_provider_error_should_preserve_retry_classification() {
|
||||
#[test]
|
||||
fn local_provider_capacity_exhaustion_should_map_to_service_unavailable() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
ProviderErrorKind::AccountCapacityUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::NoAvailableProvider,
|
||||
"no upstream provider is currently available for this request"
|
||||
GatewayErrorKind::AccountCapacityUnavailable,
|
||||
"all eligible upstream accounts are temporarily busy"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_eligible_provider_account_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::NoEligibleAccount,
|
||||
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 provider_infrastructure_failure_should_have_a_distinct_client_contract() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::ProviderInfrastructureUnavailable,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error),
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::ProviderInfrastructureUnavailable,
|
||||
"provider account infrastructure is temporarily unavailable"
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -152,16 +224,18 @@ fn no_eligible_provider_account_should_map_to_service_unavailable() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sent_provider_unavailability_should_remain_bad_gateway() {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
UpstreamSendState::Sent,
|
||||
));
|
||||
fn provider_unavailability_should_remain_bad_gateway_regardless_of_send_state() {
|
||||
for send_state in [UpstreamSendState::NotSent, UpstreamSendState::Sent] {
|
||||
let error = EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::Unavailable,
|
||||
send_state,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::UpstreamUnavailable
|
||||
);
|
||||
assert_eq!(
|
||||
gateway_error_from_engine(&error).kind(),
|
||||
GatewayErrorKind::UpstreamUnavailable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,14 +371,11 @@ fn openai_error_response_should_preserve_only_safe_contract_fields() {
|
||||
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()),
|
||||
)
|
||||
.expect("safe structured upstream error"),
|
||||
),
|
||||
.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);
|
||||
|
||||
@@ -4,7 +4,7 @@ mod models;
|
||||
mod responses;
|
||||
mod router;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime};
|
||||
@@ -18,7 +18,9 @@ use gateway_core::engine::admission::{
|
||||
use gateway_core::engine::continuation::{
|
||||
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{AccountSelectionPolicy, RotationStrategy};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
|
||||
};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
|
||||
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
|
||||
@@ -34,16 +36,23 @@ use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLif
|
||||
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
|
||||
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
|
||||
use gateway_core::routing::{
|
||||
ConfigRevision, ModelCapabilities, ProviderKind, ProviderModel, RuntimeSnapshot,
|
||||
UpstreamModelId,
|
||||
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
|
||||
ProviderModel, RuntimeAccount, RuntimeAccountDirectory, 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: Vec::new(),
|
||||
cors_allowed_origins,
|
||||
request_timeout_seconds: None,
|
||||
request_id_header: "x-request-id".to_owned(),
|
||||
},
|
||||
@@ -87,6 +96,10 @@ impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
|
||||
|
||||
fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
|
||||
let provider = ProviderKind::new(provider_name).expect("provider");
|
||||
let account_directory = Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
|
||||
ProviderAccountId::new("acct_api_test").expect("account ID"),
|
||||
RuntimeAccount::new(provider.clone(), BTreeSet::new()),
|
||||
)])));
|
||||
let capabilities = ModelCapabilities::new(
|
||||
BTreeSet::from([gateway_core::operation::OperationKind::Generate]),
|
||||
Some(16_000),
|
||||
@@ -112,12 +125,16 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
|
||||
vec![ClientPolicy::new(
|
||||
ClientApiKeyId::new("key_api_test").expect("key ID"),
|
||||
PlaintextClientApiKey::new(plaintext).expect("plaintext key"),
|
||||
provider,
|
||||
Arc::new(FrozenAccountScope::new(
|
||||
Arc::clone(&account_directory),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
)),
|
||||
true,
|
||||
RateLimits::unlimited(),
|
||||
)],
|
||||
)
|
||||
.expect("runtime snapshot")
|
||||
.with_account_directory(account_directory)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -253,6 +270,7 @@ struct UnusedContinuation;
|
||||
impl NativeContinuationPort for UnusedContinuation {
|
||||
fn resolve<'a>(
|
||||
&'a self,
|
||||
_: &'a ClientApiKeyId,
|
||||
_: &'a PreviousResponseId,
|
||||
) -> BoxFuture<'a, Result<Option<NativeContinuationPin>, NativeContinuationStoreError>> {
|
||||
Box::pin(async { unreachable!("authentication fixture does not execute") })
|
||||
|
||||
@@ -10,18 +10,20 @@ use gateway_core::engine::execution::{
|
||||
StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::routing::{ModelPresentation, PublicModelId, PublicModelProfile};
|
||||
use gateway_core::routing::{
|
||||
ModelPresentation, ModelServiceTier, PublicModelId, PublicModelProfile,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
struct ModelsExecution {
|
||||
pub(super) struct ModelsExecution {
|
||||
client: AuthenticatedClient,
|
||||
profiles: bool,
|
||||
}
|
||||
|
||||
impl ModelsExecution {
|
||||
fn new() -> Arc<Self> {
|
||||
pub(super) fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_models_test"),
|
||||
profiles: false,
|
||||
@@ -73,7 +75,15 @@ impl ExecutionService for ModelsExecution {
|
||||
)
|
||||
.with_context_window_tokens(Some(500_000))
|
||||
.with_image_input(true)
|
||||
.with_agent_tools(true, 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"),
|
||||
]),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -135,11 +145,25 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
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["service_tiers"], serde_json::json!([]));
|
||||
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!([
|
||||
@@ -163,6 +187,45 @@ async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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())
|
||||
|
||||
@@ -8,7 +8,10 @@ use std::sync::{
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
extract::connect_info::ConnectInfo,
|
||||
http::{HeaderMap, Request, StatusCode, header::AUTHORIZATION},
|
||||
http::{
|
||||
HeaderMap, HeaderValue, Request, StatusCode,
|
||||
header::{AUTHORIZATION, CONTENT_TYPE},
|
||||
},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::future::{BoxFuture, pending};
|
||||
@@ -18,8 +21,8 @@ use gateway_core::engine::execution::{
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
|
||||
SafeUpstreamValue,
|
||||
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind,
|
||||
ProviderError, ProviderErrorKind,
|
||||
};
|
||||
use gateway_core::event::{
|
||||
ContentItem, ContentKind, GatewayEvent, ProtocolWireEvent, ProviderEvent,
|
||||
@@ -101,6 +104,8 @@ struct ContextCaptureExecution {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct CapturedClientContext {
|
||||
public_model: String,
|
||||
wire_model: Option<String>,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
endpoint: String,
|
||||
@@ -109,6 +114,7 @@ struct CapturedClientContext {
|
||||
client_metadata: Option<Value>,
|
||||
protocol_context: Option<Value>,
|
||||
prompt_cache_key: Option<String>,
|
||||
previous_response_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecutionService for ContextCaptureExecution {
|
||||
@@ -124,11 +130,14 @@ impl ExecutionService for ContextCaptureExecution {
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
Vec::new()
|
||||
["model-a", "model-b"]
|
||||
.into_iter()
|
||||
.map(|model| PublicModelId::new(model).expect("catalog model"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
|
||||
true
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, model: &PublicModelId) -> bool {
|
||||
matches!(model.as_str(), "model-a" | "model-b")
|
||||
}
|
||||
|
||||
fn start(
|
||||
@@ -136,11 +145,16 @@ impl ExecutionService for ContextCaptureExecution {
|
||||
request: StartExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let public_model = request.public_model.as_str().to_owned();
|
||||
let operation_kind = request.operation.kind();
|
||||
let generation = match &request.operation {
|
||||
Operation::Generate(generation) => Some(generation),
|
||||
_ => None,
|
||||
};
|
||||
let wire_model = generation
|
||||
.and_then(|generation| generation.protocol_payload().body().get("model"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
let (client_metadata, protocol_context, prompt_cache_key, input) =
|
||||
generation.map_or((None, None, None, None), |generation| {
|
||||
let payload = generation.protocol_payload();
|
||||
@@ -152,7 +166,14 @@ impl ExecutionService for ContextCaptureExecution {
|
||||
payload.body().get("input").cloned(),
|
||||
)
|
||||
});
|
||||
let previous_response_id = request
|
||||
.metadata
|
||||
.previous_response_id
|
||||
.as_ref()
|
||||
.map(|value| value.as_str().to_owned());
|
||||
*self.observed.lock().expect("context capture lock") = Some(CapturedClientContext {
|
||||
public_model,
|
||||
wire_model,
|
||||
client_ip: request.metadata.client_ip,
|
||||
user_agent: request.metadata.user_agent,
|
||||
endpoint: request.metadata.endpoint,
|
||||
@@ -161,6 +182,7 @@ impl ExecutionService for ContextCaptureExecution {
|
||||
client_metadata,
|
||||
protocol_context,
|
||||
prompt_cache_key,
|
||||
previous_response_id,
|
||||
});
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
@@ -503,14 +525,18 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback()
|
||||
assert_eq!(
|
||||
captured_client_context(headers, peer).await,
|
||||
CapturedClientContext {
|
||||
public_model: "smart-code".to_owned(),
|
||||
wire_model: Some("smart-code".to_owned()),
|
||||
client_ip: Some("198.51.100.1".parse().expect("expected IP")),
|
||||
user_agent: Some("Codex-CLI/1.0".to_owned()),
|
||||
endpoint: "/v1/responses".to_owned(),
|
||||
operation_kind: OperationKind::Generate,
|
||||
input: Some(json!("hello")),
|
||||
client_metadata: None,
|
||||
// 客户端 User-Agent 仅用于本地展示,不再透传给上游指纹上下文。
|
||||
protocol_context: None,
|
||||
prompt_cache_key: None,
|
||||
previous_response_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -533,14 +559,58 @@ async fn request_context_should_resolve_forwarded_precedence_and_peer_fallback()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_request_should_forward_safe_codex_headers_without_projecting_xai_headers() {
|
||||
async fn stale_model_catalog_should_not_block_a_new_model_request() {
|
||||
let model = " gpt-future-codex ";
|
||||
let captured = captured_http_request(
|
||||
"openai",
|
||||
json!({"model": model, "input": "hello"}),
|
||||
HeaderMap::new(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(captured.public_model, model);
|
||||
assert_eq!(captured.wire_model.as_deref(), Some(model));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_request_should_pass_opaque_previous_response_id_to_core() {
|
||||
for response_id in [
|
||||
format!("resp_{}", "x".repeat(257)),
|
||||
"resp_control\0opaque".to_owned(),
|
||||
String::new(),
|
||||
] {
|
||||
let captured = captured_http_request(
|
||||
"openai",
|
||||
json!({
|
||||
"model": "smart-code",
|
||||
"input": "continue",
|
||||
"previous_response_id": response_id.clone()
|
||||
}),
|
||||
HeaderMap::new(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
captured.previous_response_id.as_deref(),
|
||||
Some(response_id.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_request_should_forward_codex_headers_without_projecting_xai_headers() {
|
||||
let peer = "192.0.2.10:443".parse().expect("peer address");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-codex-turn-state", "turn-state".parse().expect("header"));
|
||||
headers.insert("conversation-id", "conversation-1".parse().expect("header"));
|
||||
headers.insert("session-id", "session-1".parse().expect("header"));
|
||||
headers.insert("x-grok-turn-idx", "7".parse().expect("header"));
|
||||
headers.insert("x-openai-subagent", "compact".parse().expect("header"));
|
||||
headers.insert(
|
||||
"x-openai-subagent",
|
||||
"future_codex_mode".parse().expect("header"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-openai-internal-codex-responses-lite",
|
||||
"true".parse().expect("header"),
|
||||
@@ -566,10 +636,33 @@ async fn http_request_should_forward_safe_codex_headers_without_projecting_xai_h
|
||||
.client_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("x-openai-subagent")),
|
||||
Some(&json!("compact"))
|
||||
Some(&json!("future_codex_mode"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_header_should_not_replace_non_object_client_metadata() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-openai-subagent",
|
||||
"future_codex_mode".parse().expect("header"),
|
||||
);
|
||||
|
||||
let captured = captured_http_request(
|
||||
"openai",
|
||||
json!({
|
||||
"model": "smart-code",
|
||||
"input": "preserve metadata",
|
||||
"client_metadata": "opaque-client-value"
|
||||
}),
|
||||
headers,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(captured.client_metadata, Some(json!("opaque-client-value")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn xai_private_headers_should_not_enter_openai_request_facts() {
|
||||
let peer = "192.0.2.10:443".parse().expect("peer address");
|
||||
@@ -632,6 +725,43 @@ async fn review_route_should_inject_review_subagent_and_record_its_endpoint() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_route_should_not_replace_non_object_client_metadata() {
|
||||
let observed = Arc::new(Mutex::new(None));
|
||||
let execution = Arc::new(ContextCaptureExecution {
|
||||
observed: Arc::clone(&observed),
|
||||
client: authenticated_client("sk_context_test"),
|
||||
});
|
||||
let request = Request::post("/v1/responses/review")
|
||||
.header(AUTHORIZATION, "Bearer sk_context_test")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"model": "smart-code",
|
||||
"input": "review this",
|
||||
"client_metadata": ["opaque", "client", "value"]
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("review request");
|
||||
|
||||
let response = api_router(execution)
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("review response");
|
||||
let captured = observed
|
||||
.lock()
|
||||
.expect("review capture lock")
|
||||
.clone()
|
||||
.expect("captured review request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(
|
||||
captured.client_metadata,
|
||||
Some(json!(["opaque", "client", "value"]))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
@@ -653,19 +783,22 @@ async fn streaming_encodes_first_frame_before_commit_and_http_delivery() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_response_forwards_only_allowlisted_provider_headers() {
|
||||
async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresentable_or_sensitive_ones()
|
||||
{
|
||||
let trace = Arc::new(Trace::default());
|
||||
let headers = vec![
|
||||
ProviderResponseHeader::new(
|
||||
"x-models-etag",
|
||||
SafeUpstreamValue::new("models-v2").expect("etag"),
|
||||
)
|
||||
.expect("safe header"),
|
||||
ProviderResponseHeader::new("x-models-etag", Bytes::from_static(b"models-v2")),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
|
||||
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
|
||||
ProviderResponseHeader::new(
|
||||
"authorization",
|
||||
SafeUpstreamValue::new("should-not-cross-boundary").expect("header value"),
|
||||
)
|
||||
.expect("syntactically safe header"),
|
||||
Bytes::from_static(b"should-not-cross-boundary"),
|
||||
),
|
||||
ProviderResponseHeader::new("connection", Bytes::from_static(b"x-hop-secret")),
|
||||
ProviderResponseHeader::new("x-hop-secret", Bytes::from_static(b"hop-secret")),
|
||||
ProviderResponseHeader::new("content-type", Bytes::from_static(b"application/private")),
|
||||
ProviderResponseHeader::new("bad\0name", Bytes::from_static(b"unrepresentable")),
|
||||
];
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
@@ -685,7 +818,29 @@ async fn streaming_response_forwards_only_allowlisted_provider_headers() {
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("models-v2")
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get_all("x-future-multi")
|
||||
.iter()
|
||||
.map(HeaderValue::as_bytes)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![b"first".as_slice(), b"second".as_slice()]
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-future-bytes")
|
||||
.map(HeaderValue::as_bytes),
|
||||
Some(b"\xffopaque".as_slice())
|
||||
);
|
||||
assert!(response.headers().get("authorization").is_none());
|
||||
assert!(response.headers().get("connection").is_none());
|
||||
assert!(response.headers().get("x-hop-secret").is_none());
|
||||
assert_eq!(
|
||||
response.headers().get(CONTENT_TYPE),
|
||||
Some(&HeaderValue::from_static("text/event-stream"))
|
||||
);
|
||||
std::mem::forget(response);
|
||||
}
|
||||
|
||||
@@ -801,7 +956,107 @@ async fn streaming_rate_limit_before_first_frame_should_persist_the_returned_429
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_postcommit_encode_failure_cancels_without_second_commit() {
|
||||
async fn streaming_upstream_http_failure_before_first_frame_should_preserve_raw_response() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let raw_body = Bytes::from_static(
|
||||
b"{\"error\":{\"message\":\"rate limited\",\"future_field\":{\"kept\":true}},\"top_level\":17}\x00",
|
||||
);
|
||||
let error = ProviderError::new(ProviderErrorKind::RateLimited, UpstreamSendState::Sent)
|
||||
.with_client_visible_upstream_response(
|
||||
ClientVisibleUpstreamResponse::new(
|
||||
429,
|
||||
Some(b"application/problem+json; charset=utf-8".to_vec()),
|
||||
raw_body.clone(),
|
||||
)
|
||||
.with_headers(vec![
|
||||
ProviderResponseHeader::new("retry-after", Bytes::from_static(b"17")),
|
||||
ProviderResponseHeader::new("x-request-id", Bytes::from_static(b"req-failure")),
|
||||
ProviderResponseHeader::new("x-future-error", Bytes::from_static(b"first")),
|
||||
ProviderResponseHeader::new("x-future-error", Bytes::from_static(b"second")),
|
||||
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
|
||||
]),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![NextStep::Error(EngineError::Provider(error))],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.map(|value| value.as_bytes()),
|
||||
Some(b"application/problem+json; charset=utf-8".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("retry-after")
|
||||
.map(|value| value.as_bytes()),
|
||||
Some(b"17".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-request-id")
|
||||
.map(|value| value.as_bytes()),
|
||||
Some(b"req-failure".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get_all("x-future-error")
|
||||
.iter()
|
||||
.map(|value| value.as_bytes())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![b"first".as_slice(), b"second".as_slice()]
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-future-bytes")
|
||||
.map(|value| value.as_bytes()),
|
||||
Some(b"\xffopaque".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read raw error body"),
|
||||
raw_body
|
||||
);
|
||||
assert_eq!(trace.client_statuses(), vec![429]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffered_upstream_http_failure_should_preserve_raw_response() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let raw_body = Bytes::from_static(b"not-json\xffstill-upstream");
|
||||
let error = ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::Sent)
|
||||
.with_client_visible_upstream_response(ClientVisibleUpstreamResponse::new(
|
||||
503,
|
||||
Some(b"application/octet-stream".to_vec()),
|
||||
raw_body.clone(),
|
||||
));
|
||||
let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()])
|
||||
.with_collect_error(EngineError::Provider(error));
|
||||
|
||||
let response = collect_execution_response(Box::new(session)).await;
|
||||
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read raw error body"),
|
||||
raw_body
|
||||
);
|
||||
assert_eq!(trace.client_statuses(), vec![503]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_canonical_identity_change_does_not_interrupt_wire_delivery() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
@@ -811,7 +1066,7 @@ async fn streaming_postcommit_encode_failure_cancels_without_second_commit() {
|
||||
mismatched_terminal_event(),
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::FinalizeCancelled,
|
||||
NextStep::FinalizeSuccess,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -820,12 +1075,16 @@ async fn streaming_postcommit_encode_failure_cancels_without_second_commit() {
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
|
||||
assert!(String::from_utf8_lossy(&body).contains("response.failed"));
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
assert!(body.contains("response.completed"));
|
||||
assert!(body.contains("resp_other"));
|
||||
assert!(!body.contains("response.failed"));
|
||||
assert!(body.ends_with("data: [DONE]\n\n"));
|
||||
assert_eq!(trace.client_statuses(), vec![200]);
|
||||
assert!(trace.is_cancelled());
|
||||
assert!(!trace.is_cancelled());
|
||||
assert_eq!(
|
||||
trace.snapshot(),
|
||||
vec!["next_event", "commit", "next_event", "cancel_finalize"]
|
||||
vec!["next_event", "commit", "next_event", "next_end"]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -874,6 +1133,38 @@ async fn streaming_success_should_emit_terminal_event_and_done_marker() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_finalized_unknown_wire_should_end_cleanly_without_synthetic_failure() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let raw = Bytes::from_static(
|
||||
b"event: response.future_terminal\ndata: {\"type\":\"response.future_terminal\",\"opaque\":true}\n\n",
|
||||
);
|
||||
let event = ProviderEvent::wire(
|
||||
ProtocolWireEvent::raw_sse("openai", raw.clone()).expect("raw future event"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(delivery_provider(
|
||||
event,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::FinalizeSuccess,
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read transparent SSE body");
|
||||
|
||||
assert!(body.as_ref().starts_with(raw.as_ref()));
|
||||
assert!(body.as_ref().ends_with(b"data: [DONE]\n\n"));
|
||||
assert!(!String::from_utf8_lossy(&body).contains("response.failed"));
|
||||
assert!(!trace.is_cancelled());
|
||||
assert_eq!(trace.snapshot(), vec!["next_event", "commit", "next_end"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_completed_event_without_finalized_execution_should_fail_closed() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
@@ -942,14 +1233,11 @@ async fn streaming_empty_terminal_should_emit_failure_done_and_cancel_execution(
|
||||
async fn streaming_error_should_emit_client_visible_upstream_details() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error = ProviderError::new(ProviderErrorKind::RateLimited, 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()),
|
||||
)
|
||||
.expect("safe structured upstream error"),
|
||||
);
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Your Codex quota is exhausted",
|
||||
Some("quota_exhausted".to_owned()),
|
||||
Some("rate_limit_error".to_owned()),
|
||||
));
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
@@ -1055,6 +1343,86 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn atomic_uncommitted_upstream_failure_batch_should_be_forwarded_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let raw_created = Bytes::from_static(
|
||||
b"event: response.created\r\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_atomic_api\",\"model\":\"public-model\",\"status\":\"in_progress\"}}\r\n\r\n",
|
||||
);
|
||||
let raw_failure = Bytes::from_static(
|
||||
b"event: response.failed\r\ndata: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_atomic_api\",\"status\":\"failed\",\"error\":{\"code\":\"rate_limit_exceeded\",\"message\":\"atomic upstream marker\"}}}\r\n\r\n",
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_atomic_api",
|
||||
"model": "public-model",
|
||||
"status": "in_progress"
|
||||
}
|
||||
}),
|
||||
raw_created.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let failed_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_atomic_api",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "rate_limit_exceeded",
|
||||
"message": "atomic upstream marker"
|
||||
}
|
||||
}
|
||||
}),
|
||||
raw_failure.clone(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream failure wire"),
|
||||
);
|
||||
let batch = CoordinatedEvent::try_batch(
|
||||
vec![started_wire, failed_wire],
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)
|
||||
.expect("atomic failure delivery");
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(batch),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::RateLimited,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
|
||||
assert_eq!(body.matches("atomic upstream marker").count(), 1);
|
||||
assert_eq!(body.matches("event: response.failed").count(), 1);
|
||||
assert!(body.contains(std::str::from_utf8(&raw_created).expect("created frame")));
|
||||
assert!(body.contains(std::str::from_utf8(&raw_failure).expect("failure frame")));
|
||||
assert!(!body.contains("upstream capacity is temporarily unavailable"));
|
||||
assert!(body.ends_with("data: [DONE]\n\n"));
|
||||
assert_eq!(trace.snapshot(), vec!["next_event", "commit", "next_error"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_second_commit_request_should_fail_and_finalize_once() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
@@ -1132,13 +1500,10 @@ async fn buffered_response_commits_only_after_complete_json_is_encoded() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn buffered_response_forwards_allowlisted_provider_headers() {
|
||||
async fn buffered_response_forwards_long_ordinary_provider_header_values() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let header = ProviderResponseHeader::new(
|
||||
"openai-model",
|
||||
SafeUpstreamValue::new("gpt-5.5-effective").expect("model header"),
|
||||
)
|
||||
.expect("safe header");
|
||||
let model = format!("gpt-{}", "x".repeat(512));
|
||||
let header = ProviderResponseHeader::new("openai-model", Bytes::from(model.clone()));
|
||||
let session = FakeSession::buffered(Arc::clone(&trace), vec![started(), completed()])
|
||||
.with_response_headers(vec![header]);
|
||||
|
||||
@@ -1149,7 +1514,7 @@ async fn buffered_response_forwards_allowlisted_provider_headers() {
|
||||
.headers()
|
||||
.get("openai-model")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("gpt-5.5-effective")
|
||||
Some(model.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ mod http;
|
||||
mod websocket;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use bytes::Bytes;
|
||||
use gateway_api::openai::responses::{
|
||||
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, OpenAiResponsesEncoder,
|
||||
@@ -117,6 +118,28 @@ fn decoder_should_preserve_the_openai_body_and_only_derive_stable_routing_facts(
|
||||
assert!(decoded.metadata().store());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_opaque_client_model_values() {
|
||||
for model in [
|
||||
format!("future-{}", "x".repeat(512)),
|
||||
"future\0model".to_owned(),
|
||||
" future-model ".to_owned(),
|
||||
] {
|
||||
let decoded = generate_request(json!({
|
||||
"model": model.clone(),
|
||||
"input": "hello"
|
||||
}));
|
||||
|
||||
assert_eq!(decoded.metadata().public_model(), model);
|
||||
assert_eq!(
|
||||
openai_wire_body(&decoded)
|
||||
.get("model")
|
||||
.and_then(Value::as_str),
|
||||
Some(model.as_str())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_compaction_trigger_for_the_openai_provider() {
|
||||
let decoded = generate_request(json!({
|
||||
@@ -149,6 +172,21 @@ fn decoder_should_keep_transport_override_out_of_the_openai_wire_body() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_unrecognized_use_websocket_values() {
|
||||
let decoded = generate_request(json!({
|
||||
"model": "smart-code",
|
||||
"input": "hello",
|
||||
"use_websocket": {"future": "transport-mode"}
|
||||
}));
|
||||
|
||||
assert_eq!(
|
||||
openai_wire_body(&decoded).get("use_websocket"),
|
||||
Some(&json!({"future": "transport-mode"}))
|
||||
);
|
||||
assert!(!openai_protocol_context(&decoded).contains_key("use_websocket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
|
||||
let mut headers = HeaderMap::new();
|
||||
@@ -170,10 +208,155 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
|
||||
("turn_state".to_owned(), json!("turn-state")),
|
||||
("turn_metadata".to_owned(), json!("{\"kind\":\"review\"}")),
|
||||
("conversation_id".to_owned(), json!("conversation")),
|
||||
(
|
||||
"opaque_request_headers".to_owned(),
|
||||
json!([
|
||||
["x-codex-turn-state", STANDARD.encode(b"turn-state")],
|
||||
[
|
||||
"x-codex-turn-metadata",
|
||||
STANDARD.encode(br#"{"kind":"review"}"#)
|
||||
],
|
||||
["conversation-id", STANDARD.encode(b"conversation")]
|
||||
]),
|
||||
),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.append(
|
||||
"x-openai-future-mode",
|
||||
HeaderValue::from_static("future-ascii"),
|
||||
);
|
||||
headers.append(
|
||||
"x-openai-future-mode",
|
||||
HeaderValue::from_bytes(b"\x80\xff").expect("opaque header bytes"),
|
||||
);
|
||||
headers.append("version", HeaderValue::from_static("future-v1"));
|
||||
headers.append(
|
||||
"version",
|
||||
HeaderValue::from_bytes(b"future-\x80").expect("opaque known header bytes"),
|
||||
);
|
||||
headers.insert(
|
||||
"accept",
|
||||
HeaderValue::from_static("application/vnd.openai.responses+json"),
|
||||
);
|
||||
headers.insert(
|
||||
"content-type",
|
||||
HeaderValue::from_static("application/vnd.openai.responses.request+json"),
|
||||
);
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("Codex future-client"),
|
||||
);
|
||||
headers.insert("originator", HeaderValue::from_static("future-codex"));
|
||||
headers.append(
|
||||
"openai-beta",
|
||||
HeaderValue::from_static("future_responses=v2"),
|
||||
);
|
||||
headers.append("openai-beta", HeaderValue::from_static("future_tools=v3"));
|
||||
headers.insert(
|
||||
"x-openai-internal-codex-residency",
|
||||
HeaderValue::from_static("future-region"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-turn-state",
|
||||
HeaderValue::from_static("same-account-turn"),
|
||||
);
|
||||
|
||||
headers.insert(
|
||||
"connection",
|
||||
HeaderValue::from_static("keep-alive, x-private-hop"),
|
||||
);
|
||||
headers.insert("x-private-hop", HeaderValue::from_static("drop-me"));
|
||||
headers.insert("host", HeaderValue::from_static("downstream.invalid"));
|
||||
headers.insert("content-length", HeaderValue::from_static("999"));
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer client"));
|
||||
headers.insert("x-api-key", HeaderValue::from_static("client-key"));
|
||||
headers.insert("cookie", HeaderValue::from_static("client=cookie"));
|
||||
headers.insert(
|
||||
"chatgpt-account-id",
|
||||
HeaderValue::from_static("client-account"),
|
||||
);
|
||||
headers.insert(
|
||||
"chatgpt-project-id",
|
||||
HeaderValue::from_static("client-project"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-codex-installation-id",
|
||||
HeaderValue::from_static("client-installation"),
|
||||
);
|
||||
headers.insert(
|
||||
"x-oai-attestation",
|
||||
HeaderValue::from_static("client-attestation"),
|
||||
);
|
||||
headers.insert("x-oai-is", HeaderValue::from_static("client-is"));
|
||||
headers.insert(
|
||||
"x-oai-is-update",
|
||||
HeaderValue::from_static("client-is-update"),
|
||||
);
|
||||
|
||||
let decoded =
|
||||
decode_request_with_headers(br#"{"model":"smart-code","input":"hello"}"#, &headers)
|
||||
.expect("opaque request headers should not affect body decoding");
|
||||
let entries = openai_protocol_context(&decoded)
|
||||
.get("opaque_request_headers")
|
||||
.and_then(Value::as_array)
|
||||
.expect("opaque request header context");
|
||||
let values = |target: &str| {
|
||||
entries
|
||||
.iter()
|
||||
.filter_map(Value::as_array)
|
||||
.filter(|entry| entry.first().and_then(Value::as_str) == Some(target))
|
||||
.filter_map(|entry| entry.get(1).and_then(Value::as_str))
|
||||
.map(|encoded| STANDARD.decode(encoded).expect("valid base64 header value"))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
values("x-openai-future-mode"),
|
||||
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
values("version"),
|
||||
vec![b"future-v1".to_vec(), b"future-\x80".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
values("openai-beta"),
|
||||
vec![b"future_responses=v2".to_vec(), b"future_tools=v3".to_vec()]
|
||||
);
|
||||
for preserved in [
|
||||
"accept",
|
||||
"content-type",
|
||||
"x-openai-internal-codex-residency",
|
||||
"x-codex-turn-state",
|
||||
] {
|
||||
assert_eq!(values(preserved).len(), 1, "missing {preserved}");
|
||||
}
|
||||
for excluded in [
|
||||
"connection",
|
||||
"x-private-hop",
|
||||
"host",
|
||||
"content-length",
|
||||
"authorization",
|
||||
"x-api-key",
|
||||
"cookie",
|
||||
"chatgpt-account-id",
|
||||
"chatgpt-project-id",
|
||||
"x-codex-installation-id",
|
||||
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
|
||||
"user-agent",
|
||||
"originator",
|
||||
"x-oai-attestation",
|
||||
"x-oai-is",
|
||||
"x-oai-is-update",
|
||||
] {
|
||||
assert!(values(excluded).is_empty(), "unexpected {excluded}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
|
||||
let secret = "nested-private-value";
|
||||
@@ -191,25 +374,51 @@ fn decoder_should_preserve_unknown_nested_values_without_debug_disclosure() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_continuation_and_mark_the_routing_requirement() {
|
||||
let decoded = generate_request(json!({
|
||||
"model": "smart-code",
|
||||
"input": "continue",
|
||||
"previous_response_id": "resp_private_continuation"
|
||||
}));
|
||||
fn decoder_should_preserve_unknown_arbitrary_precision_numbers() {
|
||||
let number = "12345678901234567890123456789012345678901234567890";
|
||||
let body = format!(r#"{{"model":"smart-code","input":"hello","future_number":{number}}}"#);
|
||||
|
||||
assert!(matches!(
|
||||
decoded.metadata().continuation(),
|
||||
ContinuationIntent::PreviousResponseId(value) if value == "resp_private_continuation"
|
||||
));
|
||||
assert!(
|
||||
decoded
|
||||
.operation()
|
||||
.capability_requirements()
|
||||
.features()
|
||||
.contains(&Feature::NativeContinuation)
|
||||
);
|
||||
assert!(!format!("{decoded:?}").contains("resp_private_continuation"));
|
||||
let decoded = decode_request_with_headers(body.as_bytes(), &HeaderMap::new())
|
||||
.expect("opaque numeric field should decode");
|
||||
let encoded = serde_json::to_string(openai_wire_body(&decoded)).expect("encode request body");
|
||||
|
||||
assert!(encoded.contains(&format!(r#""future_number":{number}"#)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_should_preserve_opaque_continuation_and_mark_the_routing_requirement() {
|
||||
for response_id in [
|
||||
"resp_private_continuation".to_owned(),
|
||||
format!("resp_{}\0opaque", "x".repeat(257)),
|
||||
String::new(),
|
||||
] {
|
||||
let decoded = generate_request(json!({
|
||||
"model": "smart-code",
|
||||
"input": "continue",
|
||||
"previous_response_id": response_id.clone()
|
||||
}));
|
||||
|
||||
assert!(matches!(
|
||||
decoded.metadata().continuation(),
|
||||
ContinuationIntent::PreviousResponseId(value) if value == &response_id
|
||||
));
|
||||
assert_eq!(
|
||||
openai_wire_body(&decoded)
|
||||
.get("previous_response_id")
|
||||
.and_then(Value::as_str),
|
||||
Some(response_id.as_str())
|
||||
);
|
||||
assert!(
|
||||
decoded
|
||||
.operation()
|
||||
.capability_requirements()
|
||||
.features()
|
||||
.contains(&Feature::NativeContinuation)
|
||||
);
|
||||
if !response_id.is_empty() {
|
||||
assert!(!format!("{decoded:?}").contains(&response_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -242,10 +451,12 @@ fn decoder_should_not_reject_large_bodies_using_catalog_context_limits() {
|
||||
.to_string();
|
||||
let decoded = decode_request(body.as_bytes()).expect("large request should decode");
|
||||
|
||||
let plan = super::snapshot("sk_context_test", "openai")
|
||||
let snapshot = super::snapshot("sk_context_test", "openai");
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&PublicModelId::new(decoded.metadata().public_model()).expect("public model"),
|
||||
decoded.operation(),
|
||||
snapshot.all_account_scope(),
|
||||
&RoutingContext::default(),
|
||||
)
|
||||
.expect("large body must not be locally context-gated");
|
||||
@@ -316,7 +527,7 @@ fn transparent_encoder_should_forward_raw_sse_frames_unknown_events_and_terminal
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let frames = events
|
||||
.iter()
|
||||
.flat_map(|event| encoder.push_sse(event).expect("wire event should encode"))
|
||||
.flat_map(|event| encoder.push_sse(event))
|
||||
.collect::<Vec<_>>();
|
||||
let response = encoder.finish().expect("wire response should finish");
|
||||
let body = frames
|
||||
@@ -370,15 +581,11 @@ fn transparent_encoder_should_use_identical_json_for_sse_and_websocket() {
|
||||
|
||||
let sse = events
|
||||
.iter()
|
||||
.flat_map(|event| sse_encoder.push_sse(event).expect("SSE wire event"))
|
||||
.flat_map(|event| sse_encoder.push_sse(event))
|
||||
.collect::<Vec<_>>();
|
||||
let websocket = events
|
||||
.iter()
|
||||
.flat_map(|event| {
|
||||
websocket_encoder
|
||||
.push_websocket(event)
|
||||
.expect("WebSocket wire event")
|
||||
})
|
||||
.flat_map(|event| websocket_encoder.push_websocket(event))
|
||||
.collect::<Vec<_>>();
|
||||
let sse_text = String::from_utf8(
|
||||
sse.iter()
|
||||
@@ -400,8 +607,40 @@ fn transparent_encoder_should_use_identical_json_for_sse_and_websocket() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_encoder_should_require_matching_canonical_and_wire_terminals() {
|
||||
fn transparent_encoder_should_follow_wire_when_canonical_identity_changes() {
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let started = openai_wire_event(
|
||||
vec![GatewayEvent::Started(ResponseMeta::new(
|
||||
"resp_1", "gpt-test",
|
||||
))],
|
||||
"response.created",
|
||||
json!({"type": "response.created", "response": {"id": "wire_resp_1"}}),
|
||||
);
|
||||
encoder.push_sse(&started);
|
||||
let changed_identity = openai_wire_event(
|
||||
vec![GatewayEvent::Completed(ResponseMeta::new(
|
||||
"resp_2", "gpt-test",
|
||||
))],
|
||||
"response.completed",
|
||||
json!({"type": "response.completed", "response": {"id": "wire_resp_2"}}),
|
||||
);
|
||||
|
||||
let completed = encoder.push_sse(&changed_identity);
|
||||
|
||||
assert_eq!(completed.len(), 1);
|
||||
assert!(encoder.is_completed());
|
||||
assert_eq!(encoder.response_id(), Some("wire_resp_2"));
|
||||
assert_eq!(
|
||||
encoder
|
||||
.finish()
|
||||
.expect("wire terminal remains authoritative"),
|
||||
json!({"id": "wire_resp_2"})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_encoder_should_only_require_wire_terminal_for_buffered_conversion() {
|
||||
let mut missing_terminal = OpenAiResponsesEncoder::new();
|
||||
let started = openai_wire_event(
|
||||
vec![GatewayEvent::Started(ResponseMeta::new(
|
||||
"resp_1", "gpt-test",
|
||||
@@ -409,24 +648,8 @@ fn transparent_encoder_should_require_matching_canonical_and_wire_terminals() {
|
||||
"response.created",
|
||||
json!({"type": "response.created", "response": {"id": "resp_1"}}),
|
||||
);
|
||||
encoder.push_sse(&started).expect("started event");
|
||||
let changed_identity = openai_wire_event(
|
||||
vec![GatewayEvent::Completed(ResponseMeta::new(
|
||||
"resp_2", "gpt-test",
|
||||
))],
|
||||
"response.completed",
|
||||
json!({"type": "response.completed", "response": {"id": "resp_2"}}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
encoder
|
||||
.push_sse(&changed_identity)
|
||||
.expect_err("identity changes must fail"),
|
||||
ResponseEncodeError::WireIdentityChanged
|
||||
);
|
||||
|
||||
let mut missing_terminal = OpenAiResponsesEncoder::new();
|
||||
missing_terminal.push_sse(&started).expect("started event");
|
||||
missing_terminal.push_sse(&started);
|
||||
assert_eq!(
|
||||
missing_terminal
|
||||
.finish()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user