mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a709acb149 | ||
|
|
466842acd8 | ||
|
|
b91f28f95b | ||
|
|
87164db3da | ||
|
|
ad16dec3db | ||
|
|
a0da0f4713 | ||
|
|
65e5cde60d | ||
|
|
0f0b464f4e | ||
|
|
bd223f6540 | ||
|
|
168be0ca68 | ||
|
|
f2e91bbdaa | ||
|
|
d0c9248478 | ||
|
|
22c697bbff | ||
|
|
08d518324a | ||
|
|
dfacb20e43 | ||
|
|
55c9291c35 | ||
|
|
6e8e787b82 | ||
|
|
2a0a50adc5 | ||
|
|
5e13b6af6a | ||
|
|
bd2454a5b6 | ||
|
|
2640a03219 | ||
|
|
7d0e9a0a20 | ||
|
|
09982d7221 | ||
|
|
c4db1bb244 | ||
|
|
e7c2f025cc | ||
|
|
4ee294b208 | ||
|
|
93f1a82d3f | ||
|
|
c46f98cd2b | ||
|
|
5c69b1b082 | ||
|
|
6a78fb9232 | ||
|
|
02a03df79a | ||
|
|
2aca8fb79b | ||
|
|
891b227b69 | ||
|
|
04096dddb7 | ||
|
|
3a9b1c479f | ||
|
|
944d3f299e | ||
|
|
56451601fb | ||
|
|
4d53ef6068 | ||
|
|
a62bb196f7 | ||
|
|
7ad0ca48c5 | ||
|
|
e25639c11b | ||
|
|
8abac080a2 | ||
|
|
37cd059531 | ||
|
|
c46910df22 |
@@ -79,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@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'release/version.yaml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -163,6 +163,15 @@ jobs:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
upload_frontend: true
|
||||
|
||||
container:
|
||||
needs: [release-info]
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ needs.release-info.outputs.git_sha }}
|
||||
|
||||
build-docker-binary:
|
||||
name: build-docker-binary (${{ matrix.os }}-${{ matrix.arch }})
|
||||
needs: release-info
|
||||
@@ -497,7 +506,7 @@ jobs:
|
||||
exit-code: '1'
|
||||
|
||||
publish:
|
||||
needs: [release-info, package-assets, build-docker-image]
|
||||
needs: [release-info, package-assets, build-docker-image, container]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| 领域 | 当前能力 |
|
||||
| --- | --- |
|
||||
| 数据面 | Responses JSON、SSE、WebSocket、review 子代理、Images 生成/编辑与模型目录 |
|
||||
| 数据面 | Responses JSON、SSE、WebSocket(含 Codex 子代理语义)、Images 生成/编辑与模型目录 |
|
||||
| Provider | 固定 `openai`、`xai` 两个 Provider,各自拥有 OAuth、credential、额度、目录与 transport |
|
||||
| 路由 | Client Key 限定账号分组;按模型能力编译 Provider 候选;支持全局精确模型映射、会话亲和和安全 fallback |
|
||||
| 透明边界 | OpenAI 保留未知请求字段及字段顺序,并原样转发 SSE、WebSocket 与 Images 业务字节;xAI 在 Provider 内转换协议 |
|
||||
@@ -136,12 +136,9 @@ curl http://127.0.0.1:8080/v1/responses \
|
||||
| --- | --- |
|
||||
| `POST /v1/responses` | JSON 响应或 SSE Responses 流 |
|
||||
| `GET /v1/responses` | Responses WebSocket 升级 |
|
||||
| `POST /v1/responses/review` | review 子代理请求 |
|
||||
| `POST /v1/images/generations` | OpenAI Images 生成 JSON 透明转发 |
|
||||
| `POST /v1/images/edits` | OpenAI Images 编辑 JSON 透明转发 |
|
||||
| `GET /v1/models` | 当前 Client Key 账号范围内的模型列表 |
|
||||
| `GET /v1/models/catalog` | Codex 客户端展示用模型目录 |
|
||||
| `GET /v1/models/{model_id}/info` | Codex 客户端展示用模型详情 |
|
||||
| `GET /v1/models/{model_id}` | OpenAI 兼容模型详情 |
|
||||
|
||||
所有 `/v1/*` 路由都需要 Client Key。Images 是 OpenAI Provider 自有端点,不读取模型字段,也不参与
|
||||
|
||||
Generated
+303
-32
@@ -49,13 +49,13 @@ checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures 0.3.0",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -116,7 +116,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -206,9 +206,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.142.0"
|
||||
version = "1.144.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
|
||||
checksum = "30dc8bf6baaf7d46336a0ca2c69f223d9b90d7a801fb3e28f7ea17b00dc6b1de"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -331,9 +331,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.3.0"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
|
||||
checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -375,9 +375,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.13.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
|
||||
checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -599,11 +599,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
"provider-xai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
@@ -954,6 +954,29 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.13.1",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser-macros"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -1005,6 +1028,27 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -1013,7 +1057,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1054,6 +1097,21 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "dtoa-short"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
|
||||
dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
@@ -1075,6 +1133,12 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ego-tree"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
@@ -1318,7 +1382,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1363,6 +1427,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
@@ -1416,6 +1481,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
@@ -1438,6 +1504,7 @@ dependencies = [
|
||||
"gateway-core",
|
||||
"hex",
|
||||
"reqwest",
|
||||
"scraper",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1501,6 +1568,15 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getopts"
|
||||
version = "0.2.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
@@ -1586,6 +1662,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
@@ -1656,6 +1737,16 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
@@ -2046,11 +2137,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.16.4"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
|
||||
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2059,6 +2150,17 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2127,6 +2229,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -2232,13 +2340,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"getrandom 0.4.3",
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2262,13 +2369,24 @@ version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
@@ -2281,6 +2399,27 @@ dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
@@ -2291,19 +2430,42 @@ dependencies = [
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
@@ -2322,6 +2484,15 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -2374,6 +2545,12 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "primefield"
|
||||
version = "0.14.0"
|
||||
@@ -2902,6 +3079,21 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
"getopts",
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.8.1"
|
||||
@@ -2949,6 +3141,25 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
@@ -2982,7 +3193,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3032,6 +3243,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
@@ -3371,6 +3591,30 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.13.1",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -3407,9 +3651,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3460,6 +3704,15 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.20"
|
||||
@@ -3477,7 +3730,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3875,6 +4128,12 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.7.1"
|
||||
@@ -3913,9 +4172,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.1"
|
||||
version = "1.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
@@ -4058,6 +4317,18 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
|
||||
@@ -79,6 +79,7 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
vec![openai.admin_provider(), xai.admin_provider()],
|
||||
core.snapshot_control(),
|
||||
core.account_probe(),
|
||||
host.client_distribution_resolver(),
|
||||
host.system_operations(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
use provider_openai::transport::tls::ensure_rustls_provider;
|
||||
use provider_openai::ensure_rustls_provider;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
ensure_rustls_provider();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
//! docs/architecture.md 依赖 DAG 与生产源码纪律的 workspace 级机器校验。
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use syn::Item;
|
||||
|
||||
/// workspace 成员冻结清单;新增 crate 必须同步扩展本文件的依赖规则。
|
||||
const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
pub(super) const WORKSPACE_MEMBERS: &[&str] = &[
|
||||
"apps/gateway",
|
||||
"crates/gateway-admin",
|
||||
"crates/gateway-api",
|
||||
@@ -95,6 +98,36 @@ const PACKAGE_TO_MEMBER: &[(&str, &str)] = &[
|
||||
("provider-xai", "crates/providers/xai"),
|
||||
];
|
||||
|
||||
/// Adapter/provider 根门面的稳定合同模块;任何增减都必须同步完成边界审计。
|
||||
const ADAPTER_PUBLIC_MODULES: &[(&str, &[&str])] = &[
|
||||
("crates/gateway-api", &["admin", "openai"]),
|
||||
(
|
||||
"crates/gateway-host",
|
||||
&[
|
||||
"client_distribution",
|
||||
"config",
|
||||
"serve",
|
||||
"system_update",
|
||||
"workers",
|
||||
],
|
||||
),
|
||||
("crates/gateway-store", &["backup", "postgres", "redis"]),
|
||||
(
|
||||
"crates/providers/openai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
(
|
||||
"crates/providers/xai",
|
||||
&["config", "credential", "transport"],
|
||||
),
|
||||
];
|
||||
|
||||
/// 不对应单一生产模块、而是校验 crate/workspace 整体契约的根级测试场景。
|
||||
const ROOT_TEST_SCENARIOS: &[(&str, &[&str])] = &[
|
||||
("apps/gateway", &["architecture"]),
|
||||
("crates/gateway-api", &["architecture"]),
|
||||
];
|
||||
|
||||
/// 冻结的 workspace 内部运行时依赖边;新增/删除任何边都必须同步本表。
|
||||
const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("codex-proxy-rs", "gateway-admin"),
|
||||
@@ -167,6 +200,35 @@ fn workspace_internal_dependency_edges_match_frozen_dag() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_public_module_surfaces_match_allowlist() {
|
||||
for (member, allowed) in ADAPTER_PUBLIC_MODULES {
|
||||
let root = backend_root().join(member).join("src/lib.rs");
|
||||
let source = fs::read_to_string(&root).expect("read adapter crate root");
|
||||
let syntax = syn::parse_file(&source).expect("parse adapter crate root");
|
||||
let actual = syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::Mod(module) if matches!(module.vis, syn::Visibility::Public(_)) => {
|
||||
Some(module.ident.to_string())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
let expected = allowed
|
||||
.iter()
|
||||
.map(|module| (*module).to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
expected,
|
||||
"{} public module surface diverged from its allowlist",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_sources_do_not_host_tests() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
@@ -185,7 +247,192 @@ fn production_sources_do_not_host_tests() {
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_root() -> PathBuf {
|
||||
#[test]
|
||||
fn workspace_modules_follow_conventional_file_layout() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
assert_module_tree(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
|
||||
let tests = member_root.join("tests");
|
||||
if tests.is_dir() {
|
||||
assert_module_tree(&tests, &["main.rs"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ModuleLayout {
|
||||
File,
|
||||
Directory,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integration_tests_mirror_production_module_tree() {
|
||||
for member in WORKSPACE_MEMBERS {
|
||||
let member_root = backend_root().join(member);
|
||||
let tests_root = member_root.join("tests");
|
||||
if !tests_root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let production = module_layouts(&member_root.join("src"), &["lib.rs", "main.rs"]);
|
||||
let tests = module_layouts(&tests_root, &["main.rs"]);
|
||||
for (module, test_layout) in tests {
|
||||
if module == Path::new("support") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(production_layout) = production.get(&module) {
|
||||
assert_eq!(
|
||||
test_layout,
|
||||
*production_layout,
|
||||
"{} must mirror the production module layout for {}",
|
||||
tests_root.join(module.with_extension("rs")).display(),
|
||||
member_root.join("src").join(&module).display(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_production_owner = module
|
||||
.parent()
|
||||
.into_iter()
|
||||
.flat_map(Path::ancestors)
|
||||
.take_while(|ancestor| !ancestor.as_os_str().is_empty())
|
||||
.any(|ancestor| production.contains_key(ancestor));
|
||||
assert!(
|
||||
has_production_owner || root_test_scenario_allowed(member, &module),
|
||||
"{} has no mirrored production module owner",
|
||||
tests_root.join(module.with_extension("rs")).display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn module_layouts(root: &Path, crate_roots: &[&str]) -> BTreeMap<PathBuf, ModuleLayout> {
|
||||
super::rust_files(root)
|
||||
.into_iter()
|
||||
.filter(|relative| {
|
||||
!crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
})
|
||||
.map(|relative| {
|
||||
if relative.file_name().and_then(|value| value.to_str()) == Some("mod.rs") {
|
||||
(
|
||||
relative.parent().expect("mod.rs parent").to_path_buf(),
|
||||
ModuleLayout::Directory,
|
||||
)
|
||||
} else {
|
||||
(relative.with_extension(""), ModuleLayout::File)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn root_test_scenario_allowed(member: &str, module: &Path) -> bool {
|
||||
let Some(module) = module.to_str() else {
|
||||
return false;
|
||||
};
|
||||
ROOT_TEST_SCENARIOS
|
||||
.iter()
|
||||
.any(|(candidate, scenarios)| *candidate == member && scenarios.contains(&module))
|
||||
}
|
||||
|
||||
fn assert_module_tree(root: &Path, crate_roots: &[&str]) {
|
||||
let files = super::rust_files(root);
|
||||
for relative in &files {
|
||||
if crate_roots
|
||||
.iter()
|
||||
.any(|candidate| relative == Path::new(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_name = relative
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("Rust source file name");
|
||||
let parent = relative.parent().expect("Rust source parent");
|
||||
let module_name = if file_name == "mod.rs" {
|
||||
parent
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("mod.rs module name")
|
||||
} else {
|
||||
relative
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.expect("Rust source module name")
|
||||
};
|
||||
|
||||
if file_name != "mod.rs" {
|
||||
assert_ne!(
|
||||
parent.file_name().and_then(|value| value.to_str()),
|
||||
Some(module_name),
|
||||
"{} repeats its parent module name",
|
||||
root.join(relative).display()
|
||||
);
|
||||
let child_directory = relative.with_extension("");
|
||||
assert!(
|
||||
!files
|
||||
.iter()
|
||||
.any(|candidate| candidate.starts_with(&child_directory)),
|
||||
"{} mixes a leaf module with a same-name module directory",
|
||||
root.join(relative).display()
|
||||
);
|
||||
}
|
||||
|
||||
let declaration_parents = if parent.as_os_str().is_empty()
|
||||
|| (file_name == "mod.rs"
|
||||
&& parent
|
||||
.parent()
|
||||
.is_some_and(|ancestor| ancestor.as_os_str().is_empty()))
|
||||
{
|
||||
crate_roots
|
||||
.iter()
|
||||
.map(|candidate| root.join(candidate))
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
let declaration_directory = if file_name == "mod.rs" {
|
||||
parent.parent().expect("nested mod.rs parent")
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
vec![root.join(declaration_directory).join("mod.rs")]
|
||||
};
|
||||
let declaration_count = declaration_parents
|
||||
.iter()
|
||||
.map(|path| external_module_declaration_count(path, module_name))
|
||||
.sum::<usize>();
|
||||
assert_eq!(
|
||||
declaration_count,
|
||||
1,
|
||||
"{} must be declared exactly once by its parent module",
|
||||
root.join(relative).display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn external_module_declaration_count(path: &Path, module_name: &str) -> usize {
|
||||
if !path.is_file() {
|
||||
return 0;
|
||||
}
|
||||
let source = fs::read_to_string(path).expect("read parent module source");
|
||||
let syntax = syn::parse_file(&source).expect("parse parent module source");
|
||||
syntax
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
Item::Mod(module)
|
||||
if module.content.is_none() && module.ident == module_name
|
||||
)
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) fn backend_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
|
||||
@@ -144,7 +144,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
|
||||
#[test]
|
||||
fn config_loader_should_reject_invalid_codex_cli_version() {
|
||||
assert_rejected(valid_config().replace(
|
||||
"codex_version: '0.147.0-alpha.6.6'",
|
||||
"codex_version: '0.151.0-alpha.7.2'",
|
||||
"codex_version: 'latest'",
|
||||
));
|
||||
}
|
||||
@@ -165,7 +165,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: '6415'", "desktop_build: 'build'"));
|
||||
assert_rejected(valid_config().replace("desktop_build: '7377'", "desktop_build: 'build'"));
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -43,31 +43,33 @@ fn cargo_library_root_is_conventional_lib() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
for relative in ["bootstrap.rs", "lib.rs", "main.rs"] {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
path.display()
|
||||
);
|
||||
let syntax = syn::parse_file(&source).expect("parse production source");
|
||||
for item in &syntax.items {
|
||||
if let Item::Mod(module) = item {
|
||||
assert!(
|
||||
module.content.is_none(),
|
||||
"{} has an inline module",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
for attribute in item_attrs(item) {
|
||||
assert!(
|
||||
!is_path_or_test_cfg(attribute),
|
||||
"{} has a production test/path hook",
|
||||
path.display(),
|
||||
);
|
||||
fn workspace_production_files_have_no_hidden_modules_or_test_hooks() {
|
||||
for member in architecture::WORKSPACE_MEMBERS {
|
||||
let root = architecture::backend_root().join(member).join("src");
|
||||
for relative in rust_files(&root) {
|
||||
let path = root.join(relative);
|
||||
let source = fs::read_to_string(&path).expect("read production source");
|
||||
assert!(
|
||||
!source.contains("include!("),
|
||||
"{} uses include!",
|
||||
path.display()
|
||||
);
|
||||
let syntax = syn::parse_file(&source).expect("parse production source");
|
||||
for item in &syntax.items {
|
||||
if let Item::Mod(module) = item {
|
||||
assert!(
|
||||
module.content.is_none(),
|
||||
"{} has an inline module",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
for attribute in item_attrs(item) {
|
||||
assert!(
|
||||
!is_path_or_test_cfg(attribute),
|
||||
"{} has a production test/path hook",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ name = "main"
|
||||
path = "tests/main.rs"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.5.3"
|
||||
argon2 = "0.6.0"
|
||||
async-trait = "0.1.89"
|
||||
base64 = "0.23.0"
|
||||
bytes = "1.11.1"
|
||||
chrono = "0.4.45"
|
||||
futures = "0.3.32"
|
||||
gateway-core = { path = "../gateway-core" }
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
use chrono::{DateTime, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::{
|
||||
engine::CancellationToken,
|
||||
lifecycle::CancellationToken,
|
||||
task::{DaemonTask, WorkerTaskError},
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::{fmt, path::Path, sync::Arc, time::Duration};
|
||||
|
||||
use gateway_core::{
|
||||
engine::probe::AccountProbe,
|
||||
routing::{ProviderKind, snapshot::SnapshotControl},
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
task::{
|
||||
DaemonRestartPolicy, WorkerContribution, WorkerId, WorkerKind, WorkerRegistration,
|
||||
WorkerRunnable,
|
||||
@@ -22,19 +23,22 @@ mod use_case;
|
||||
|
||||
pub use use_case::{
|
||||
account_groups::AccountGroupService, accounts::AccountsService, auth::AuthService,
|
||||
backup::BackupService, client_keys::ClientKeyService, observability::ObservabilityService,
|
||||
openai::OpenAiService, settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
backup::BackupService, client_distribution::ClientDistributionService,
|
||||
client_keys::ClientKeyService, observability::ObservabilityService, openai::OpenAiService,
|
||||
settings::SettingsService, system::SystemService, xai::XaiService,
|
||||
};
|
||||
|
||||
use model::{AdminError, AdminErrorKind};
|
||||
use ports::{
|
||||
client_distribution::ClientDistributionResolver,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind, ProviderAdminRegistry},
|
||||
store::AdminStorePorts,
|
||||
system::SystemOperations,
|
||||
};
|
||||
use use_case::{
|
||||
account_groups::DefaultAccountGroupService, accounts::DefaultAccountsService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService, client_keys::DefaultClientKeyService,
|
||||
auth::DefaultAuthService, backup::DefaultBackupService,
|
||||
client_distribution::DefaultClientDistributionService, client_keys::DefaultClientKeyService,
|
||||
observability::DefaultObservabilityService, openai::DefaultOpenAiService,
|
||||
settings::DefaultSettingsService, system::DefaultSystemService, xai::DefaultXaiService,
|
||||
};
|
||||
@@ -149,6 +153,7 @@ pub struct AdminServices {
|
||||
accounts: Arc<dyn AccountsService>,
|
||||
account_groups: Arc<dyn AccountGroupService>,
|
||||
client_keys: Arc<dyn ClientKeyService>,
|
||||
client_distribution: Arc<dyn ClientDistributionService>,
|
||||
observability: Arc<dyn ObservabilityService>,
|
||||
settings: Arc<dyn SettingsService>,
|
||||
system: Arc<dyn SystemService>,
|
||||
@@ -178,6 +183,11 @@ impl AdminServices {
|
||||
self.client_keys.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_distribution(&self) -> &dyn ClientDistributionService {
|
||||
self.client_distribution.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn observability(&self) -> &dyn ObservabilityService {
|
||||
self.observability.as_ref()
|
||||
@@ -238,6 +248,7 @@ pub async fn initialize(
|
||||
providers: Vec<Arc<dyn ProviderAdmin>>,
|
||||
snapshot: Arc<dyn SnapshotControl>,
|
||||
probe: Arc<dyn AccountProbe>,
|
||||
client_distribution: Arc<dyn ClientDistributionResolver>,
|
||||
system: Arc<dyn SystemOperations>,
|
||||
) -> Result<AdminBundle, AdminError> {
|
||||
config
|
||||
@@ -290,6 +301,7 @@ pub async fn initialize(
|
||||
store.client_keys(),
|
||||
snapshot.clone(),
|
||||
)),
|
||||
client_distribution: Arc::new(DefaultClientDistributionService::new(client_distribution)),
|
||||
observability: Arc::new(DefaultObservabilityService::new(
|
||||
store.observability(),
|
||||
store.settings(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::{engine::credential::AccountStatusFacts, routing::AccountGroupId};
|
||||
use gateway_core::{account::AccountStatusFacts, routing::AccountGroupId};
|
||||
|
||||
use super::{PageSize, Revision, observability::DecimalAmount};
|
||||
|
||||
|
||||
@@ -6,14 +6,13 @@ use chrono::{DateTime, Utc};
|
||||
use futures::Stream;
|
||||
|
||||
use gateway_core::{
|
||||
engine::{UpstreamSendState, probe::AccountProbeErrorSource},
|
||||
error::GatewayErrorKind,
|
||||
routing::ProviderKind,
|
||||
engine::probe::AccountProbeErrorSource, error::GatewayErrorKind, routing::ProviderKind,
|
||||
upstream::UpstreamSendState,
|
||||
};
|
||||
|
||||
use super::{PageSize, Revision, account_groups::AccountGroupRef, observability::TimeRange};
|
||||
|
||||
pub use gateway_core::engine::credential::{
|
||||
pub use gateway_core::account::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStatus, AccountStatusFacts,
|
||||
AccountStatusProjection, AccountWeight, CredentialState, QuotaAccessState, QuotaEvidence,
|
||||
QuotaState, resolve_account_status,
|
||||
@@ -218,7 +217,7 @@ pub struct UpdateAccount {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_id: gateway_core::engine::credential::ProviderAccountId,
|
||||
pub account_id: gateway_core::account::ProviderAccountId,
|
||||
}
|
||||
|
||||
/// 一批账号可编辑事实的一次性替换命令。
|
||||
@@ -235,7 +234,7 @@ pub struct BatchUpdateAccounts {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
pub config_revision: Revision,
|
||||
pub account_ids: Vec<gateway_core::engine::credential::ProviderAccountId>,
|
||||
pub account_ids: Vec<gateway_core::account::ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 账号批量删除命令。
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! 备份领域的稳定错误码。
|
||||
|
||||
/// 配置或参数非法。
|
||||
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";
|
||||
+1
-34
@@ -447,40 +447,7 @@ impl BackupError {
|
||||
}
|
||||
|
||||
/// 构造稳定错误码常量。
|
||||
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";
|
||||
}
|
||||
pub mod code;
|
||||
|
||||
/// 构建对象 key:`{prefix}/YYYY/MM/DD/codex-proxy-rs_{UTC seconds}_{backup id}.dump`。
|
||||
///
|
||||
@@ -0,0 +1,58 @@
|
||||
//! 管理设置页使用的 Codex 客户端下载信息。
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Windows 安装包架构。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ClientArchitecture {
|
||||
X64,
|
||||
Arm64,
|
||||
}
|
||||
|
||||
impl ClientArchitecture {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::X64 => "x64",
|
||||
Self::Arm64 => "arm64",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载地址的可信来源。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientDownloadSource {
|
||||
MicrosoftStore,
|
||||
OfficialOpenAi,
|
||||
}
|
||||
|
||||
impl ClientDownloadSource {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::MicrosoftStore => "microsoft_store",
|
||||
Self::OfficialOpenAi => "official_openai",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个已经过 Host 校验、可以直接交给浏览器下载的安装包。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientDownloadPackage {
|
||||
pub architecture: ClientArchitecture,
|
||||
pub source: ClientDownloadSource,
|
||||
pub version: Option<String>,
|
||||
pub file_name: String,
|
||||
pub size_bytes: Option<u64>,
|
||||
pub download_url: String,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Codex Desktop Windows 下载解析结果。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodexDesktopWindowsDownloads {
|
||||
pub resolved_at: DateTime<Utc>,
|
||||
pub cached: bool,
|
||||
pub warning: Option<String>,
|
||||
pub packages: Vec<ClientDownloadPackage>,
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod provider_credentials;
|
||||
|
||||
@@ -38,13 +38,6 @@ impl TimeRange {
|
||||
}
|
||||
}
|
||||
|
||||
/// 观测记录的稳定键集游标。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ObservabilityCursor {
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub stable_id: String,
|
||||
}
|
||||
|
||||
/// 请求结果状态。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RequestOutcome {
|
||||
@@ -130,9 +123,8 @@ pub struct UsageFilter {
|
||||
pub struct UsageQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: UsageFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
/// 运维错误过滤条件。
|
||||
@@ -148,7 +140,6 @@ pub struct OpsErrorFilter {
|
||||
pub attempt_index: Option<u32>,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub failure_kind: Option<String>,
|
||||
pub status_code: Option<u16>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
@@ -158,9 +149,8 @@ pub struct OpsErrorFilter {
|
||||
pub struct OpsErrorQuery {
|
||||
pub range: TimeRange,
|
||||
pub filter: OpsErrorFilter,
|
||||
pub cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: PageSize,
|
||||
pub include_total: bool,
|
||||
}
|
||||
|
||||
/// 用量诊断维度。
|
||||
@@ -197,11 +187,11 @@ impl DecimalAmount {
|
||||
Self::from_decimal(self.to_decimal()?.checked_div_u64(divisor)?)
|
||||
}
|
||||
|
||||
fn to_decimal(&self) -> Option<gateway_core::accounting::Decimal> {
|
||||
fn to_decimal(&self) -> Option<gateway_core::metering::Decimal> {
|
||||
self.0.parse().ok()
|
||||
}
|
||||
|
||||
fn from_decimal(value: gateway_core::accounting::Decimal) -> Option<Self> {
|
||||
fn from_decimal(value: gateway_core::metering::Decimal) -> Option<Self> {
|
||||
Some(Self(value.canonical()))
|
||||
}
|
||||
}
|
||||
@@ -667,8 +657,9 @@ pub struct UsageRecord {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsagePage {
|
||||
pub items: Vec<UsageListRecord>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 请求中的一次上游尝试或运维事件。
|
||||
@@ -770,6 +761,10 @@ pub struct OpsError {
|
||||
pub client_api_key_ref: Option<String>,
|
||||
pub component: String,
|
||||
pub operation: String,
|
||||
pub protocol: Option<String>,
|
||||
pub client_transport: Option<String>,
|
||||
pub requested_model_id: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub endpoint: Option<String>,
|
||||
pub provider_kind: Option<String>,
|
||||
pub provider_account_ref: Option<String>,
|
||||
@@ -779,6 +774,7 @@ pub struct OpsError {
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub failure_kind: String,
|
||||
pub upstream_send_state: Option<String>,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
@@ -786,7 +782,27 @@ pub struct OpsError {
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub message: String,
|
||||
pub raw_upstream_error: Option<String>,
|
||||
pub occurrence_count: u32,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: Option<bool>,
|
||||
pub continuation_affinity_hash: Option<String>,
|
||||
pub continuation_previous_response_id_hash: Option<String>,
|
||||
pub continuation_unavailable_reason: Option<String>,
|
||||
pub upstream_connection_id: Option<String>,
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub upstream_connection_age_ms: Option<u64>,
|
||||
pub upstream_connection_idle_ms: Option<u64>,
|
||||
pub recovery_request_id: Option<String>,
|
||||
pub recovered_at: Option<DateTime<Utc>>,
|
||||
pub recovery_attempt_count: u32,
|
||||
pub recovery_retry_delay_ms: Option<u64>,
|
||||
pub recovery_total_latency_ms: Option<u64>,
|
||||
pub occurred_at: DateTime<Utc>,
|
||||
pub stable_sort_id: String,
|
||||
}
|
||||
@@ -795,8 +811,9 @@ pub struct OpsError {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpsErrorPage {
|
||||
pub items: Vec<OpsError>,
|
||||
pub total: Option<u64>,
|
||||
pub next_cursor: Option<ObservabilityCursor>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// 仪表盘趋势指标。
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! Provider 管理能力交换的中立 Command 与 Result。
|
||||
|
||||
use std::fmt;
|
||||
use std::{fmt, pin::Pin};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use futures::Stream;
|
||||
use gateway_core::{
|
||||
engine::credential::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -679,6 +681,35 @@ pub struct ProviderProfileStatistics {
|
||||
pub activity_insights: ProviderProfileActivityInsights,
|
||||
}
|
||||
|
||||
/// Provider 头像正文的中立字节流;公共层不解释 MIME,也不限制总字节数。
|
||||
pub type ProviderProfileAvatarStream =
|
||||
Pin<Box<dyn Stream<Item = Result<Bytes, ProviderProfileAvatarStreamError>> + Send + 'static>>;
|
||||
|
||||
/// Provider 头像流在响应开始后的通用失败,不携带上游 URL 或正文。
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("provider profile avatar stream failed")]
|
||||
pub struct ProviderProfileAvatarStreamError;
|
||||
|
||||
/// Provider 已校验并打开的官方头像响应。
|
||||
pub struct ProviderProfileAvatar {
|
||||
pub content_type: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
pub etag: Option<String>,
|
||||
pub body: ProviderProfileAvatarStream,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ProviderProfileAvatar {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderProfileAvatar")
|
||||
.field("content_type", &self.content_type)
|
||||
.field("content_length", &self.content_length)
|
||||
.field("etag", &self.etag)
|
||||
.field("body", &"<stream>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 返回的一张安全主动额度重置卡。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderResetCredit {
|
||||
@@ -877,7 +908,7 @@ impl fmt::Debug for ProviderExport {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AccountDirectoryItem {
|
||||
pub account: AccountRecord,
|
||||
pub projection: gateway_core::engine::credential::AccountStatusProjection,
|
||||
pub projection: gateway_core::account::AccountStatusProjection,
|
||||
pub usage: Option<super::accounts::AccountUsage>,
|
||||
pub quota: ProviderQuota,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::Revision;
|
||||
pub type ModelMappings = BTreeMap<PublicModelId, UpstreamModelId>;
|
||||
|
||||
/// 账号调度策略;由 Core 拥有稳定值与 wire 映射。
|
||||
pub use gateway_core::engine::credential::RotationStrategy;
|
||||
pub use gateway_core::account::RotationStrategy;
|
||||
|
||||
/// 完整运行设置事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -24,6 +24,8 @@ pub struct RuntimeSettings {
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
@@ -39,6 +41,8 @@ pub struct ReplaceRuntimeSettings {
|
||||
pub max_concurrent_per_account: u32,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: RotationStrategy,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u32,
|
||||
pub ops_event_retention_days: u32,
|
||||
pub audit_retention_days: u32,
|
||||
|
||||
@@ -145,7 +145,7 @@ pub trait DatabaseDumpPort: Send + Sync {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DumpRequest {
|
||||
pub backup_id: String,
|
||||
pub cancellation: gateway_core::engine::CancellationToken,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 已完成并落盘的归档事实。
|
||||
@@ -212,7 +212,7 @@ pub struct UploadObjectRequest {
|
||||
pub object_key: String,
|
||||
pub source: PathBuf,
|
||||
pub metadata: BackupObjectMetadata,
|
||||
pub cancellation: gateway_core::engine::CancellationToken,
|
||||
pub cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
/// 备份控制面所需的全部 Store 能力集合。
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 客户端安装包解析的外部能力端口。
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::model::client_distribution::CodexDesktopWindowsDownloads;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClientDistributionResolver: Send + Sync {
|
||||
async fn resolve_codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 管理用例依赖的外部能力端口。
|
||||
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod system;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
account::ProviderAccountId,
|
||||
operation::Operation,
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
};
|
||||
@@ -17,8 +17,8 @@ use crate::model::provider_credentials::{
|
||||
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
|
||||
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
|
||||
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
|
||||
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult,
|
||||
ProviderResetCredits,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderResetCreditResult, ProviderResetCredits,
|
||||
};
|
||||
|
||||
/// Provider 管理失败的稳定分类。
|
||||
@@ -157,6 +157,14 @@ pub trait ProviderAdmin: Send + Sync {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 打开 Provider 官方头像字节流;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileAvatar, ProviderAdminError> {
|
||||
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
|
||||
}
|
||||
|
||||
/// 查询 Provider 主动额度重置卡;不支持该能力的 Provider 使用默认拒绝。
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
|
||||
@@ -118,13 +118,13 @@ pub trait AccountStore: Send + Sync {
|
||||
async fn credential_details(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_id: &gateway_core::engine::credential::ProviderAccountId,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
) -> AdminStoreResult<Option<CredentialDetails>>;
|
||||
|
||||
async fn load_credentials_for_export(
|
||||
&self,
|
||||
provider_kind: &gateway_core::routing::ProviderKind,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
) -> AdminStoreResult<Vec<ProviderExportCredentialInput>>;
|
||||
|
||||
async fn commit_credential_import(
|
||||
@@ -159,7 +159,7 @@ pub trait AccountStore: Send + Sync {
|
||||
|
||||
async fn recover_account(
|
||||
&self,
|
||||
account_id: &gateway_core::engine::credential::ProviderAccountId,
|
||||
account_id: &gateway_core::account::ProviderAccountId,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountUpdateResult>;
|
||||
|
||||
@@ -177,7 +177,7 @@ pub trait AccountStore: Send + Sync {
|
||||
|
||||
async fn record_credential_export(
|
||||
&self,
|
||||
account_ids: &[gateway_core::engine::credential::ProviderAccountId],
|
||||
account_ids: &[gateway_core::account::ProviderAccountId],
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<()>;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{
|
||||
engine::credential::{AccountStatus, resolve_account_status},
|
||||
routing::{AccountGroupId, snapshot::SnapshotControl},
|
||||
account::{AccountStatus, resolve_account_status},
|
||||
routing::AccountGroupId,
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -6,11 +6,10 @@ use async_trait::async_trait;
|
||||
use chrono::{Duration, Utc};
|
||||
use futures::StreamExt as _;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeRequest},
|
||||
},
|
||||
routing::{ProviderKind, UpstreamModelId, snapshot::SnapshotControl},
|
||||
account::ProviderAccountId,
|
||||
engine::probe::{AccountProbe, AccountProbeRequest},
|
||||
routing::{ProviderKind, UpstreamModelId},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -25,8 +24,9 @@ use crate::{
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels,
|
||||
ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
|
||||
ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution,
|
||||
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
|
||||
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
|
||||
QuotaLocalUsageAttribution,
|
||||
},
|
||||
},
|
||||
ports::{
|
||||
@@ -90,6 +90,13 @@ pub trait AccountsService: Send + Sync {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
_account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileAvatar, AdminError> {
|
||||
Err(AdminError::invalid("当前 Provider 不支持账号头像"))
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
_context: &MutationContext,
|
||||
@@ -566,6 +573,17 @@ impl AccountsService for DefaultAccountsService {
|
||||
.map_err(|error| map_provider_error(error, "provider profile statistics"))
|
||||
}
|
||||
|
||||
async fn profile_avatar(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ProviderProfileAvatar, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
provider
|
||||
.profile_avatar(account_id)
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "provider profile avatar"))
|
||||
}
|
||||
|
||||
async fn reset_credits(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{Duration, Utc};
|
||||
@@ -195,9 +195,8 @@ impl AuthService for DefaultAuthService {
|
||||
}
|
||||
|
||||
fn hash_admin_password(password: &str) -> Result<String, AdminError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.hash_password(password.as_bytes())
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|_| AdminError::internal("管理员密码哈希失败"))
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use secrecy::ExposeSecret as _;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Codex 客户端下载信息用例。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
model::client_distribution::CodexDesktopWindowsDownloads,
|
||||
ports::client_distribution::ClientDistributionResolver,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ClientDistributionService: Send + Sync {
|
||||
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultClientDistributionService {
|
||||
resolver: Arc<dyn ClientDistributionResolver>,
|
||||
}
|
||||
|
||||
impl DefaultClientDistributionService {
|
||||
#[must_use]
|
||||
pub(crate) const fn new(resolver: Arc<dyn ClientDistributionResolver>) -> Self {
|
||||
Self { resolver }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ClientDistributionService for DefaultClientDistributionService {
|
||||
async fn codex_desktop_windows(&self, refresh: bool) -> CodexDesktopWindowsDownloads {
|
||||
self.resolver.resolve_codex_desktop_windows(refresh).await
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod account_groups;
|
||||
pub mod accounts;
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod client_distribution;
|
||||
pub mod client_keys;
|
||||
pub mod observability;
|
||||
pub mod openai;
|
||||
@@ -29,8 +30,9 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::credential::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
|
||||
account::ProviderAccountId,
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
fn map_store_error(error: AdminStoreError, resource: &'static str) -> AdminError {
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Duration, Timelike as _, Utc};
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::ProviderKind};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
@@ -654,7 +654,7 @@ fn no_cache_cost(
|
||||
.checked_sub(decimal(&breakdown.cache_write_amount.amount)?.scaled())?
|
||||
.checked_add(replaced_input_amount)?;
|
||||
DecimalAmount::from_str(
|
||||
&gateway_core::accounting::Decimal::from_scaled(total)
|
||||
&gateway_core::metering::Decimal::from_scaled(total)
|
||||
.ok()?
|
||||
.canonical(),
|
||||
)
|
||||
@@ -669,14 +669,14 @@ fn amount_difference(
|
||||
.scaled()
|
||||
.checked_sub(decimal(smaller?)?.scaled())?;
|
||||
DecimalAmount::from_str(
|
||||
&gateway_core::accounting::Decimal::from_scaled(difference)
|
||||
&gateway_core::metering::Decimal::from_scaled(difference)
|
||||
.ok()?
|
||||
.canonical(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn decimal(value: &DecimalAmount) -> Option<gateway_core::accounting::Decimal> {
|
||||
fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
|
||||
value.as_str().parse().ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::{engine::credential::ProviderAccountId, routing::snapshot::SnapshotControl};
|
||||
use gateway_core::{account::ProviderAccountId, runtime::SnapshotControl};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::policy::CodexClientVersion;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
|
||||
use crate::{
|
||||
@@ -119,6 +120,8 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
&& command.usage_retention_days >= 31
|
||||
&& command.ops_event_retention_days > 0
|
||||
&& command.audit_retention_days > 0
|
||||
&& valid_client_version(command.min_codex_desktop_version.as_deref())
|
||||
&& valid_client_version(command.min_codex_cli_version.as_deref())
|
||||
&& i64::try_from(command.request_interval_ms).is_ok();
|
||||
if valid {
|
||||
Ok(())
|
||||
@@ -126,3 +129,7 @@ fn validate_settings(command: &ReplaceRuntimeSettings) -> Result<(), AdminError>
|
||||
Err(AdminError::invalid("运行时设置不满足约束"))
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_client_version(value: Option<&str>) -> bool {
|
||||
value.is_none_or(|value| CodexClientVersion::parse(value).is_ok())
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gateway_core::routing::snapshot::SnapshotControl;
|
||||
use gateway_core::runtime::SnapshotControl;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
|
||||
@@ -10,7 +10,7 @@ use gateway_admin::{
|
||||
},
|
||||
ports::backup::{BackupRepository, DatabaseDumpPort, DumpRequest},
|
||||
};
|
||||
use gateway_core::engine::CancellationToken;
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
|
||||
use super::support::{
|
||||
DUMP_CONTENT, FakeBackupRepository, FakeDumpPort, FakeObjectStore, backup_id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::str::FromStr as _;
|
||||
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::engine::credential::OpaqueProviderData;
|
||||
use gateway_core::account::OpaqueProviderData;
|
||||
|
||||
use gateway_admin::{
|
||||
model::{
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
//! 备份领域模型(model/backup.rs)的纯逻辑测试。
|
||||
//! 备份领域模型(model/backup)的纯逻辑测试。
|
||||
|
||||
use chrono::{TimeZone as _, Utc};
|
||||
use secrecy::SecretString;
|
||||
@@ -24,7 +24,7 @@ use gateway_admin::{
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::credential::{AccountStatusFacts, CredentialState, QuotaState},
|
||||
account::{AccountStatusFacts, CredentialState, QuotaState},
|
||||
routing::AccountGroupId,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,20 +4,18 @@ use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use futures::{StreamExt as _, future::BoxFuture};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
UpstreamSendState,
|
||||
credential::{
|
||||
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId,
|
||||
QuotaEvidence, QuotaState, resolve_account_status,
|
||||
},
|
||||
probe::{
|
||||
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
|
||||
AccountProbeResult,
|
||||
},
|
||||
account::{
|
||||
AccountStatusFacts, CredentialState, OpaqueProviderData, ProviderAccountId, QuotaEvidence,
|
||||
QuotaState, resolve_account_status,
|
||||
},
|
||||
engine::probe::{
|
||||
AccountProbe, AccountProbeError, AccountProbeErrorSource, AccountProbeRequest,
|
||||
AccountProbeResult,
|
||||
},
|
||||
error::{ClientVisibleUpstreamError, GatewayError, GatewayErrorKind},
|
||||
operation::{GenerateRequest, Operation, ProtocolPayload},
|
||||
routing::ProviderKind,
|
||||
upstream::UpstreamSendState,
|
||||
};
|
||||
|
||||
use gateway_admin::{
|
||||
@@ -798,6 +796,8 @@ impl SettingsStore for StaticSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 30,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
@@ -1110,8 +1110,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
std::time::SystemTime::now(),
|
||||
None,
|
||||
);
|
||||
account.last_error_reason =
|
||||
Some(gateway_core::engine::credential::AccountErrorReason::CredentialInvalid);
|
||||
account.last_error_reason = Some(gateway_core::account::AccountErrorReason::CredentialInvalid);
|
||||
account.last_error_message = Some("invalid credential".to_owned());
|
||||
let store = FakeAccountStore::with_account(account, events.clone());
|
||||
let services = accounts_service(provider, store.clone()).await;
|
||||
@@ -1133,7 +1132,7 @@ async fn accounts_recover_should_commit_facts_then_return_normal_account() {
|
||||
);
|
||||
assert_eq!(
|
||||
result.account.account.quota.access(),
|
||||
gateway_core::engine::credential::QuotaAccessState::Allowed
|
||||
gateway_core::account::QuotaAccessState::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
result.account.projection.status,
|
||||
@@ -1166,7 +1165,7 @@ async fn accounts_update_should_commit_then_release_disabled_account_and_publish
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
@@ -1202,7 +1201,7 @@ async fn accounts_update_should_not_notify_provider_when_store_commit_fails() {
|
||||
account_id: "acct_test".to_owned(),
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
@@ -1243,7 +1242,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
account_ids: vec!["acct_openai".to_owned(), "acct_xai".to_owned()],
|
||||
enabled: false,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
group_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
@@ -1896,7 +1895,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
next_refresh_at: Some(now + TimeDelta::minutes(30)),
|
||||
enabled: true,
|
||||
concurrency_limit: None,
|
||||
weight: gateway_core::engine::credential::AccountWeight::DEFAULT,
|
||||
weight: gateway_core::account::AccountWeight::DEFAULT,
|
||||
credential_state: CredentialState::Ready,
|
||||
credential_observed_at: now,
|
||||
quota: QuotaState::allowed(now.into()),
|
||||
|
||||
@@ -31,6 +31,7 @@ use gateway_admin::{
|
||||
BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_distribution::CodexDesktopWindowsDownloads,
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
@@ -55,6 +56,7 @@ use gateway_admin::{
|
||||
},
|
||||
ports::{
|
||||
backup::BackupStorePorts,
|
||||
client_distribution::ClientDistributionResolver,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
|
||||
@@ -68,13 +70,12 @@ use gateway_admin::{
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::ProviderAccountId,
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
account::ProviderAccountId,
|
||||
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::ClientApiKeyId,
|
||||
routing::{ConfigRevision, ProviderKind, snapshot::SnapshotControl},
|
||||
routing::{ConfigRevision, ProviderKind},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
pub(super) struct AdminHarness {
|
||||
@@ -205,6 +206,7 @@ impl AdminHarness {
|
||||
self.providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
self.probe,
|
||||
Arc::new(NoopClientDistribution),
|
||||
self.system,
|
||||
)
|
||||
.await
|
||||
@@ -213,6 +215,20 @@ impl AdminHarness {
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopClientDistribution;
|
||||
|
||||
#[async_trait]
|
||||
impl ClientDistributionResolver for NoopClientDistribution {
|
||||
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
|
||||
CodexDesktopWindowsDownloads {
|
||||
resolved_at: Utc::now(),
|
||||
cached: false,
|
||||
warning: None,
|
||||
packages: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct BootstrapAuthStore {
|
||||
password_hash: Mutex<Option<String>>,
|
||||
|
||||
@@ -675,13 +675,14 @@ impl ObservabilityStore for FixtureObservabilityStore {
|
||||
.clone())
|
||||
}
|
||||
|
||||
async fn list_usage_records(&self, _: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
let total = u64::try_from(items.len()).unwrap_or(u64::MAX);
|
||||
Ok(UsagePage {
|
||||
items,
|
||||
total: Some(total),
|
||||
next_cursor: None,
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -720,6 +721,8 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
@@ -783,9 +786,8 @@ fn usage_query(now: DateTime<Utc>) -> UsageQuery {
|
||||
UsageQuery {
|
||||
range: observation_range(now),
|
||||
filter: UsageFilter::default(),
|
||||
cursor: None,
|
||||
current_page: 1,
|
||||
page_size: PageSize::new(50).expect("page size"),
|
||||
include_total: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
|
||||
@@ -67,6 +67,8 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
max_concurrent_per_account: 1,
|
||||
request_interval_ms: 0,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 30,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_core::engine::credential::ProviderAccountId;
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
|
||||
use gateway_admin::{
|
||||
AdminServices,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
//! Provider-owned 凭据请求、命令转换与敏感材料校验。
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum AccountProvider {
|
||||
OpenAi,
|
||||
Xai,
|
||||
}
|
||||
|
||||
impl AccountProvider {
|
||||
fn parse(value: &str) -> Result<Self, WireValidationError> {
|
||||
match value.trim() {
|
||||
"openai" => Ok(Self::OpenAi),
|
||||
"xai" => Ok(Self::Xai),
|
||||
_ => Err(WireValidationError::new("provider")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned 账号导入请求;公共 API 不解释 `data` 内部字段。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountImportRequest {
|
||||
pub provider: String,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl AccountImportRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
if !self.data.is_object()
|
||||
|| serde_json::to_vec(&self.data)
|
||||
.map_or(true, |encoded| encoded.len() > MAX_IMPORT_DATA_BYTES)
|
||||
{
|
||||
return Err(WireValidationError::new("data"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, ImportCredentials), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
ImportCredentials {
|
||||
context,
|
||||
document: provider_document(self.data, "data")?,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct StartAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl StartAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
require_text(&self.name, MAX_NAME_BYTES, "name")?;
|
||||
if let Some(account_id) = self.account_id.as_deref() {
|
||||
require_account_id(account_id, "accountId")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, StartAuthorization), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
let reauthorization = self
|
||||
.account_id
|
||||
.map(ProviderAccountId::new)
|
||||
.transpose()
|
||||
.map_err(|_| WireValidationError::new("accountId"))?;
|
||||
Ok((
|
||||
provider,
|
||||
StartAuthorization {
|
||||
context,
|
||||
name: self.name,
|
||||
reauthorization,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct CompleteAccountAuthorizationRequest {
|
||||
pub provider: String,
|
||||
pub flow_id: String,
|
||||
pub callback_url: String,
|
||||
}
|
||||
|
||||
impl CompleteAccountAuthorizationRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
if !URL_SAFE_NO_PAD
|
||||
.decode(&self.flow_id)
|
||||
.is_ok_and(|decoded| decoded.len() == 32)
|
||||
{
|
||||
return Err(WireValidationError::new("flowId"));
|
||||
}
|
||||
}
|
||||
AccountProvider::Xai => require_wire_id(&self.flow_id, "flowId")?,
|
||||
}
|
||||
require_text(&self.callback_url, MAX_CALLBACK_URL_BYTES, "callbackUrl")
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, CompleteAuthorization), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
CompleteAuthorization {
|
||||
context,
|
||||
flow_id: self.flow_id,
|
||||
callback_url: self.callback_url,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub account_id: String,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl UpdateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(self) -> Result<UpdateAccount, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(UpdateAccount {
|
||||
account_id: self.account_id,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdatedAccountData {
|
||||
pub account_id: String,
|
||||
pub config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountUpdateResult> for UpdatedAccountData {
|
||||
fn from(result: AccountUpdateResult) -> Self {
|
||||
Self {
|
||||
account_id: result.account_id.to_string(),
|
||||
config_revision: result.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountDeletionRequest {
|
||||
pub provider: String,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountDeletionRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
AccountProvider::parse(&self.provider)?;
|
||||
if self.account_ids.is_empty() || self.account_ids.len() > MAX_ACCOUNT_DELETE_BATCH {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for account_id in &self.account_ids {
|
||||
require_account_id(account_id, "accountIds")?;
|
||||
if !unique.insert(account_id.as_str()) {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<(AccountProvider, CredentialDeletion), WireValidationError> {
|
||||
self.validate()?;
|
||||
let provider = AccountProvider::parse(&self.provider)?;
|
||||
Ok((
|
||||
provider,
|
||||
CredentialDeletion {
|
||||
context,
|
||||
account_ids: self
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(ProviderAccountId::new)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| WireValidationError::new("accountIds"))?,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct RotateAccountRequest {
|
||||
pub provider: String,
|
||||
pub account_id: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub id_token: Option<String>,
|
||||
}
|
||||
|
||||
impl RotateAccountRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if AccountProvider::parse(&self.provider)? != AccountProvider::OpenAi {
|
||||
return Err(WireValidationError::new("provider"));
|
||||
}
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
validate_oauth_material(
|
||||
&self.access_token,
|
||||
self.refresh_token.as_deref(),
|
||||
self.id_token.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
context: gateway_admin::model::MutationContext,
|
||||
) -> Result<RotateCredential, WireValidationError> {
|
||||
self.validate()?;
|
||||
let mut material = Map::new();
|
||||
material.insert("access_token".to_owned(), Value::String(self.access_token));
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
self.refresh_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
material.insert(
|
||||
"id_token".to_owned(),
|
||||
self.id_token.map_or(Value::Null, Value::String),
|
||||
);
|
||||
Ok(RotateCredential {
|
||||
mutation: CredentialMutation {
|
||||
context,
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
},
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountImportData {
|
||||
pub imported_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl AccountImportData {
|
||||
pub fn from_result(result: CredentialImportResult) -> Self {
|
||||
let account_ids = result
|
||||
.credential_ids
|
||||
.into_iter()
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
imported_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountAuthorizationData {
|
||||
pub flow_id: String,
|
||||
pub authorization_url: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<AuthorizationStarted> for AccountAuthorizationData {
|
||||
fn from(started: AuthorizationStarted) -> Self {
|
||||
Self {
|
||||
flow_id: started.flow_id,
|
||||
authorization_url: started.authorization_url,
|
||||
expires_at: started.expires_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountMutationData {
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
impl From<CredentialMutationResult> for AccountMutationData {
|
||||
fn from(result: CredentialMutationResult) -> Self {
|
||||
Self {
|
||||
account_id: result.account_id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountDeletionData {
|
||||
pub deleted_count: usize,
|
||||
pub account_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<CredentialDeletionResult> for AccountDeletionData {
|
||||
fn from(result: CredentialDeletionResult) -> Self {
|
||||
let account_ids = result
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(|account_id| account_id.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
deleted_count: account_ids.len(),
|
||||
account_ids,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn require_account_id(
|
||||
value: &str,
|
||||
field: &'static str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if value.trim().is_empty()
|
||||
|| value.len() > 128
|
||||
|| value.chars().any(char::is_control)
|
||||
|| !value.starts_with("acct_")
|
||||
{
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn parse_concurrency_limit(
|
||||
value: Option<u64>,
|
||||
) -> Result<Option<AccountConcurrencyLimit>, WireValidationError> {
|
||||
value
|
||||
.map(|value| {
|
||||
u32::try_from(value)
|
||||
.ok()
|
||||
.and_then(AccountConcurrencyLimit::new)
|
||||
.ok_or_else(|| WireValidationError::new("concurrencyLimit"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub(super) fn deserialize_required_nullable<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<u64>::deserialize(deserializer)
|
||||
}
|
||||
|
||||
pub(super) fn parse_account_weight(value: u64) -> Result<AccountWeight, WireValidationError> {
|
||||
u16::try_from(value)
|
||||
.ok()
|
||||
.and_then(AccountWeight::new)
|
||||
.ok_or_else(|| WireValidationError::new("weight"))
|
||||
}
|
||||
|
||||
pub(super) fn validate_wire_group_ids(
|
||||
values: &[String],
|
||||
) -> Result<Vec<AccountGroupId>, WireValidationError> {
|
||||
if values.len() > MAX_ACCOUNT_GROUP_BATCH
|
||||
|| values.iter().collect::<BTreeSet<_>>().len() != values.len()
|
||||
{
|
||||
return Err(WireValidationError::new("groupIds"));
|
||||
}
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|value| AccountGroupId::new(value).map_err(|_| WireValidationError::new("groupIds")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn provider_document(
|
||||
value: Value,
|
||||
field: &'static str,
|
||||
) -> Result<ProviderDocument, WireValidationError> {
|
||||
match value {
|
||||
Value::Object(document) => Ok(ProviderDocument::new(OpaqueProviderData::new(document))),
|
||||
_ => Err(WireValidationError::new(field)),
|
||||
}
|
||||
}
|
||||
|
||||
fn require_wire_id(value: &str, field: &'static str) -> Result<(), WireValidationError> {
|
||||
require_text(value, MAX_ID_BYTES, field)?;
|
||||
if value.starts_with("__")
|
||||
|| !value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':'))
|
||||
{
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_text(
|
||||
value: &str,
|
||||
max_bytes: usize,
|
||||
field: &'static str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_oauth_material(
|
||||
access_token: &str,
|
||||
refresh_token: Option<&str>,
|
||||
id_token: Option<&str>,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if access_token.len() > MAX_ACCESS_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(access_token)
|
||||
|| !valid_compact_jwt_shape(access_token)
|
||||
{
|
||||
return Err(WireValidationError::new("accessToken"));
|
||||
}
|
||||
if refresh_token.is_some_and(|token| {
|
||||
token.len() > MAX_REFRESH_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(token)
|
||||
|| token == access_token
|
||||
}) {
|
||||
return Err(WireValidationError::new("refreshToken"));
|
||||
}
|
||||
if id_token.is_some_and(|token| {
|
||||
token.len() > MAX_ID_TOKEN_BYTES
|
||||
|| !valid_visible_ascii(token)
|
||||
|| !valid_compact_jwt_shape(token)
|
||||
}) {
|
||||
return Err(WireValidationError::new("idToken"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_compact_jwt_shape(value: &str) -> bool {
|
||||
let mut segments = value.split('.');
|
||||
matches!(
|
||||
(segments.next(), segments.next(), segments.next(), segments.next()),
|
||||
(Some(header), Some(payload), Some(signature), None)
|
||||
if !header.is_empty() && !payload.is_empty() && !signature.is_empty()
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_visible_ascii(value: &str) -> bool {
|
||||
!value.is_empty() && value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
|
||||
}
|
||||
|
||||
pub(super) fn provider_document_value(document: ProviderDocument) -> Value {
|
||||
Value::Object(document.into_provider_data().into_inner())
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
//! 账号管理路由与 HTTP handler 编排。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 构造统一账号管理路由。
|
||||
pub fn router<S>() -> Router<S>
|
||||
where
|
||||
S: AdminSessionState + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route("/api/admin/accounts", get(list_accounts::<S>))
|
||||
.route("/api/admin/accounts/detail", get(account_detail::<S>))
|
||||
.route("/api/admin/accounts/export", get(export_accounts::<S>))
|
||||
.route("/api/admin/accounts/import", post(import_accounts::<S>))
|
||||
.route("/api/admin/accounts/refresh", post(refresh_account::<S>))
|
||||
.route("/api/admin/accounts/recover", post(recover_account::<S>))
|
||||
.route("/api/admin/accounts/rotate", post(rotate_account::<S>))
|
||||
.route("/api/admin/accounts/update", post(update_account::<S>))
|
||||
.route("/api/admin/accounts/delete", post(delete_accounts::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/batch-update",
|
||||
post(batch_update_accounts::<S>),
|
||||
)
|
||||
.route("/api/admin/accounts/quota", get(account_quota::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/profile-statistics",
|
||||
get(account_profile_statistics::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/profile-avatar",
|
||||
get(account_profile_avatar::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/reset-credits",
|
||||
get(account_reset_credits::<S>).post(consume_account_reset_credit::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/quota/refresh",
|
||||
post(refresh_account_quota::<S>),
|
||||
)
|
||||
.route("/api/admin/accounts/models", get(account_models::<S>))
|
||||
.route(
|
||||
"/api/admin/accounts/models/refresh",
|
||||
post(refresh_account_models::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/connection-test",
|
||||
get(test_account_connection::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/oauth/start",
|
||||
post(start_account_authorization::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/accounts/oauth/complete",
|
||||
post(complete_account_authorization::<S>),
|
||||
)
|
||||
}
|
||||
|
||||
async fn batch_update_accounts<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<BatchUpdateAccountsRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.batch_update(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(BatchUpdatedAccountsData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_accounts<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ListQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = query.validate().map_err(map_wire_error)?;
|
||||
let page = command.page;
|
||||
let page_size = command.page_size.get();
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.list(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_page_data(result, page, page_size, Utc::now());
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_detail<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.quota(&account_id, false)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
account: account_view(result, Utc::now()),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn export_accounts<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountExportQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let ids = query.into_ids().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.export(&auth.context().mutation_context(), ids)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountExportData::from_result(result);
|
||||
let mut response = AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)).into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn import_accounts<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountImportRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.import_document(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => state.admin_services().xai().import_document(command).await,
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountImportData::from_result(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn start_account_authorization<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<StartAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => {
|
||||
state
|
||||
.admin_services()
|
||||
.xai()
|
||||
.start_authorization(command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountAuthorizationData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn complete_account_authorization<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<CompleteAccountAuthorizationRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => {
|
||||
state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
}
|
||||
AccountProvider::Xai => {
|
||||
state
|
||||
.admin_services()
|
||||
.xai()
|
||||
.complete_authorization(command)
|
||||
.await
|
||||
}
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::CREATED,
|
||||
AdminEnvelope::ok(AccountMutationData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn rotate_account<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<RotateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.openai()
|
||||
.rotate(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountMutationData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_account<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<UpdateAccountRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.update(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(UpdatedAccountData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn delete_accounts<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountDeletionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (provider, command) = request
|
||||
.into_command(auth.context().mutation_context())
|
||||
.map_err(map_wire_error)?;
|
||||
let result = match provider {
|
||||
AccountProvider::OpenAi => state.admin_services().openai().delete(command).await,
|
||||
AccountProvider::Xai => state.admin_services().xai().delete(command).await,
|
||||
}
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountDeletionData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn refresh_account<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountRefreshRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.refresh(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn recover_account<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.recover(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_refresh_data(result, Utc::now());
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_quota<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.quota(&account_id, false)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
account: account_view(result, Utc::now()),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_profile_statistics<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_statistics(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountProfileStatisticsData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_profile_avatar<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountProfileAvatarQuery>,
|
||||
) -> Result<Response, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let avatar = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.profile_avatar(&account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(profile_avatar_response(avatar))
|
||||
}
|
||||
|
||||
/// 将 Provider 头像流投影为受保护的同源 HTTP 响应。
|
||||
#[must_use]
|
||||
pub fn profile_avatar_response(avatar: ProviderProfileAvatar) -> Response {
|
||||
let ProviderProfileAvatar {
|
||||
content_type,
|
||||
content_length,
|
||||
etag,
|
||||
body,
|
||||
} = avatar;
|
||||
let mut response = Response::new(Body::from_stream(body));
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
content_type
|
||||
.and_then(|value| HeaderValue::from_str(&value).ok())
|
||||
.unwrap_or_else(|| HeaderValue::from_static("application/octet-stream")),
|
||||
);
|
||||
if let Some(value) =
|
||||
content_length.and_then(|length| HeaderValue::from_str(&length.to_string()).ok())
|
||||
{
|
||||
headers.insert(header::CONTENT_LENGTH, value);
|
||||
}
|
||||
if let Some(value) = etag.and_then(|value| HeaderValue::from_str(&value).ok()) {
|
||||
headers.insert(header::ETAG, value);
|
||||
}
|
||||
headers.insert(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("private, max-age=3600"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("x-content-type-options"),
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("cross-origin-resource-policy"),
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
headers.insert(
|
||||
HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static("sandbox; default-src 'none'"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
async fn refresh_account_quota<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.quota(&account_id, true)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = AccountQuotaData {
|
||||
account: account_view(result, Utc::now()),
|
||||
};
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn account_reset_credits<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.reset_credits(&auth.context().mutation_context(), account_id)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditsData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn consume_account_reset_credit<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountResetCreditConsumeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let command = request.into_command().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.consume_reset_credit(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(AccountResetCreditResultData::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn account_models<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountIdQuery>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = query.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.models(&account_id, false)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_models_data(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn refresh_account_models<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<AccountActionRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let account_id = request.into_id().map_err(map_wire_error)?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.models(&account_id, true)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = account_models_data(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
async fn test_account_connection<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<AccountTestQuery>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let (account_id, upstream_model) = query.into_command().map_err(map_wire_error)?;
|
||||
let stream = state
|
||||
.admin_services()
|
||||
.accounts()
|
||||
.test_connection(account_id, upstream_model)
|
||||
.await
|
||||
.map_err(map_service_error)?
|
||||
.map(|event| {
|
||||
let event = AccountConnectionTestEvent::from(event);
|
||||
let data = serde_json::to_string(&event.data).unwrap_or_else(|_| "{}".to_owned());
|
||||
Ok(Event::default().data(data))
|
||||
});
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! 管理端账号目录的 HTTP 合同、凭据动作、路由处理与安全响应投影。
|
||||
|
||||
use std::{collections::BTreeSet, convert::Infallible, fmt};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{
|
||||
IntoResponse, Response,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, FixedOffset, Utc};
|
||||
use futures::{Stream, StreamExt as _};
|
||||
use gateway_admin::model::{
|
||||
AdminError as AdminServiceError, PageSize,
|
||||
accounts::{
|
||||
AccountConcurrencyLimit, AccountConnectionTestEvent as DomainConnectionTestEvent,
|
||||
AccountCost, AccountGroupFilter, AccountListQuery, AccountModelUsage, AccountSort,
|
||||
AccountSortField, AccountStatus as DomainAccountStatus, AccountUpdateResult, AccountUsage,
|
||||
AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount,
|
||||
},
|
||||
provider_credentials::{
|
||||
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult,
|
||||
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
|
||||
CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation,
|
||||
CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels,
|
||||
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
|
||||
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
|
||||
ProviderResetCredits, RotateCredential, StartAuthorization,
|
||||
},
|
||||
};
|
||||
use gateway_core::{
|
||||
account::{OpaqueProviderData, ProviderAccountId},
|
||||
routing::{AccountGroupId, ProviderKind, UpstreamModelId},
|
||||
};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use uuid::{Uuid, Version};
|
||||
|
||||
use super::presenter::{format_compact_number, format_decimal_currency, format_number};
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
PageMeta, WireValidationError,
|
||||
};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: u32 = 50;
|
||||
const MAX_PAGE_SIZE: u32 = 200;
|
||||
const MAX_SEARCH_BYTES: usize = 256;
|
||||
const MAX_ID_BYTES: usize = 256;
|
||||
const MAX_NAME_BYTES: usize = 512;
|
||||
const MAX_IMPORT_DATA_BYTES: usize = 64 * 1024 * 1024;
|
||||
const MAX_ACCESS_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_REFRESH_TOKEN_BYTES: usize = 64 * 1024;
|
||||
const MAX_ID_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_CALLBACK_URL_BYTES: usize = 64 * 1024;
|
||||
const MAX_ACCOUNT_DELETE_BATCH: usize = 200;
|
||||
const MAX_ACCOUNT_GROUP_BATCH: usize = 1000;
|
||||
const MAX_AVATAR_VERSION_BYTES: usize = 32;
|
||||
|
||||
mod credentials;
|
||||
mod handlers;
|
||||
mod presenter;
|
||||
mod wire;
|
||||
|
||||
pub use credentials::*;
|
||||
pub use handlers::{profile_avatar_response, router};
|
||||
pub use wire::*;
|
||||
|
||||
use credentials::{
|
||||
AccountProvider, deserialize_required_nullable, parse_account_weight, parse_concurrency_limit,
|
||||
provider_document_value, require_account_id, validate_wire_group_ids,
|
||||
};
|
||||
use presenter::*;
|
||||
use wire::BatchUpdatedAccountsData;
|
||||
@@ -0,0 +1,413 @@
|
||||
//! Admin 领域结果到安全 HTTP wire 的展示投影。
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn account_page_data(
|
||||
result: AccountDirectoryPage,
|
||||
page: u32,
|
||||
page_size: u16,
|
||||
now: DateTime<Utc>,
|
||||
) -> AccountPageData {
|
||||
let total_pages = if result.total == 0 {
|
||||
0
|
||||
} else {
|
||||
u32::try_from(result.total.div_ceil(u64::from(page_size))).unwrap_or(u32::MAX)
|
||||
};
|
||||
AccountPageData {
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| account_view(item, now))
|
||||
.collect(),
|
||||
page: PageMeta::new(page, u32::from(page_size), result.total, total_pages),
|
||||
summary: AccountSummaryView {
|
||||
total: result.summary.total,
|
||||
normal: result.summary.normal,
|
||||
quota_exhausted: result.summary.quota_exhausted,
|
||||
rate_limited: result.summary.rate_limited,
|
||||
disabled: result.summary.disabled,
|
||||
error: result.summary.error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_refresh_data(
|
||||
result: AccountRefreshResult,
|
||||
now: DateTime<Utc>,
|
||||
) -> AccountRefreshData {
|
||||
AccountRefreshData {
|
||||
account: account_view(result.account, now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData {
|
||||
AccountModelsData {
|
||||
models: result
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let id = model.id.to_string();
|
||||
AccountModelView {
|
||||
label: id.clone(),
|
||||
id,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> AccountView {
|
||||
let AccountDirectoryItem {
|
||||
account,
|
||||
projection,
|
||||
usage,
|
||||
quota,
|
||||
} = item;
|
||||
let status = projection.status.as_str().to_owned();
|
||||
let rate_limited_until = projection.rate_limited_until.map(DateTime::<Utc>::from);
|
||||
let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339);
|
||||
let added_at = china_rfc3339(&account.created_at);
|
||||
let updated_at = china_rfc3339(&account.updated_at);
|
||||
let (quota, refresh_token_expires_at) = account_quota_view(quota, rate_limited_until, now);
|
||||
AccountView {
|
||||
id: account.id.clone(),
|
||||
name: account.name,
|
||||
provider: account.provider_kind.to_string(),
|
||||
groups: account
|
||||
.groups
|
||||
.into_iter()
|
||||
.map(|group| AccountGroupRefView {
|
||||
id: group.id.to_string(),
|
||||
name: group.name,
|
||||
color: group.color.as_str().to_owned(),
|
||||
enabled: group.enabled,
|
||||
})
|
||||
.collect(),
|
||||
resource_ref: account.id,
|
||||
email: account.email,
|
||||
account_id: account.upstream_account_id,
|
||||
user_id: account.upstream_user_id,
|
||||
label: None,
|
||||
plan_type: account.plan_type,
|
||||
authentication_kind: account.authentication_kind,
|
||||
has_refresh_token: account.has_refresh_token,
|
||||
status,
|
||||
error_reason: projection
|
||||
.error_reason
|
||||
.map(|reason| reason.as_str().to_owned()),
|
||||
error_message: projection.error_message,
|
||||
enabled: account.enabled,
|
||||
concurrency_limit: account.concurrency_limit.map(|limit| limit.get()),
|
||||
weight: account.weight.get(),
|
||||
access_token_expires_at: expires_at,
|
||||
access_token_expires_at_display: account
|
||||
.access_token_expires_at
|
||||
.as_ref()
|
||||
.map(china_datetime),
|
||||
refresh_token_expires_at,
|
||||
next_refresh_at: account.next_refresh_at.map(|value| china_rfc3339(&value)),
|
||||
added_at,
|
||||
added_at_display: china_datetime(&account.created_at),
|
||||
updated_at,
|
||||
updated_at_display: china_datetime(&account.updated_at),
|
||||
quota,
|
||||
usage: account_usage_view(usage, now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_quota_view(
|
||||
mut quota: ProviderQuota,
|
||||
rate_limited_until: Option<DateTime<Utc>>,
|
||||
now: DateTime<Utc>,
|
||||
) -> (AccountQuotaView, Option<String>) {
|
||||
quota.apply_limit_reached_display();
|
||||
let refresh_token_expires_at = quota
|
||||
.refresh_token_expires_at
|
||||
.map(|value| china_rfc3339(&value));
|
||||
let refreshed_at_display = quota
|
||||
.observed_at
|
||||
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now));
|
||||
let windows = quota.windows.into_iter().map(quota_window_view).collect();
|
||||
let rate_limited_until = rate_limited_until.map(|until| china_datetime(&until));
|
||||
(
|
||||
AccountQuotaView {
|
||||
refreshed_at_display,
|
||||
limit_reached: quota.limit_reached,
|
||||
rate_limited_until,
|
||||
windows,
|
||||
},
|
||||
refresh_token_expires_at,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn quota_window_view(window: ProviderQuotaWindow) -> AccountQuotaWindowView {
|
||||
let ProviderQuotaWindow {
|
||||
key,
|
||||
group,
|
||||
label,
|
||||
limit_id,
|
||||
limit_name,
|
||||
role,
|
||||
local_usage_attribution: _,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at,
|
||||
limit_reached,
|
||||
local_usage,
|
||||
provider_data: _,
|
||||
} = window;
|
||||
let label_display = limit_name
|
||||
.as_ref()
|
||||
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
|
||||
AccountQuotaWindowView {
|
||||
label_display,
|
||||
window_label_display: label,
|
||||
key,
|
||||
group,
|
||||
limit_id,
|
||||
limit_name,
|
||||
role: role.map(|value| value.as_str().to_owned()),
|
||||
window_seconds,
|
||||
used_percent,
|
||||
used_percent_display: used_percent
|
||||
.map_or_else(|| "—".to_owned(), |value| format!("{value:.1}%")),
|
||||
limit_reached,
|
||||
local_usage: local_usage.as_ref().map(quota_local_usage),
|
||||
reset_at_display: reset_at.map_or_else(|| "—".to_owned(), |value| china_datetime(&value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn quota_local_usage(usage: &AccountUsage) -> Value {
|
||||
let total_tokens = usage.total_tokens.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"requestCount": usage.request_count,
|
||||
"requestCountDisplay": format_number(usage.request_count),
|
||||
"inputTokens": usage.input_tokens.unwrap_or_default(),
|
||||
"inputTokensDisplay": display_optional_tokens(usage.input_tokens),
|
||||
"outputTokens": usage.output_tokens.unwrap_or_default(),
|
||||
"outputTokensDisplay": display_optional_tokens(usage.output_tokens),
|
||||
"cachedTokens": usage.cached_tokens.unwrap_or_default(),
|
||||
"cachedTokensDisplay": display_optional_tokens(usage.cached_tokens),
|
||||
"imageInputTokens": usage.image_input_tokens.unwrap_or_default(),
|
||||
"imageInputTokensDisplay": display_optional_tokens(usage.image_input_tokens),
|
||||
"imageOutputTokens": usage.image_output_tokens.unwrap_or_default(),
|
||||
"imageOutputTokensDisplay": display_optional_tokens(usage.image_output_tokens),
|
||||
"imageRequestCount": usage.image_request_count,
|
||||
"imageRequestFailedCount": usage.image_request_failed_count,
|
||||
"totalTokens": total_tokens,
|
||||
"totalTokensDisplay": format_compact_number(total_tokens),
|
||||
"requestBuckets": usage.request_buckets.iter().map(|bucket| serde_json::json!({
|
||||
"bucketStart": bucket.bucket_start,
|
||||
"requestCount": bucket.request_count,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn account_usage_view(
|
||||
usage: Option<AccountUsage>,
|
||||
now: DateTime<Utc>,
|
||||
) -> AccountUsageView {
|
||||
let Some(usage) = usage else {
|
||||
return empty_account_usage();
|
||||
};
|
||||
let known_count = usage
|
||||
.cost_coverage
|
||||
.provider_reported_count
|
||||
.saturating_add(usage.cost_coverage.calculated_count);
|
||||
let cost_estimate_status = if known_count == 0 {
|
||||
"unknown"
|
||||
} else if usage.cost_coverage.unavailable_count > 0 {
|
||||
"partial"
|
||||
} else {
|
||||
"known"
|
||||
};
|
||||
AccountUsageView {
|
||||
request_count: Some(usage.request_count),
|
||||
request_count_display: format_number(usage.request_count),
|
||||
input_tokens: usage.input_tokens,
|
||||
input_tokens_display: display_optional_tokens(usage.input_tokens),
|
||||
output_tokens: usage.output_tokens,
|
||||
output_tokens_display: display_optional_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
|
||||
image_request_count: Some(usage.image_request_count),
|
||||
image_request_count_display: format_number(usage.image_request_count),
|
||||
image_request_failed_count: Some(usage.image_request_failed_count),
|
||||
image_request_failed_count_display: format_number(usage.image_request_failed_count),
|
||||
total_tokens: usage.total_tokens,
|
||||
total_tokens_display: display_optional_tokens(usage.total_tokens),
|
||||
created_tokens: usage.cache_write_tokens,
|
||||
created_tokens_display: display_optional_tokens(usage.cache_write_tokens),
|
||||
read_tokens: usage.cached_tokens,
|
||||
read_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
last_used_at: usage.last_used_at.map(|value| china_rfc3339(&value)),
|
||||
last_used_at_display: usage
|
||||
.last_used_at
|
||||
.map_or_else(|| "—".to_owned(), |value| relative_time(value, now)),
|
||||
cost_estimate_status: cost_estimate_status.to_owned(),
|
||||
known_cost_count: Some(known_count),
|
||||
partial_cost_count: Some(u64::from(cost_estimate_status == "partial")),
|
||||
unknown_cost_count: Some(usage.cost_coverage.unavailable_count),
|
||||
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
|
||||
models: usage
|
||||
.models
|
||||
.into_iter()
|
||||
.map(|model| account_model_usage_view(model, now))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_model_usage_view(
|
||||
usage: AccountModelUsage,
|
||||
now: DateTime<Utc>,
|
||||
) -> ModelUsageView {
|
||||
let known_count = usage
|
||||
.cost_coverage
|
||||
.provider_reported_count
|
||||
.saturating_add(usage.cost_coverage.calculated_count);
|
||||
let cost_estimate_status = if known_count == 0 {
|
||||
"unknown"
|
||||
} else if usage.cost_coverage.unavailable_count > 0 {
|
||||
"partial"
|
||||
} else {
|
||||
"known"
|
||||
};
|
||||
let usd = usage
|
||||
.costs
|
||||
.iter()
|
||||
.find(|cost| cost.currency.eq_ignore_ascii_case("USD"));
|
||||
ModelUsageView {
|
||||
model: usage.model,
|
||||
request_count: usage.request_count,
|
||||
request_count_display: format_number(usage.request_count),
|
||||
success_rate: (usage.request_count > 0)
|
||||
.then(|| usage.success_count as f64 * 100.0 / usage.request_count as f64),
|
||||
success_rate_display: if usage.request_count == 0 {
|
||||
"—".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"{:.1}%",
|
||||
usage.success_count as f64 * 100.0 / usage.request_count as f64
|
||||
)
|
||||
},
|
||||
input_tokens: usage.input_tokens,
|
||||
input_tokens_display: display_optional_tokens(usage.input_tokens),
|
||||
output_tokens: usage.output_tokens,
|
||||
output_tokens_display: display_optional_tokens(usage.output_tokens),
|
||||
cached_tokens: usage.cached_tokens,
|
||||
cached_tokens_display: display_optional_tokens(usage.cached_tokens),
|
||||
image_input_tokens: usage.image_input_tokens,
|
||||
image_input_tokens_display: display_optional_tokens(usage.image_input_tokens),
|
||||
image_output_tokens: usage.image_output_tokens,
|
||||
image_output_tokens_display: display_optional_tokens(usage.image_output_tokens),
|
||||
image_request_count: usage.image_request_count,
|
||||
image_request_count_display: format_number(usage.image_request_count),
|
||||
image_request_failed_count: usage.image_request_failed_count,
|
||||
image_request_failed_count_display: format_number(usage.image_request_failed_count),
|
||||
total_tokens: usage.total_tokens,
|
||||
total_tokens_display: display_optional_tokens(usage.total_tokens),
|
||||
billing_amount_usd: usd.map(|cost| cost.amount.as_str().to_owned()),
|
||||
billing_amount_usd_display: usd.map_or_else(
|
||||
|| "—".to_owned(),
|
||||
|cost| format_decimal_currency(cost.amount.as_str(), "USD"),
|
||||
),
|
||||
cost_estimate_status: cost_estimate_status.to_owned(),
|
||||
known_cost_count: known_count,
|
||||
partial_cost_count: u64::from(cost_estimate_status == "partial"),
|
||||
unknown_cost_count: usage.cost_coverage.unavailable_count,
|
||||
costs: usage.costs.iter().map(account_currency_cost_view).collect(),
|
||||
last_used_at: china_rfc3339(&usage.last_used_at),
|
||||
last_used_at_display: relative_time(usage.last_used_at, now),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn account_currency_cost_view(cost: &AccountCost) -> CurrencyCostView {
|
||||
CurrencyCostView {
|
||||
currency: cost.currency.clone(),
|
||||
estimated_amount: cost.amount.as_str().to_owned(),
|
||||
estimated_amount_display: format_decimal_currency(cost.amount.as_str(), &cost.currency),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_optional_tokens(value: Option<u64>) -> String {
|
||||
value.map_or_else(|| "—".to_owned(), format_compact_number)
|
||||
}
|
||||
|
||||
pub(super) fn empty_account_usage() -> AccountUsageView {
|
||||
AccountUsageView {
|
||||
request_count: None,
|
||||
request_count_display: "—".to_owned(),
|
||||
input_tokens: None,
|
||||
input_tokens_display: "—".to_owned(),
|
||||
output_tokens: None,
|
||||
output_tokens_display: "—".to_owned(),
|
||||
cached_tokens: None,
|
||||
cached_tokens_display: "—".to_owned(),
|
||||
image_input_tokens: None,
|
||||
image_input_tokens_display: "—".to_owned(),
|
||||
image_output_tokens: None,
|
||||
image_output_tokens_display: "—".to_owned(),
|
||||
image_request_count: None,
|
||||
image_request_count_display: "—".to_owned(),
|
||||
image_request_failed_count: None,
|
||||
image_request_failed_count_display: "—".to_owned(),
|
||||
total_tokens: None,
|
||||
total_tokens_display: "—".to_owned(),
|
||||
created_tokens: None,
|
||||
created_tokens_display: "—".to_owned(),
|
||||
read_tokens: None,
|
||||
read_tokens_display: "—".to_owned(),
|
||||
last_used_at: None,
|
||||
last_used_at_display: "—".to_owned(),
|
||||
cost_estimate_status: "unavailable".to_owned(),
|
||||
known_cost_count: None,
|
||||
partial_cost_count: None,
|
||||
unknown_cost_count: None,
|
||||
costs: Vec::new(),
|
||||
models: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn relative_time(value: DateTime<Utc>, now: DateTime<Utc>) -> String {
|
||||
let elapsed = now.signed_duration_since(value);
|
||||
if elapsed.num_seconds() < 0 {
|
||||
return china_datetime(&value);
|
||||
}
|
||||
if elapsed.num_seconds() < 60 {
|
||||
return "刚刚".to_owned();
|
||||
}
|
||||
if elapsed.num_minutes() < 60 {
|
||||
return format!("{} 分钟前", elapsed.num_minutes());
|
||||
}
|
||||
if elapsed.num_hours() < 24 {
|
||||
return format!("{} 小时前", elapsed.num_hours());
|
||||
}
|
||||
format!("{} 天前", elapsed.num_days())
|
||||
}
|
||||
|
||||
pub(super) fn china_offset() -> FixedOffset {
|
||||
FixedOffset::east_opt(8 * 60 * 60).expect("UTC+8 is a valid fixed offset")
|
||||
}
|
||||
|
||||
pub(super) fn china_rfc3339(value: &DateTime<Utc>) -> String {
|
||||
value.with_timezone(&china_offset()).to_rfc3339()
|
||||
}
|
||||
|
||||
pub(super) fn china_datetime(value: &DateTime<Utc>) -> String {
|
||||
value
|
||||
.with_timezone(&china_offset())
|
||||
.format("%Y-%m-%d %H:%M:%S")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(super) fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
AdminError::bad_request(format!("{} 字段不合法", error.field()))
|
||||
}
|
||||
|
||||
pub(super) fn map_service_error(error: AdminServiceError) -> AdminError {
|
||||
super::super::wire::map_admin_service_error(error)
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
//! 账号管理请求、响应与查询 wire contract。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 账号列表查询参数。
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ListQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
pub provider: Option<String>,
|
||||
pub group_id: Option<String>,
|
||||
pub search: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_direction: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: bool,
|
||||
#[serde(deserialize_with = "deserialize_required_nullable")]
|
||||
pub concurrency_limit: Option<u64>,
|
||||
pub weight: u64,
|
||||
pub group_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct BatchUpdatedAccountsData {
|
||||
account_ids: Vec<String>,
|
||||
config_revision: u64,
|
||||
}
|
||||
|
||||
impl From<AccountsUpdateResult> for BatchUpdatedAccountsData {
|
||||
fn from(result: AccountsUpdateResult) -> Self {
|
||||
Self {
|
||||
account_ids: result
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(|id| id.to_string())
|
||||
.collect(),
|
||||
config_revision: result.config_revision.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BatchUpdateAccountsRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self.account_ids.is_empty()
|
||||
|| self.account_ids.len() > MAX_ACCOUNT_GROUP_BATCH
|
||||
|| self
|
||||
.account_ids
|
||||
.iter()
|
||||
.any(|id| require_account_id(id, "accountIds").is_err())
|
||||
|| self.account_ids.iter().collect::<BTreeSet<_>>().len() != self.account_ids.len()
|
||||
{
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
validate_wire_group_ids(&self.group_ids)?;
|
||||
parse_concurrency_limit(self.concurrency_limit)?;
|
||||
parse_account_weight(self.weight)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
account_ids: self.account_ids,
|
||||
enabled: self.enabled,
|
||||
concurrency_limit: parse_concurrency_limit(self.concurrency_limit)?,
|
||||
weight: parse_account_weight(self.weight)?,
|
||||
group_ids: validate_wire_group_ids(&self.group_ids)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ListQuery {
|
||||
/// 解析并校验全部 wire 字段,生成 Admin 查询命令。
|
||||
pub fn validate(self) -> Result<AccountListQuery, 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"));
|
||||
}
|
||||
let provider_kind = parse_provider(self.provider.as_deref().unwrap_or("all"))?;
|
||||
let group_filter = match self.group_id.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some("ungrouped") => Some(AccountGroupFilter::Ungrouped),
|
||||
Some(value) => Some(AccountGroupFilter::Group(
|
||||
AccountGroupId::new(value.to_owned())
|
||||
.map_err(|_| WireValidationError::new("groupId"))?,
|
||||
)),
|
||||
};
|
||||
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() > MAX_SEARCH_BYTES || value.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(WireValidationError::new("search"));
|
||||
}
|
||||
let status = match self.status.as_deref().map(str::trim) {
|
||||
None | Some("") => None,
|
||||
Some(value) => Some(
|
||||
DomainAccountStatus::parse(&value.to_ascii_lowercase())
|
||||
.ok_or_else(|| WireValidationError::new("status"))?,
|
||||
),
|
||||
};
|
||||
let sort = match (self.sort_by.as_deref(), self.sort_direction.as_deref()) {
|
||||
(None, None) => None,
|
||||
(Some(field), Some(direction)) => Some(AccountSort {
|
||||
field: parse_sort_field(field).ok_or_else(|| WireValidationError::new("sortBy"))?,
|
||||
direction: parse_sort_direction(direction)
|
||||
.ok_or_else(|| WireValidationError::new("sortDirection"))?,
|
||||
}),
|
||||
_ => return Err(WireValidationError::new("sort")),
|
||||
};
|
||||
Ok(AccountListQuery {
|
||||
page,
|
||||
page_size: PageSize::new(
|
||||
u16::try_from(page_size).map_err(|_| WireValidationError::new("pageSize"))?,
|
||||
)
|
||||
.map_err(|_| WireValidationError::new("pageSize"))?,
|
||||
provider_kind,
|
||||
group_filter,
|
||||
search,
|
||||
status,
|
||||
sort,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_provider(value: &str) -> Result<Option<ProviderKind>, WireValidationError> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.eq_ignore_ascii_case("all") {
|
||||
return Ok(None);
|
||||
}
|
||||
ProviderKind::new(value.to_owned())
|
||||
.map(Some)
|
||||
.map_err(|_| WireValidationError::new("provider"))
|
||||
}
|
||||
|
||||
fn parse_sort_field(value: &str) -> Option<AccountSortField> {
|
||||
match value.trim() {
|
||||
"email" => Some(AccountSortField::Email),
|
||||
"status" => Some(AccountSortField::Status),
|
||||
"planType" => Some(AccountSortField::PlanType),
|
||||
"usage" => Some(AccountSortField::Usage),
|
||||
"lastUsedAt" => Some(AccountSortField::LastUsedAt),
|
||||
"expiresAt" => Some(AccountSortField::ExpiresAt),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_sort_direction(value: &str) -> Option<SortDirection> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"asc" => Some(SortDirection::Asc),
|
||||
"desc" => Some(SortDirection::Desc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号列表响应数据。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountPageData {
|
||||
pub items: Vec<AccountView>,
|
||||
pub page: PageMeta,
|
||||
pub summary: AccountSummaryView,
|
||||
}
|
||||
|
||||
/// 账号概览计数。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
pub error: u64,
|
||||
}
|
||||
|
||||
/// 一条安全账号视图。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub provider: String,
|
||||
pub groups: Vec<AccountGroupRefView>,
|
||||
pub resource_ref: String,
|
||||
pub email: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub user_id: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub plan_type: Option<String>,
|
||||
pub authentication_kind: String,
|
||||
pub has_refresh_token: bool,
|
||||
pub status: String,
|
||||
/// `status == "error"` 时的具体原因;其余状态为 `null`。
|
||||
pub error_reason: Option<String>,
|
||||
/// 最近一次失败的上游错误描述;仅错误状态存在。
|
||||
pub error_message: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub concurrency_limit: Option<u32>,
|
||||
pub weight: u16,
|
||||
pub access_token_expires_at: Option<String>,
|
||||
pub access_token_expires_at_display: Option<String>,
|
||||
pub refresh_token_expires_at: Option<String>,
|
||||
pub next_refresh_at: Option<String>,
|
||||
pub added_at: String,
|
||||
pub added_at_display: String,
|
||||
pub updated_at: String,
|
||||
pub updated_at_display: String,
|
||||
pub quota: AccountQuotaView,
|
||||
pub usage: AccountUsageView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountGroupRefView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Provider quota 安全视图。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub refreshed_at_display: String,
|
||||
pub limit_reached: bool,
|
||||
/// 429 临时限流(Redis 冷却)到期时间展示;非限流中为 `null`。
|
||||
pub rate_limited_until: Option<String>,
|
||||
pub windows: Vec<AccountQuotaWindowView>,
|
||||
}
|
||||
|
||||
/// 一个 quota 时间窗口。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaWindowView {
|
||||
pub key: String,
|
||||
pub group: String,
|
||||
pub limit_id: Option<String>,
|
||||
pub limit_name: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub window_seconds: Option<u64>,
|
||||
pub label_display: String,
|
||||
pub window_label_display: String,
|
||||
pub used_percent: Option<f64>,
|
||||
pub used_percent_display: String,
|
||||
pub limit_reached: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub local_usage: Option<serde_json::Value>,
|
||||
pub reset_at_display: String,
|
||||
}
|
||||
|
||||
/// 账号观测用量。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountUsageView {
|
||||
pub request_count: Option<u64>,
|
||||
pub request_count_display: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cached_tokens_display: String,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_output_tokens_display: String,
|
||||
pub image_request_count: Option<u64>,
|
||||
pub image_request_count_display: String,
|
||||
pub image_request_failed_count: Option<u64>,
|
||||
pub image_request_failed_count_display: String,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub total_tokens_display: String,
|
||||
pub created_tokens: Option<u64>,
|
||||
pub created_tokens_display: String,
|
||||
pub read_tokens: Option<u64>,
|
||||
pub read_tokens_display: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub last_used_at_display: String,
|
||||
pub cost_estimate_status: String,
|
||||
pub known_cost_count: Option<u64>,
|
||||
pub partial_cost_count: Option<u64>,
|
||||
pub unknown_cost_count: Option<u64>,
|
||||
pub costs: Vec<CurrencyCostView>,
|
||||
pub models: Vec<ModelUsageView>,
|
||||
}
|
||||
|
||||
/// 凭据在单个上游模型上的观测用量。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ModelUsageView {
|
||||
pub model: String,
|
||||
pub request_count: u64,
|
||||
pub request_count_display: String,
|
||||
pub success_rate: Option<f64>,
|
||||
pub success_rate_display: String,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub input_tokens_display: String,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub output_tokens_display: String,
|
||||
pub cached_tokens: Option<u64>,
|
||||
pub cached_tokens_display: String,
|
||||
pub image_input_tokens: Option<u64>,
|
||||
pub image_input_tokens_display: String,
|
||||
pub image_output_tokens: Option<u64>,
|
||||
pub image_output_tokens_display: String,
|
||||
pub image_request_count: u64,
|
||||
pub image_request_count_display: String,
|
||||
pub image_request_failed_count: u64,
|
||||
pub image_request_failed_count_display: String,
|
||||
pub total_tokens: Option<u64>,
|
||||
pub total_tokens_display: String,
|
||||
pub billing_amount_usd: Option<String>,
|
||||
pub billing_amount_usd_display: String,
|
||||
pub cost_estimate_status: String,
|
||||
pub known_cost_count: u64,
|
||||
pub partial_cost_count: u64,
|
||||
pub unknown_cost_count: u64,
|
||||
pub costs: Vec<CurrencyCostView>,
|
||||
pub last_used_at: String,
|
||||
pub last_used_at_display: String,
|
||||
}
|
||||
|
||||
/// 单一货币的可查询成本。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurrencyCostView {
|
||||
pub currency: String,
|
||||
pub estimated_amount: String,
|
||||
pub estimated_amount_display: String,
|
||||
}
|
||||
|
||||
/// 账号详情类 GET 的固定 ID query。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountIdQuery {
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
impl AccountIdQuery {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 头像 GET 的固定 query;`version` 只参与浏览器缓存键,不进入上游请求。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountProfileAvatarQuery {
|
||||
pub account_id: String,
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl AccountProfileAvatarQuery {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.version.as_deref().is_some_and(|version| {
|
||||
version.is_empty()
|
||||
|| version.len() > MAX_AVATAR_VERSION_BYTES
|
||||
|| !version.bytes().all(|byte| byte.is_ascii_alphanumeric())
|
||||
}) {
|
||||
return Err(WireValidationError::new("version"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 敏感导出的固定 query;IDs 使用逗号分隔,禁止隐式导出全部账号。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountExportQuery {
|
||||
pub account_ids: String,
|
||||
pub confirm: String,
|
||||
}
|
||||
|
||||
impl AccountExportQuery {
|
||||
pub fn into_ids(self) -> Result<Vec<ProviderAccountId>, WireValidationError> {
|
||||
if self.confirm != "export_sensitive_accounts" {
|
||||
return Err(WireValidationError::new("confirm"));
|
||||
}
|
||||
let ids = self
|
||||
.account_ids
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if ids.is_empty()
|
||||
|| ids.len() > 200
|
||||
|| ids
|
||||
.iter()
|
||||
.any(|id| require_account_id(id, "accountIds").is_err())
|
||||
{
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
let unique = ids.iter().collect::<BTreeSet<_>>();
|
||||
if unique.len() != ids.len() {
|
||||
return Err(WireValidationError::new("accountIds"));
|
||||
}
|
||||
ids.into_iter()
|
||||
.map(|id| {
|
||||
ProviderAccountId::new(id).map_err(|_| WireValidationError::new("accountIds"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号运行期动作。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountActionRequest {
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
impl AccountActionRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
pub(super) fn into_id(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动额度重置卡消费请求。幂等键由 UI 生成并在不确定重试时复用,与官方一致。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountResetCreditConsumeRequest {
|
||||
pub account_id: String,
|
||||
#[serde(default)]
|
||||
pub credit_id: Option<String>,
|
||||
pub redeem_request_id: String,
|
||||
}
|
||||
|
||||
impl AccountResetCreditConsumeRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.credit_id.as_deref().is_some_and(|value| {
|
||||
let value = value.trim();
|
||||
value.is_empty() || value.len() > 512 || value.chars().any(char::is_control)
|
||||
}) {
|
||||
return Err(WireValidationError::new("creditId"));
|
||||
}
|
||||
let value = self.redeem_request_id.trim();
|
||||
let redeem_request_id =
|
||||
Uuid::parse_str(value).map_err(|_| WireValidationError::new("redeemRequestId"))?;
|
||||
if redeem_request_id.get_version() != Some(Version::Random)
|
||||
|| redeem_request_id.hyphenated().to_string() != value
|
||||
{
|
||||
return Err(WireValidationError::new("redeemRequestId"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(self) -> Result<ConsumeProviderResetCredit, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(ConsumeProviderResetCredit {
|
||||
account_id: ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
credit_id: self.credit_id.map(|value| value.trim().to_owned()),
|
||||
redeem_request_id: Uuid::parse_str(&self.redeem_request_id)
|
||||
.map_err(|_| WireValidationError::new("redeemRequestId"))?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 手工 OAuth 刷新会变更持久 credential。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountRefreshRequest {
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
impl AccountRefreshRequest {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")
|
||||
}
|
||||
|
||||
pub(super) fn into_command(self) -> Result<ProviderAccountId, WireValidationError> {
|
||||
self.validate()?;
|
||||
ProviderAccountId::new(self.account_id).map_err(|_| WireValidationError::new("accountId"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接测试 query;测试仍经唯一 Core/Provider 模型请求路径执行。
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountTestQuery {
|
||||
pub account_id: String,
|
||||
pub model_id: String,
|
||||
}
|
||||
|
||||
impl AccountTestQuery {
|
||||
pub fn validate(&self) -> Result<(), WireValidationError> {
|
||||
require_account_id(&self.account_id, "accountId")?;
|
||||
if self.model_id.trim().is_empty() || self.model_id.chars().any(char::is_control) {
|
||||
return Err(WireValidationError::new("modelId"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn into_command(
|
||||
self,
|
||||
) -> Result<(ProviderAccountId, UpstreamModelId), WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok((
|
||||
ProviderAccountId::new(self.account_id)
|
||||
.map_err(|_| WireValidationError::new("accountId"))?,
|
||||
UpstreamModelId::new(self.model_id).map_err(|_| WireValidationError::new("modelId"))?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AccountModelView {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AccountModelsData {
|
||||
pub models: Vec<AccountModelView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountRefreshData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AccountQuotaData {
|
||||
pub account: AccountView,
|
||||
}
|
||||
|
||||
/// Provider 官方个人资料统计响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileStatisticsData {
|
||||
pub display_name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub has_stats_error: bool,
|
||||
pub summary: AccountProfileStatisticsSummaryView,
|
||||
pub daily_usage: Option<Vec<AccountProfileDailyUsageView>>,
|
||||
pub activity_insights: AccountProfileActivityInsightsView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileStatisticsSummaryView {
|
||||
pub total_text_tokens: Option<u64>,
|
||||
pub peak_tokens: Option<u64>,
|
||||
pub longest_task_duration_ms: Option<u64>,
|
||||
pub current_streak_days: Option<u64>,
|
||||
pub longest_streak_days: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileDailyUsageView {
|
||||
pub date: String,
|
||||
pub tokens: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileActivityInsightsView {
|
||||
pub fast_mode_percent: Option<f64>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_effort_percent: Option<f64>,
|
||||
pub skills_explored: Option<u64>,
|
||||
pub total_skills_used: Option<u64>,
|
||||
pub total_threads: Option<u64>,
|
||||
pub invocations: Option<Vec<AccountProfileInvocationView>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountProfileInvocationView {
|
||||
#[serde(rename = "type")]
|
||||
pub invocation_type: String,
|
||||
pub plugin_id: Option<String>,
|
||||
pub plugin_name: Option<String>,
|
||||
pub skill_id: Option<String>,
|
||||
pub skill_name: Option<String>,
|
||||
pub usage_count: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<ProviderProfileStatistics> for AccountProfileStatisticsData {
|
||||
fn from(statistics: ProviderProfileStatistics) -> Self {
|
||||
Self {
|
||||
display_name: statistics.display_name,
|
||||
username: statistics.username,
|
||||
image_url: statistics.image_url,
|
||||
has_stats_error: statistics.has_stats_error,
|
||||
summary: profile_statistics_summary_view(statistics.summary),
|
||||
daily_usage: statistics
|
||||
.daily_usage
|
||||
.map(|daily| daily.into_iter().map(profile_daily_usage_view).collect()),
|
||||
activity_insights: profile_activity_insights_view(statistics.activity_insights),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn profile_statistics_summary_view(
|
||||
summary: ProviderProfileStatisticsSummary,
|
||||
) -> AccountProfileStatisticsSummaryView {
|
||||
AccountProfileStatisticsSummaryView {
|
||||
total_text_tokens: summary.total_text_tokens,
|
||||
peak_tokens: summary.peak_tokens,
|
||||
longest_task_duration_ms: summary.longest_task_duration_ms,
|
||||
current_streak_days: summary.current_streak_days,
|
||||
longest_streak_days: summary.longest_streak_days,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_daily_usage_view(daily: ProviderProfileDailyUsage) -> AccountProfileDailyUsageView {
|
||||
AccountProfileDailyUsageView {
|
||||
date: daily.date.to_string(),
|
||||
tokens: daily.tokens,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_activity_insights_view(
|
||||
insights: ProviderProfileActivityInsights,
|
||||
) -> AccountProfileActivityInsightsView {
|
||||
AccountProfileActivityInsightsView {
|
||||
fast_mode_percent: insights.fast_mode_percent,
|
||||
reasoning_effort: insights.reasoning_effort,
|
||||
reasoning_effort_percent: insights.reasoning_effort_percent,
|
||||
skills_explored: insights.skills_explored,
|
||||
total_skills_used: insights.total_skills_used,
|
||||
total_threads: insights.total_threads,
|
||||
invocations: insights.invocations.map(|invocations| {
|
||||
invocations
|
||||
.into_iter()
|
||||
.map(profile_invocation_view)
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_invocation_view(invocation: ProviderProfileInvocation) -> AccountProfileInvocationView {
|
||||
AccountProfileInvocationView {
|
||||
invocation_type: invocation.invocation_type,
|
||||
plugin_id: invocation.plugin_id,
|
||||
plugin_name: invocation.plugin_name,
|
||||
skill_id: invocation.skill_id,
|
||||
skill_name: invocation.skill_name,
|
||||
usage_count: invocation.usage_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// 主动额度重置卡列表响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditsData {
|
||||
pub available_count: u64,
|
||||
pub credits: Vec<AccountResetCreditView>,
|
||||
}
|
||||
|
||||
/// 一张安全主动额度重置卡视图。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditView {
|
||||
pub id: String,
|
||||
pub status: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub reset_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 主动额度重置卡消费响应。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountResetCreditResultData {
|
||||
pub code: String,
|
||||
pub credit: Option<AccountResetCreditView>,
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredit> for AccountResetCreditView {
|
||||
fn from(credit: ProviderResetCredit) -> Self {
|
||||
Self {
|
||||
id: credit.id,
|
||||
status: credit.status,
|
||||
title: credit.title,
|
||||
expires_at: credit.expires_at.map(|value| value.to_rfc3339()),
|
||||
reset_type: credit.reset_type,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCredits> for AccountResetCreditsData {
|
||||
fn from(credits: ProviderResetCredits) -> Self {
|
||||
Self {
|
||||
available_count: credits.available_count,
|
||||
credits: credits
|
||||
.credits
|
||||
.into_iter()
|
||||
.map(AccountResetCreditView::from)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProviderResetCreditResult> for AccountResetCreditResultData {
|
||||
fn from(result: ProviderResetCreditResult) -> Self {
|
||||
Self {
|
||||
code: result.code,
|
||||
credit: result.credit.map(AccountResetCreditView::from),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-owned 明文导出文档;Debug 永远不输出内部 JSON。
|
||||
#[derive(Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct AccountExportData(Value);
|
||||
|
||||
impl AccountExportData {
|
||||
#[must_use]
|
||||
pub const fn new(value: Value) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub(super) fn from_result(bundle: AccountExportBundle) -> Self {
|
||||
let documents = bundle
|
||||
.documents
|
||||
.into_iter()
|
||||
.map(|document| {
|
||||
serde_json::json!({
|
||||
"provider": document.provider_kind.to_string(),
|
||||
"document": provider_document_value(document.document),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Self::new(serde_json::json!({
|
||||
"exportedAt": bundle.exported_at.to_rfc3339(),
|
||||
"documents": documents,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for AccountExportData {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("AccountExportData(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AccountConnectionTestEvent {
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
impl From<DomainConnectionTestEvent> for AccountConnectionTestEvent {
|
||||
fn from(event: DomainConnectionTestEvent) -> Self {
|
||||
let data = match event {
|
||||
DomainConnectionTestEvent::Started { model } => serde_json::json!({
|
||||
"type": "test_start",
|
||||
"model": model,
|
||||
"text": "正在连接上游 Responses"
|
||||
}),
|
||||
DomainConnectionTestEvent::Request {
|
||||
model,
|
||||
input_text,
|
||||
stream,
|
||||
store,
|
||||
} => serde_json::json!({
|
||||
"type": "request",
|
||||
"payload": {
|
||||
"model": model,
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{ "type": "input_text", "text": input_text }]
|
||||
}],
|
||||
"stream": stream,
|
||||
"store": store
|
||||
}
|
||||
}),
|
||||
DomainConnectionTestEvent::Content { text } => {
|
||||
serde_json::json!({ "type": "content", "text": text })
|
||||
}
|
||||
DomainConnectionTestEvent::Completed => serde_json::json!({
|
||||
"type": "test_complete",
|
||||
"success": true
|
||||
}),
|
||||
DomainConnectionTestEvent::Failed {
|
||||
source,
|
||||
gateway_error_code,
|
||||
send_state,
|
||||
message,
|
||||
provider_error_code,
|
||||
provider_error_type,
|
||||
upstream_status,
|
||||
upstream_content_type,
|
||||
upstream_body,
|
||||
} => serde_json::json!({
|
||||
"type": "error",
|
||||
"source": source.as_str(),
|
||||
"gatewayErrorCode": gateway_error_code.as_str(),
|
||||
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
|
||||
"error": message,
|
||||
"providerErrorCode": provider_error_code,
|
||||
"providerErrorType": provider_error_type,
|
||||
"upstreamStatus": upstream_status,
|
||||
"upstreamContentType": upstream_content_type,
|
||||
"upstreamBody": upstream_body
|
||||
}),
|
||||
};
|
||||
Self { data }
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ async fn admin_not_found() -> AdminError {
|
||||
async fn no_store(mut response: Response) -> Response {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
.entry(header::CACHE_CONTROL)
|
||||
.or_insert(HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{Router, extract::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};
|
||||
|
||||
@@ -515,18 +515,13 @@ pub(crate) fn usage_detail_view(detail: domain::UsageDetail) -> UsageRecordDetai
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usage_page_view(
|
||||
page: domain::UsagePage,
|
||||
) -> Result<PageData<UsageListRecordView>, WireValidationError> {
|
||||
Ok(PageData {
|
||||
pub(crate) fn usage_page_view(page: domain::UsagePage) -> PageData<UsageListRecordView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(usage_list_record_view).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.map(encode_observability_cursor)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
@@ -536,48 +531,78 @@ pub(crate) fn ops_error_view(error: domain::OpsError) -> OpsErrorView {
|
||||
.or(error.provider_account_name.as_ref())
|
||||
.or(error.provider_account_ref.as_ref())
|
||||
.cloned();
|
||||
let model = error
|
||||
.upstream_model_id
|
||||
.clone()
|
||||
.or_else(|| error.requested_model_id.clone());
|
||||
OpsErrorView {
|
||||
id: error.event_id,
|
||||
request_id: error.request_id,
|
||||
client_api_key_id: error.client_api_key_ref,
|
||||
kind: error.component.clone(),
|
||||
operation: error.operation,
|
||||
protocol: error.protocol,
|
||||
client_transport: error.client_transport,
|
||||
provider: error.provider_kind,
|
||||
authentication_kind: error.provider_account_authentication_kind,
|
||||
account_id: error.provider_account_ref,
|
||||
account_name: error.provider_account_name,
|
||||
account_email: error.provider_account_email,
|
||||
route: error.endpoint.unwrap_or_else(|| "—".to_owned()),
|
||||
model: error.upstream_model_id,
|
||||
model,
|
||||
requested_model: error.requested_model_id,
|
||||
upstream_model: error.upstream_model_id,
|
||||
service_tier: error.service_tier,
|
||||
client_status_code: error.client_status_code.map(i64::from),
|
||||
upstream_status_code: error.upstream_status_code.map(i64::from),
|
||||
transport: error.upstream_transport,
|
||||
attempt_index: error.attempt_index,
|
||||
failure_class: error.failure_kind,
|
||||
upstream_send_state: error.upstream_send_state,
|
||||
provider_error_code: error.provider_error_code,
|
||||
occurrence_count: error.occurrence_count,
|
||||
response_id: error.client_response_id,
|
||||
upstream_request_id: error.upstream_request_id,
|
||||
latency_ms: error.latency_ms,
|
||||
client_ip: error.client_ip,
|
||||
user_agent: error.user_agent,
|
||||
reasoning_effort: error.reasoning_effort,
|
||||
reasoning_preset: error.reasoning_preset,
|
||||
request_kind: error.request_kind,
|
||||
subagent_kind: error.subagent_kind,
|
||||
compact: error.compact,
|
||||
message: error.message,
|
||||
raw_upstream_error: error.raw_upstream_error,
|
||||
metadata: OpsErrorMetadataView {
|
||||
source: error.source,
|
||||
component: error.component,
|
||||
attempt_id: None,
|
||||
account_label,
|
||||
continuation_affinity_hash: error.continuation_affinity_hash,
|
||||
continuation_previous_response_id_hash: error.continuation_previous_response_id_hash,
|
||||
continuation_unavailable_reason: error.continuation_unavailable_reason,
|
||||
upstream_connection_id: error.upstream_connection_id,
|
||||
upstream_connection_exit_reason: error.upstream_connection_exit_reason,
|
||||
upstream_connection_age_ms: error.upstream_connection_age_ms,
|
||||
upstream_connection_idle_ms: error.upstream_connection_idle_ms,
|
||||
recovery_request_id: error.recovery_request_id,
|
||||
recovered_at: error.recovered_at,
|
||||
recovery_attempt_count: error.recovery_attempt_count,
|
||||
recovery_retry_delay_ms: error.recovery_retry_delay_ms,
|
||||
recovery_total_latency_ms: error.recovery_total_latency_ms,
|
||||
},
|
||||
created_at: error.occurred_at,
|
||||
created_at_display: china_datetime(&error.occurred_at),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ops_page_view(
|
||||
page: domain::OpsErrorPage,
|
||||
) -> Result<PageData<OpsErrorView>, WireValidationError> {
|
||||
Ok(PageData {
|
||||
pub(crate) fn ops_page_view(page: domain::OpsErrorPage) -> PageData<OpsErrorView> {
|
||||
PageData {
|
||||
items: page.items.into_iter().map(ops_error_view).collect(),
|
||||
current_page: page.current_page,
|
||||
page_size: page.page_size,
|
||||
total: page.total,
|
||||
next_cursor: page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.map(encode_observability_cursor)
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn trend_point_view(point: domain::TrendPoint) -> TrendPointView {
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
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)]
|
||||
@@ -31,8 +27,8 @@ impl DashboardQuery {
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct UsageQuery {
|
||||
pub current_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>,
|
||||
@@ -52,18 +48,17 @@ pub struct UsageQuery {
|
||||
}
|
||||
|
||||
impl UsageQuery {
|
||||
/// 校验分页字段,不改变仓储层的分页 owner。
|
||||
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
|
||||
/// 校验 Element Plus 风格的页码分页字段。
|
||||
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
if current_page == 0 {
|
||||
return Err(WireValidationError::new("currentPage"));
|
||||
}
|
||||
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_size)
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界;编码和排序语义由应用层负责。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
Ok((current_page, page_size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,21 +100,19 @@ impl DiagnosticsQuery {
|
||||
#[derive(Clone, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct OpsQuery {
|
||||
pub current_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>,
|
||||
@@ -128,18 +121,17 @@ pub struct OpsQuery {
|
||||
}
|
||||
|
||||
impl OpsQuery {
|
||||
/// 校验分页字段。
|
||||
pub fn validate_page_size(&self) -> Result<u16, WireValidationError> {
|
||||
/// 校验 Element Plus 风格的页码分页字段。
|
||||
pub fn validate_pagination(&self) -> Result<(u32, u16), WireValidationError> {
|
||||
let current_page = self.current_page.unwrap_or(1);
|
||||
if current_page == 0 {
|
||||
return Err(WireValidationError::new("currentPage"));
|
||||
}
|
||||
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_size)
|
||||
}
|
||||
|
||||
/// 校验游标的 wire 边界。
|
||||
pub fn validate_cursor(&self) -> Result<(), WireValidationError> {
|
||||
validate_cursor(self.cursor.as_deref())
|
||||
Ok((current_page, page_size))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,13 +240,6 @@ pub(crate) fn require_text(value: &str, field: &'static str) -> Result<(), WireV
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -336,22 +321,19 @@ pub(crate) fn usage_filter(query: &UsageQuery) -> Result<domain::UsageFilter, Wi
|
||||
}
|
||||
|
||||
pub(crate) fn usage_command(query: &UsageQuery) -> Result<domain::UsageQuery, WireValidationError> {
|
||||
let page_size = query.validate_page_size()?;
|
||||
query.validate_cursor()?;
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
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())?,
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
include_total: query.cursor.is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, WireValidationError> {
|
||||
let page_size = query.validate_page_size()?;
|
||||
query.validate_cursor()?;
|
||||
let (current_page, page_size) = query.validate_pagination()?;
|
||||
let page_size_value =
|
||||
DomainPageSize::new(page_size).map_err(|_| WireValidationError::new("pageSize"))?;
|
||||
let status_code = parse_status(
|
||||
@@ -367,57 +349,20 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
|
||||
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())),
|
||||
operation: 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())?,
|
||||
current_page,
|
||||
page_size: page_size_value,
|
||||
include_total: query.cursor.is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -91,7 +91,7 @@ where
|
||||
.usage_records(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = usage_page_view(result).map_err(|_| AdminError::internal())?;
|
||||
let data = usage_page_view(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -207,6 +207,6 @@ where
|
||||
.ops_errors(command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
let data = ops_page_view(result).map_err(|_| AdminError::internal())?;
|
||||
let data = ops_page_view(result);
|
||||
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
|
||||
}
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
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 total: Option<u64>,
|
||||
pub next_cursor: Option<String>,
|
||||
pub current_page: u32,
|
||||
pub page_size: u16,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Token 详情展示。
|
||||
@@ -765,20 +758,40 @@ pub struct OpsErrorView {
|
||||
pub request_id: Option<String>,
|
||||
pub client_api_key_id: Option<String>,
|
||||
pub kind: String,
|
||||
pub operation: String,
|
||||
pub protocol: Option<String>,
|
||||
pub client_transport: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub authentication_kind: Option<String>,
|
||||
pub account_id: Option<String>,
|
||||
pub account_name: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub route: String,
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub service_tier: 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 upstream_send_state: Option<String>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub occurrence_count: u32,
|
||||
pub response_id: Option<String>,
|
||||
pub upstream_request_id: Option<String>,
|
||||
pub latency_ms: Option<u64>,
|
||||
pub client_ip: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub reasoning_preset: Option<String>,
|
||||
pub request_kind: Option<String>,
|
||||
pub subagent_kind: Option<String>,
|
||||
pub compact: Option<bool>,
|
||||
pub message: String,
|
||||
/// 上游错误正文或 WebSocket close/error frame 原文;不做脱敏。
|
||||
pub raw_upstream_error: Option<String>,
|
||||
pub metadata: OpsErrorMetadataView,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub created_at_display: String,
|
||||
@@ -792,4 +805,16 @@ pub struct OpsErrorMetadataView {
|
||||
pub component: String,
|
||||
pub attempt_id: Option<String>,
|
||||
pub account_label: Option<String>,
|
||||
pub continuation_affinity_hash: Option<String>,
|
||||
pub continuation_previous_response_id_hash: Option<String>,
|
||||
pub continuation_unavailable_reason: Option<String>,
|
||||
pub upstream_connection_id: Option<String>,
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub upstream_connection_age_ms: Option<u64>,
|
||||
pub upstream_connection_idle_ms: Option<u64>,
|
||||
pub recovery_request_id: Option<String>,
|
||||
pub recovered_at: Option<DateTime<Utc>>,
|
||||
pub recovery_attempt_count: u32,
|
||||
pub recovery_retry_delay_ms: Option<u64>,
|
||||
pub recovery_total_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! 当前 wire 仍保留 display 字段;统一由本模块提供,避免 accounts/observability
|
||||
//! 各自维护格式化规则。
|
||||
|
||||
use gateway_core::accounting::Decimal;
|
||||
use gateway_core::metering::Decimal;
|
||||
|
||||
/// 千分位格式化。
|
||||
#[must_use]
|
||||
|
||||
@@ -10,14 +10,18 @@ use axum::{
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::client_distribution::{
|
||||
ClientDownloadPackage, CodexDesktopWindowsDownloads,
|
||||
};
|
||||
use gateway_admin::model::settings::{
|
||||
ModelMappings as DomainModelMappings, ReplaceRuntimeSettings, RotationStrategy, RuntimeSettings,
|
||||
};
|
||||
use gateway_core::policy::CodexClientVersion;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminResponse, AdminSessionState,
|
||||
AdminAuth, AdminEnvelope, AdminError, AdminJson, AdminQuery, AdminResponse, AdminSessionState,
|
||||
WireValidationError, wire::map_admin_service_error,
|
||||
};
|
||||
|
||||
@@ -34,6 +38,8 @@ pub struct RuntimeSettingsView {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
@@ -50,6 +56,8 @@ pub struct UpdateRuntimeSettingsRequest {
|
||||
pub max_concurrent_per_account: u64,
|
||||
pub request_interval_ms: u64,
|
||||
pub rotation_strategy: String,
|
||||
pub min_codex_desktop_version: Option<String>,
|
||||
pub min_codex_cli_version: Option<String>,
|
||||
pub usage_retention_days: u64,
|
||||
pub ops_event_retention_days: u64,
|
||||
pub audit_retention_days: u64,
|
||||
@@ -75,6 +83,14 @@ impl UpdateRuntimeSettingsRequest {
|
||||
if RotationStrategy::parse(&self.rotation_strategy).is_none() {
|
||||
return Err(WireValidationError::new("rotationStrategy"));
|
||||
}
|
||||
validate_optional_client_version(
|
||||
self.min_codex_desktop_version.as_deref(),
|
||||
"minCodexDesktopVersion",
|
||||
)?;
|
||||
validate_optional_client_version(
|
||||
self.min_codex_cli_version.as_deref(),
|
||||
"minCodexCliVersion",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -90,6 +106,8 @@ impl UpdateRuntimeSettingsRequest {
|
||||
request_interval_ms: self.request_interval_ms,
|
||||
rotation_strategy: RotationStrategy::parse(&self.rotation_strategy)
|
||||
.ok_or_else(|| WireValidationError::new("rotationStrategy"))?,
|
||||
min_codex_desktop_version: self.min_codex_desktop_version,
|
||||
min_codex_cli_version: self.min_codex_cli_version,
|
||||
usage_retention_days: u32::try_from(self.usage_retention_days)
|
||||
.map_err(|_| WireValidationError::new("settingsUsageRetentionOverflow"))?,
|
||||
ops_event_retention_days: u32::try_from(self.ops_event_retention_days)
|
||||
@@ -109,6 +127,8 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
max_concurrent_per_account: u64::from(settings.max_concurrent_per_account),
|
||||
request_interval_ms: settings.request_interval_ms,
|
||||
rotation_strategy: settings.rotation_strategy.as_str().to_owned(),
|
||||
min_codex_desktop_version: settings.min_codex_desktop_version,
|
||||
min_codex_cli_version: settings.min_codex_cli_version,
|
||||
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),
|
||||
@@ -145,6 +165,58 @@ pub struct DeletedAdminApiKey {
|
||||
pub message: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct ClientDownloadsQuery {
|
||||
refresh: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClientDownloadPackageView {
|
||||
architecture: String,
|
||||
source: String,
|
||||
version: Option<String>,
|
||||
file_name: String,
|
||||
size_bytes: Option<u64>,
|
||||
download_url: String,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<ClientDownloadPackage> for ClientDownloadPackageView {
|
||||
fn from(package: ClientDownloadPackage) -> Self {
|
||||
Self {
|
||||
architecture: package.architecture.as_str().to_owned(),
|
||||
source: package.source.as_str().to_owned(),
|
||||
version: package.version,
|
||||
file_name: package.file_name,
|
||||
size_bytes: package.size_bytes,
|
||||
download_url: package.download_url,
|
||||
expires_at: package.expires_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexDesktopWindowsDownloadsView {
|
||||
resolved_at: DateTime<Utc>,
|
||||
cached: bool,
|
||||
warning: Option<String>,
|
||||
packages: Vec<ClientDownloadPackageView>,
|
||||
}
|
||||
|
||||
impl From<CodexDesktopWindowsDownloads> for CodexDesktopWindowsDownloadsView {
|
||||
fn from(downloads: CodexDesktopWindowsDownloads) -> Self {
|
||||
Self {
|
||||
resolved_at: downloads.resolved_at,
|
||||
cached: downloads.cached,
|
||||
warning: downloads.warning,
|
||||
packages: downloads.packages.into_iter().map(Into::into).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DeletedAdminApiKey {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -161,6 +233,10 @@ where
|
||||
Router::new()
|
||||
.route("/api/admin/settings", get(settings::<S>))
|
||||
.route("/api/admin/settings/update", post(update_settings::<S>))
|
||||
.route(
|
||||
"/api/admin/settings/client-downloads/codex-desktop/windows",
|
||||
get(codex_desktop_windows_downloads::<S>),
|
||||
)
|
||||
.route(
|
||||
"/api/admin/settings/admin-api-key",
|
||||
get(admin_api_key_status::<S>),
|
||||
@@ -175,6 +251,25 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
async fn codex_desktop_windows_downloads<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminQuery(query): AdminQuery<ClientDownloadsQuery>,
|
||||
) -> impl IntoResponse
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let downloads = state
|
||||
.admin_services()
|
||||
.client_distribution()
|
||||
.codex_desktop_windows(query.refresh)
|
||||
.await;
|
||||
AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(CodexDesktopWindowsDownloadsView::from(downloads)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn settings<S>(
|
||||
_auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
@@ -322,6 +417,16 @@ fn valid_model_name(value: &str, max_len: usize) -> bool {
|
||||
&& !value.bytes().any(|byte| byte.is_ascii_control())
|
||||
}
|
||||
|
||||
fn validate_optional_client_version(
|
||||
value: Option<&str>,
|
||||
field: &'static str,
|
||||
) -> Result<(), WireValidationError> {
|
||||
if value.is_some_and(|value| CodexClientVersion::parse(value).is_err()) {
|
||||
return Err(WireValidationError::new(field));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
let message = match error.field() {
|
||||
"settingsRefreshConcurrencyOverflow" => "refreshConcurrency 不合法".to_owned(),
|
||||
@@ -329,6 +434,8 @@ fn map_wire_error(error: WireValidationError) -> AdminError {
|
||||
"settingsUsageRetentionOverflow" => "usageRetentionDays 不合法".to_owned(),
|
||||
"settingsOpsRetentionOverflow" => "opsEventRetentionDays 不合法".to_owned(),
|
||||
"settingsAuditRetentionOverflow" => "auditRetentionDays 不合法".to_owned(),
|
||||
"minCodexDesktopVersion" => "Codex Desktop 最低版本格式不合法".to_owned(),
|
||||
"minCodexCliVersion" => "Codex CLI 最低版本格式不合法".to_owned(),
|
||||
field => format!("{field} 字段不合法"),
|
||||
};
|
||||
AdminError::bad_request(message)
|
||||
|
||||
@@ -5,9 +5,13 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::engine::execution::AuthenticatedClient;
|
||||
use gateway_core::policy::{ClientVersionRejection, CodexClientKind, CodexClientVersion};
|
||||
|
||||
use super::{
|
||||
error::{missing_client_api_key_response, runtime_unavailable_response},
|
||||
error::{
|
||||
client_version_rejection_response, missing_client_api_key_response,
|
||||
runtime_unavailable_response,
|
||||
},
|
||||
service::OpenAiService,
|
||||
};
|
||||
|
||||
@@ -26,6 +30,44 @@ pub enum ClientApiKeyAuthError {
|
||||
RuntimeUnavailable,
|
||||
}
|
||||
|
||||
/// Client API Key 或最低版本准入失败。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClientAccessError {
|
||||
Authentication(ClientApiKeyAuthError),
|
||||
Version(ClientVersionRejection),
|
||||
}
|
||||
|
||||
impl From<ClientApiKeyAuthError> for ClientAccessError {
|
||||
fn from(error: ClientApiKeyAuthError) -> Self {
|
||||
Self::Authentication(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ClientVersionRejection> for ClientAccessError {
|
||||
fn from(error: ClientVersionRejection) -> Self {
|
||||
Self::Version(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从有界请求头中识别出的客户端及其可选合法版本。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct IdentifiedCodexClient {
|
||||
kind: CodexClientKind,
|
||||
version: Option<CodexClientVersion>,
|
||||
}
|
||||
|
||||
impl IdentifiedCodexClient {
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> CodexClientKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn version(&self) -> Option<&CodexClientVersion> {
|
||||
self.version.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientApiKeyAuthError {
|
||||
/// 返回可用于日志和指标的稳定失败原因。
|
||||
#[must_use]
|
||||
@@ -67,19 +109,121 @@ pub fn bearer_client_api_key(headers: &HeaderMap) -> Result<&str, ClientApiKeyAu
|
||||
pub(crate) fn authenticate_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
|
||||
bearer_client_api_key(headers).and_then(|key| service.authenticate(key))
|
||||
) -> Result<AuthenticatedClient, ClientAccessError> {
|
||||
let key = bearer_client_api_key(headers)?;
|
||||
let client = service.authenticate(key)?;
|
||||
if let Some(identified) = identify_codex_client(headers) {
|
||||
client
|
||||
.snapshot()
|
||||
.min_codex_client_versions()
|
||||
.enforce(identified.kind(), identified.version())?;
|
||||
}
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
pub(crate) fn authentication_error_response(error: ClientApiKeyAuthError) -> Response {
|
||||
log_client_api_key_auth_failure(error);
|
||||
if error == ClientApiKeyAuthError::RuntimeUnavailable {
|
||||
runtime_unavailable_response().into_response()
|
||||
} else {
|
||||
missing_client_api_key_response().into_response()
|
||||
pub(crate) fn client_access_error_response(error: ClientAccessError) -> Response {
|
||||
match error {
|
||||
ClientAccessError::Authentication(error) => {
|
||||
log_client_api_key_auth_failure(error);
|
||||
if error == ClientApiKeyAuthError::RuntimeUnavailable {
|
||||
runtime_unavailable_response().into_response()
|
||||
} else {
|
||||
missing_client_api_key_response().into_response()
|
||||
}
|
||||
}
|
||||
ClientAccessError::Version(error) => {
|
||||
tracing::info!(
|
||||
client = error.kind().as_str(),
|
||||
current_version = error.current().map(ToString::to_string),
|
||||
min_version = %error.min(),
|
||||
"Codex client version requirement rejected request"
|
||||
);
|
||||
client_version_rejection_response(&error).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop 优先于其内嵌的 CLI/Core 标记;未知客户端保持兼容并返回 `None`。
|
||||
#[must_use]
|
||||
pub fn identify_codex_client(headers: &HeaderMap) -> Option<IdentifiedCodexClient> {
|
||||
const MAXIMUM_HEADER_LENGTH: usize = 4096;
|
||||
|
||||
let originator = bounded_ascii_header(headers, "originator", MAXIMUM_HEADER_LENGTH);
|
||||
let user_agent = bounded_ascii_header(headers, "user-agent", MAXIMUM_HEADER_LENGTH);
|
||||
let is_desktop = originator.is_some_and(|value| value.eq_ignore_ascii_case("Codex Desktop"))
|
||||
|| user_agent.is_some_and(|value| contains_ascii_case_insensitive(value, "Codex Desktop"));
|
||||
if is_desktop {
|
||||
let explicit_version = bounded_ascii_header(headers, "version", MAXIMUM_HEADER_LENGTH);
|
||||
let version = match explicit_version {
|
||||
Some(value) => CodexClientVersion::parse(value).ok(),
|
||||
None => user_agent
|
||||
.and_then(desktop_version_from_user_agent)
|
||||
.and_then(|value| CodexClientVersion::parse(value).ok()),
|
||||
};
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Desktop,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
||||
let user_agent = user_agent?;
|
||||
for product in user_agent.split(|character: char| {
|
||||
character.is_ascii_whitespace() || matches!(character, '(' | ')' | ';' | ',' | '[' | ']')
|
||||
}) {
|
||||
for prefix in ["codex_cli_rs", "codex-cli"] {
|
||||
if product.eq_ignore_ascii_case(prefix) {
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Cli,
|
||||
version: None,
|
||||
});
|
||||
}
|
||||
let Some((name, version)) = product.split_once('/') else {
|
||||
continue;
|
||||
};
|
||||
if name.eq_ignore_ascii_case(prefix) {
|
||||
return Some(IdentifiedCodexClient {
|
||||
kind: CodexClientKind::Cli,
|
||||
version: CodexClientVersion::parse(version).ok(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bounded_ascii_header<'a>(
|
||||
headers: &'a HeaderMap,
|
||||
name: &'static str,
|
||||
maximum_length: usize,
|
||||
) -> Option<&'a str> {
|
||||
let value = headers.get(name)?.to_str().ok()?;
|
||||
if !value.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
Some(&value[..value.len().min(maximum_length)])
|
||||
}
|
||||
|
||||
fn desktop_version_from_user_agent(user_agent: &str) -> Option<&str> {
|
||||
let marker = "Codex Desktop;";
|
||||
let start = find_ascii_case_insensitive(user_agent, marker)? + marker.len();
|
||||
let version = user_agent[start..]
|
||||
.trim_start()
|
||||
.split([')', ' ', ';', ','])
|
||||
.next()?;
|
||||
(!version.is_empty()).then_some(version)
|
||||
}
|
||||
|
||||
fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
|
||||
find_ascii_case_insensitive(haystack, needle).is_some()
|
||||
}
|
||||
|
||||
fn find_ascii_case_insensitive(haystack: &str, needle: &str) -> Option<usize> {
|
||||
haystack
|
||||
.as_bytes()
|
||||
.windows(needle.len())
|
||||
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||
}
|
||||
|
||||
fn log_client_api_key_auth_failure(error: ClientApiKeyAuthError) {
|
||||
match error {
|
||||
ClientApiKeyAuthError::RuntimeUnavailable => {
|
||||
|
||||
@@ -9,6 +9,7 @@ use axum::{
|
||||
use gateway_core::{
|
||||
engine::EngineError,
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
policy::{ClientVersionRejection, CodexClientKind},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -58,6 +59,47 @@ pub fn runtime_unavailable_response() -> (StatusCode, Json<Value>) {
|
||||
)
|
||||
}
|
||||
|
||||
/// 已识别 Codex 客户端不满足最低版本要求。
|
||||
pub fn client_version_rejection_response(
|
||||
rejection: &ClientVersionRejection,
|
||||
) -> (StatusCode, Json<Value>) {
|
||||
let client_label = match rejection.kind() {
|
||||
CodexClientKind::Desktop => "Codex Desktop",
|
||||
CodexClientKind::Cli => "Codex CLI",
|
||||
};
|
||||
let (message, code, current_version) = match rejection.current() {
|
||||
Some(current) => (
|
||||
format!(
|
||||
"{client_label} {current} is below the minimum required version {}. Upgrade {client_label} and retry.",
|
||||
rejection.min()
|
||||
),
|
||||
"client_version_too_old",
|
||||
Some(current.to_string()),
|
||||
),
|
||||
None => (
|
||||
format!(
|
||||
"A valid {client_label} version is required. Upgrade to version {} or newer and retry.",
|
||||
rejection.min()
|
||||
),
|
||||
"client_version_unavailable",
|
||||
None,
|
||||
),
|
||||
};
|
||||
(
|
||||
StatusCode::UPGRADE_REQUIRED,
|
||||
Json(json!({
|
||||
"error": {
|
||||
"message": message,
|
||||
"type": "invalid_request_error",
|
||||
"code": code,
|
||||
"client": rejection.kind().as_str(),
|
||||
"current_version": current_version,
|
||||
"min_version": rejection.min().to_string()
|
||||
}
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
/// 对外模型不存在。
|
||||
pub fn model_not_found_response() -> (StatusCode, Json<Value>) {
|
||||
openai_error_response(
|
||||
|
||||
@@ -3,22 +3,21 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
body::Bytes,
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
http::HeaderMap,
|
||||
response::Response,
|
||||
};
|
||||
use gateway_core::engine::execution::StartedExecution;
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::operation::{ImageRequest, ImageRequestKind, Operation, RawJsonPayload};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
error::{engine_error_response, gateway_error_response, protocol_error_response},
|
||||
responses::{PendingExecution, ProtocolError, ProtocolErrorBody, request_client_context},
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::gateway_error_response,
|
||||
provider_endpoint::collect_raw_json_response,
|
||||
responses::request_client_context,
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
@@ -71,7 +70,7 @@ async fn handle_image_request(
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
@@ -82,13 +81,13 @@ async fn handle_image_request(
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let started = match service
|
||||
.start_image(client, operation, client_ip, user_agent, endpoint)
|
||||
.start_provider_endpoint(client, operation, client_ip, user_agent, endpoint)
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
collect_image_response(started).await
|
||||
collect_raw_json_response(started).await
|
||||
}
|
||||
|
||||
fn image_operation(
|
||||
@@ -118,116 +117,3 @@ fn image_operation(
|
||||
kind, payload,
|
||||
)))
|
||||
}
|
||||
|
||||
async fn collect_image_response(started: StartedExecution) -> Response {
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
let body = match raw_json_body(events) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
let response = invalid_upstream_response();
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let status = session
|
||||
.response_status_code()
|
||||
.and_then(|status| StatusCode::from_u16(status).ok())
|
||||
.filter(StatusCode::is_success)
|
||||
.unwrap_or(StatusCode::OK);
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = json_body_response(body, status, &response_headers);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
execution.cancel_and_finalize().await;
|
||||
return invalid_upstream_response();
|
||||
}
|
||||
execution.disarm();
|
||||
response
|
||||
}
|
||||
|
||||
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
|
||||
let mut body = None;
|
||||
for event in events {
|
||||
let (_, wire) = event.into_parts();
|
||||
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
|
||||
continue;
|
||||
};
|
||||
let Some(raw) = wire.into_raw_json_body() else {
|
||||
continue;
|
||||
};
|
||||
if body.replace(raw).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn json_body_response(
|
||||
body: Bytes,
|
||||
status: StatusCode,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn apply_response_headers(
|
||||
mut response: Response,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let connection_options =
|
||||
crate::openai::responses::response_connection_options(response_headers);
|
||||
for header in response_headers {
|
||||
if !crate::openai::responses::response_header_is_forwardable(
|
||||
header.name(),
|
||||
&connection_options,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn invalid_upstream_response() -> Response {
|
||||
protocol_error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "server_error",
|
||||
code: "invalid_upstream_response",
|
||||
message: "The gateway could not forward the upstream image response.".to_owned(),
|
||||
param: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ pub mod auth;
|
||||
pub mod error;
|
||||
pub mod images;
|
||||
pub mod models;
|
||||
mod provider_endpoint;
|
||||
pub mod responses;
|
||||
pub mod router;
|
||||
pub mod search;
|
||||
pub(crate) mod service;
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
|
||||
use crate::ApiState;
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::model_not_found_response,
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ pub(crate) async fn models(
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
if query
|
||||
@@ -81,7 +81,7 @@ pub(crate) async fn model_detail(
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
return model_not_found_response().into_response();
|
||||
@@ -97,50 +97,6 @@ pub(crate) async fn model_detail(
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/catalog`。
|
||||
///
|
||||
/// 新架构的目录由 core 按客户端 Provider 范围裁剪;这里不读取或重建任一
|
||||
/// Provider 的 wire catalog,只恢复 Codex 客户端依赖的稳定展示合同。
|
||||
pub(crate) async fn model_catalog(State(state): State<ApiState>, headers: HeaderMap) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let models = service
|
||||
.public_models(&client)
|
||||
.into_iter()
|
||||
.map(|model| catalog_model_json(model.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
(StatusCode::OK, Json(models)).into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/models/{model_id}/info`。
|
||||
pub(crate) async fn model_info(
|
||||
State(state): State<ApiState>,
|
||||
headers: HeaderMap,
|
||||
Path(model_id): Path<String>,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
};
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
return model_not_found_response().into_response();
|
||||
};
|
||||
if !service.contains_public_model(&client, &public_model) {
|
||||
return model_not_found_response().into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(catalog_model_json(public_model.as_str())),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn openai_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
@@ -150,22 +106,6 @@ fn openai_model_json(id: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn catalog_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
"displayName": id,
|
||||
"description": "",
|
||||
"isDefault": false,
|
||||
"supportedReasoningEfforts": [],
|
||||
"defaultReasoningEffort": "",
|
||||
"inputModalities": ["text"],
|
||||
"outputModalities": ["text"],
|
||||
"supportsPersonality": false,
|
||||
"upgrade": Value::Null,
|
||||
"source": "gateway",
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_model_json(profile: &PublicModelProfile, index: usize) -> Value {
|
||||
let id = profile.model().as_str();
|
||||
let presentation = profile.presentation();
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! OpenAI Provider 原生非流式 JSON 端点的公共交付边界。
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use gateway_core::engine::execution::StartedExecution;
|
||||
use gateway_core::event::{ProviderEvent, ProviderResponseHeader};
|
||||
|
||||
use super::{
|
||||
error::{engine_error_response, protocol_error_response},
|
||||
responses::{PendingExecution, ProtocolError, ProtocolErrorBody},
|
||||
};
|
||||
|
||||
/// 收集一个 Provider 原生 JSON 响应,并在 Core commit 后按原始 bytes 交付。
|
||||
pub(super) async fn collect_raw_json_response(started: StartedExecution) -> Response {
|
||||
let mut execution = PendingExecution::new(started.session);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
let events = match session.collect_uncommitted().await {
|
||||
Ok(events) => events,
|
||||
Err(error) => {
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
};
|
||||
let body = match raw_json_body(events) {
|
||||
Some(body) => body,
|
||||
None => {
|
||||
let response = invalid_upstream_response();
|
||||
let response = execution.record_response_status(response).await;
|
||||
execution.cancel_and_finalize().await;
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let status = session
|
||||
.response_status_code()
|
||||
.and_then(|status| StatusCode::from_u16(status).ok())
|
||||
.filter(StatusCode::is_success)
|
||||
.unwrap_or(StatusCode::OK);
|
||||
let response_headers = session.response_headers().to_vec();
|
||||
let response = json_body_response(body, status, &response_headers);
|
||||
let Some(session) = execution.session_mut() else {
|
||||
return invalid_upstream_response();
|
||||
};
|
||||
if let Err(error) = session.commit_downstream(Some(status.as_u16())).await {
|
||||
execution.cancel_and_finalize().await;
|
||||
let response = engine_error_response(&error);
|
||||
return execution.record_response_status(response).await;
|
||||
}
|
||||
if !session.is_finalized() {
|
||||
execution.cancel_and_finalize().await;
|
||||
return invalid_upstream_response();
|
||||
}
|
||||
execution.disarm();
|
||||
response
|
||||
}
|
||||
|
||||
fn raw_json_body(events: Vec<ProviderEvent>) -> Option<Bytes> {
|
||||
let mut body = None;
|
||||
for event in events {
|
||||
let (_, wire) = event.into_parts();
|
||||
let Some(wire) = wire.filter(|wire| wire.protocol() == "openai") else {
|
||||
continue;
|
||||
};
|
||||
let Some(raw) = wire.into_raw_json_body() else {
|
||||
continue;
|
||||
};
|
||||
if body.replace(raw).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn json_body_response(
|
||||
body: Bytes,
|
||||
status: StatusCode,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
apply_response_headers(response, response_headers)
|
||||
}
|
||||
|
||||
fn apply_response_headers(
|
||||
mut response: Response,
|
||||
response_headers: &[ProviderResponseHeader],
|
||||
) -> Response {
|
||||
let connection_options = super::responses::response_connection_options(response_headers);
|
||||
for header in response_headers {
|
||||
if !super::responses::response_header_is_forwardable(header.name(), &connection_options) {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = HeaderName::from_bytes(header.name().as_bytes()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(value) = HeaderValue::from_bytes(header.value()) else {
|
||||
continue;
|
||||
};
|
||||
response.headers_mut().append(name, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
fn invalid_upstream_response() -> Response {
|
||||
protocol_error_response(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
ProtocolErrorBody {
|
||||
error: ProtocolError {
|
||||
kind: "server_error",
|
||||
code: "invalid_upstream_response",
|
||||
message: "The gateway could not forward the upstream JSON response.".to_owned(),
|
||||
param: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use gateway_protocol::openai::sse::{DONE_SSE_FRAME, response_failed_sse_event_wi
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::{
|
||||
engine_error_response, gateway_error_contract, gateway_error_from_engine,
|
||||
gateway_error_response, protocol_error_response, runtime_unavailable_response,
|
||||
@@ -32,7 +32,7 @@ use crate::openai::{
|
||||
|
||||
use super::{
|
||||
OpenAiResponsesEncoder, ProtocolErrorBody, ResponseEncodeError,
|
||||
request::{decode_request_with_headers, decode_review_request_with_headers},
|
||||
request::decode_request_with_headers,
|
||||
};
|
||||
|
||||
/// `POST /v1/responses`。
|
||||
@@ -41,37 +41,13 @@ pub(crate) async fn responses(
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_responses(state, connect_info, headers, body, false).await
|
||||
}
|
||||
|
||||
/// `POST /v1/responses/review`。
|
||||
pub(crate) async fn review_responses(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
handle_responses(state, connect_info, headers, body, true).await
|
||||
}
|
||||
|
||||
async fn handle_responses(
|
||||
state: ApiState,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
review: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
let decoded = match if review {
|
||||
decode_review_request_with_headers(&body, &headers)
|
||||
} else {
|
||||
decode_request_with_headers(&body, &headers)
|
||||
} {
|
||||
let decoded = match decode_request_with_headers(&body, &headers) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => {
|
||||
return protocol_error_response(StatusCode::BAD_REQUEST, error.protocol_body());
|
||||
@@ -100,11 +76,7 @@ async fn handle_responses(
|
||||
} else {
|
||||
ClientTransport::HttpJson
|
||||
},
|
||||
if review {
|
||||
"/v1/responses/review"
|
||||
} else {
|
||||
"/v1/responses"
|
||||
},
|
||||
"/v1/responses",
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -4,12 +4,12 @@ mod error;
|
||||
mod http;
|
||||
mod request;
|
||||
mod response;
|
||||
mod websocket;
|
||||
pub mod websocket;
|
||||
|
||||
pub use error::{ProtocolError, ProtocolErrorBody, RequestDecodeError, ResponseEncodeError};
|
||||
pub(crate) use http::responses;
|
||||
pub(super) use http::{PendingExecution, request_client_context};
|
||||
pub use http::{collect_execution_response, stream_execution_response};
|
||||
pub(crate) use http::{responses, review_responses};
|
||||
pub use request::{
|
||||
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
|
||||
decode_request_with_headers,
|
||||
|
||||
@@ -13,10 +13,19 @@ use serde_json::{Map, Value};
|
||||
use super::error::RequestDecodeError;
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const REVIEW_SUBAGENT: &str = "review";
|
||||
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
|
||||
const CODEX_TURN_METADATA_KEY: &str = "x-codex-turn-metadata";
|
||||
const PASSTHROUGH_HEADERS_CONTEXT_KEY: &str = "opaque_request_headers";
|
||||
|
||||
/// Responses 请求进入共享解码内核时的下游传输来源。
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RequestDecodeSource {
|
||||
/// 单次 HTTP 请求,请求头与正文属于同一请求。
|
||||
Http,
|
||||
/// 复用连接上的 WebSocket `response.create` 帧。
|
||||
WebSocketFrame,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OpenAiRequestHeaders {
|
||||
turn_state: Option<String>,
|
||||
@@ -65,16 +74,24 @@ impl OpenAiRequestHeaders {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subagent(&self, body: &mut Map<String, Value>, forced_subagent: Option<&str>) {
|
||||
if let Some(subagent) = forced_subagent.or(self.subagent.as_deref()) {
|
||||
fn apply_subagent(&self, body: &mut Map<String, Value>) {
|
||||
if let Some(subagent) = self.subagent.as_deref() {
|
||||
inject_subagent_metadata(body, subagent);
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_context(&self, use_websocket: Option<bool>) -> Map<String, Value> {
|
||||
fn protocol_context(
|
||||
&self,
|
||||
use_websocket: Option<bool>,
|
||||
frame_turn_metadata: Option<&String>,
|
||||
) -> Map<String, Value> {
|
||||
let mut context = Map::new();
|
||||
insert_protocol_context(&mut context, "turn_state", self.turn_state.as_ref());
|
||||
insert_protocol_context(&mut context, "turn_metadata", self.turn_metadata.as_ref());
|
||||
insert_protocol_context(
|
||||
&mut context,
|
||||
"turn_metadata",
|
||||
frame_turn_metadata.or(self.turn_metadata.as_ref()),
|
||||
);
|
||||
insert_protocol_context(&mut context, "beta_features", self.beta_features.as_ref());
|
||||
insert_protocol_context(&mut context, "version", self.version.as_ref());
|
||||
insert_protocol_context(
|
||||
@@ -283,20 +300,11 @@ pub fn decode_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, false, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
/// 解码 `POST /v1/responses/review` 请求并冻结 review subagent 语义。
|
||||
pub(super) fn decode_review_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
decode_request_inner(body, true, &OpenAiRequestHeaders::from_headers(headers))
|
||||
decode_request_inner(body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
|
||||
pub(super) fn decode_request_inner(
|
||||
body: &[u8],
|
||||
review: bool,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
let value =
|
||||
@@ -304,14 +312,14 @@ pub(super) fn decode_request_inner(
|
||||
let Value::Object(object) = value else {
|
||||
return Err(RequestDecodeError::ExpectedObject);
|
||||
};
|
||||
decode_request_object(object, review, request_headers)
|
||||
decode_request_object(object, request_headers, RequestDecodeSource::Http)
|
||||
}
|
||||
|
||||
/// 解码已解析的顶层 object;WebSocket 帧解码复用此内核,避免序列化往返。
|
||||
/// 解码已解析的顶层 object;按下游传输来源恢复连接级协议上下文。
|
||||
pub(super) fn decode_request_object(
|
||||
mut object: Map<String, Value>,
|
||||
review: bool,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
source: RequestDecodeSource,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议。
|
||||
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
|
||||
@@ -340,8 +348,12 @@ pub(super) fn decode_request_object(
|
||||
.map(|response_id| ContinuationIntent::PreviousResponseId(response_id.to_owned()))
|
||||
.unwrap_or(ContinuationIntent::None);
|
||||
|
||||
request_headers.apply_subagent(&mut object, review.then_some(REVIEW_SUBAGENT));
|
||||
let protocol_context = request_headers.protocol_context(use_websocket);
|
||||
request_headers.apply_subagent(&mut object);
|
||||
let frame_turn_metadata = match source {
|
||||
RequestDecodeSource::Http => None,
|
||||
RequestDecodeSource::WebSocketFrame => frame_turn_metadata(&object),
|
||||
};
|
||||
let protocol_context = request_headers.protocol_context(use_websocket, frame_turn_metadata);
|
||||
|
||||
let payload = ProtocolPayload::json_object(OPENAI_PROTOCOL, object)
|
||||
.map_err(|_| RequestDecodeError::CanonicalContract {
|
||||
@@ -362,6 +374,16 @@ pub(super) fn decode_request_object(
|
||||
})
|
||||
}
|
||||
|
||||
fn frame_turn_metadata(body: &Map<String, Value>) -> Option<&String> {
|
||||
body.get("client_metadata")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|metadata| metadata.get(CODEX_TURN_METADATA_KEY))
|
||||
.and_then(|value| match value {
|
||||
Value::String(value) if !value.trim().is_empty() => Some(value),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn inject_subagent_metadata(body: &mut Map<String, Value>, subagent: &str) {
|
||||
let metadata = body
|
||||
.entry("client_metadata".to_owned())
|
||||
@@ -455,10 +477,11 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
|
||||
| "x-openai-project"
|
||||
// installation ID 始终由当前 lease 重建。
|
||||
| "x-codex-installation-id"
|
||||
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent
|
||||
// 不能透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
|
||||
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
|
||||
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
|
||||
| "originator"
|
||||
| "user-agent"
|
||||
| "version"
|
||||
// 设备 attestation/integrity 头只能由官方客户端或网关自身生成,
|
||||
// 客户端注入的 x-oai-attestation / X-OAI-IS 不得透传上游。
|
||||
| "x-oai-attestation"
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::{
|
||||
|
||||
use axum::extract::ws::{CloseFrame, Message, WebSocket, close_code};
|
||||
use futures::{Sink, SinkExt, Stream, StreamExt};
|
||||
use gateway_core::engine::CancellationToken;
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
use thiserror::Error;
|
||||
use tokio::{
|
||||
sync::{mpsc, oneshot},
|
||||
@@ -24,21 +24,23 @@ const INBOUND_EVENT_BUFFER: usize = 32;
|
||||
const DOWNSTREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
const CONNECTION_MAX_AGE: Duration = Duration::from_secs(60 * 60);
|
||||
|
||||
/// WebSocket pump 的写入与生命周期预算。
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ConnectionConfig {
|
||||
pub(super) write_timeout: Duration,
|
||||
pub(super) max_age: Duration,
|
||||
pub struct ConnectionConfig {
|
||||
write_timeout: Duration,
|
||||
max_age: Duration,
|
||||
}
|
||||
|
||||
impl ConnectionConfig {
|
||||
pub(super) const PRODUCTION: Self = Self {
|
||||
/// 生产环境固定预算。
|
||||
pub const PRODUCTION: Self = Self {
|
||||
write_timeout: DOWNSTREAM_WRITE_TIMEOUT,
|
||||
max_age: CONNECTION_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
/// 业务层可观察的客户端输入;Ping/Pong 始终由 pump 消费。
|
||||
pub(super) enum ConnectionEvent {
|
||||
pub enum ConnectionEvent {
|
||||
Text(String),
|
||||
Binary,
|
||||
Expired,
|
||||
@@ -47,7 +49,7 @@ pub(super) enum ConnectionEvent {
|
||||
|
||||
/// 下游写入阶段;名称刻意使用 write,而不是暗示客户端已消费的 delivery。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum FramePhase {
|
||||
pub enum FramePhase {
|
||||
Metadata,
|
||||
First,
|
||||
Data,
|
||||
@@ -85,21 +87,26 @@ impl FramePhase {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次下游写入的请求归属与协议阶段。
|
||||
#[derive(Clone)]
|
||||
pub(super) struct WriteContext {
|
||||
pub struct WriteContext {
|
||||
request_id: Option<Arc<str>>,
|
||||
phase: FramePhase,
|
||||
}
|
||||
|
||||
impl WriteContext {
|
||||
pub(super) fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
|
||||
/// 创建归属于某个请求的写入上下文。
|
||||
#[must_use]
|
||||
pub fn request(request_id: &Arc<str>, phase: FramePhase) -> Self {
|
||||
Self {
|
||||
request_id: Some(Arc::clone(request_id)),
|
||||
phase,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) const fn connection(phase: FramePhase) -> Self {
|
||||
/// 创建连接级写入上下文。
|
||||
#[must_use]
|
||||
pub const fn connection(phase: FramePhase) -> Self {
|
||||
Self {
|
||||
request_id: None,
|
||||
phase,
|
||||
@@ -107,8 +114,9 @@ impl WriteContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// WebSocket pump 停止的稳定原因。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum PumpExitReason {
|
||||
pub enum PumpExitReason {
|
||||
ClientClose,
|
||||
PeerEof,
|
||||
ReadError,
|
||||
@@ -140,8 +148,9 @@ impl PumpExitReason {
|
||||
}
|
||||
}
|
||||
|
||||
/// 下游 WebSocket 写入失败。
|
||||
#[derive(Debug, Error)]
|
||||
pub(super) enum ConnectionWriteError {
|
||||
pub enum ConnectionWriteError {
|
||||
#[error("downstream WebSocket pump is closed")]
|
||||
Closed,
|
||||
#[error("downstream WebSocket write timed out after {timeout:?}")]
|
||||
@@ -179,7 +188,8 @@ impl ConnectionStats {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ResponsesWebSocketConnection {
|
||||
/// 协调层持有的单 owner WebSocket 连接句柄。
|
||||
pub struct ResponsesWebSocketConnection {
|
||||
connection_id: Arc<str>,
|
||||
opened_at: Instant,
|
||||
expired: Arc<AtomicBool>,
|
||||
@@ -209,7 +219,9 @@ impl ResponsesWebSocketConnection {
|
||||
&self.connection_id
|
||||
}
|
||||
|
||||
pub(super) fn is_expired(&self) -> bool {
|
||||
/// 返回连接是否已经达到生命周期上限。
|
||||
#[must_use]
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expired.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
@@ -217,7 +229,8 @@ impl ResponsesWebSocketConnection {
|
||||
self.opened_at.elapsed()
|
||||
}
|
||||
|
||||
pub(super) async fn next_event(&mut self) -> Option<ConnectionEvent> {
|
||||
/// 等待下一个需要业务层处理的客户端事件。
|
||||
pub async fn next_event(&mut self) -> Option<ConnectionEvent> {
|
||||
let event = self.incoming.recv().await;
|
||||
if let Some(ConnectionEvent::Exited(reason)) = event.as_ref() {
|
||||
self.exit_reason.get_or_insert(*reason);
|
||||
@@ -227,7 +240,12 @@ impl ResponsesWebSocketConnection {
|
||||
event
|
||||
}
|
||||
|
||||
pub(super) async fn send_text(
|
||||
/// 串行写入文本帧,并等待 pump 确认 transport 写入结果。
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// pump 已关闭、写入超时或 transport 失败时返回稳定错误。
|
||||
pub async fn send_text(
|
||||
&mut self,
|
||||
payload: String,
|
||||
context: WriteContext,
|
||||
@@ -391,7 +409,8 @@ impl Drop for ResponsesWebSocketConnection {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_connection<S, E>(
|
||||
/// 为一个 socket 启动单 owner pump,并返回协调层句柄。
|
||||
pub fn spawn_connection<S, E>(
|
||||
socket: S,
|
||||
connection_id: Arc<str>,
|
||||
cancellation: CancellationToken,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! OpenAI Responses WebSocket 的升级、连接与串行 session 编排。
|
||||
|
||||
mod connection;
|
||||
pub mod connection;
|
||||
mod forward;
|
||||
mod protocol;
|
||||
|
||||
@@ -26,7 +26,7 @@ use gateway_core::{
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, authentication_error_response},
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::runtime_unavailable_response,
|
||||
service::OpenAiService,
|
||||
},
|
||||
@@ -54,7 +54,7 @@ pub(crate) async fn responses_websocket(
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return authentication_error_response(error),
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
|
||||
@@ -6,7 +6,8 @@ use serde_json::{Map, Value, json};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::super::{
|
||||
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError, request::OpenAiRequestHeaders,
|
||||
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
|
||||
request::{OpenAiRequestHeaders, RequestDecodeSource},
|
||||
};
|
||||
|
||||
/// 使用连接级请求头和 Provider 上下文解码官方 `response.create` 文本帧。
|
||||
@@ -40,8 +41,12 @@ fn decode_response_create_inner(
|
||||
if matches!(body.get("stream"), Some(value) if value.as_bool() != Some(true)) {
|
||||
return Err(ResponseCreateFrameError::StreamingRequired);
|
||||
}
|
||||
super::super::request::decode_request_object(body, false, request_headers)
|
||||
.map_err(ResponseCreateFrameError::Request)
|
||||
super::super::request::decode_request_object(
|
||||
body,
|
||||
request_headers,
|
||||
RequestDecodeSource::WebSocketFrame,
|
||||
)
|
||||
.map_err(ResponseCreateFrameError::Request)
|
||||
}
|
||||
|
||||
/// `response.create` 帧的稳定安全错误。
|
||||
|
||||
@@ -8,8 +8,9 @@ use axum::{
|
||||
|
||||
use super::{
|
||||
images::{image_edits, image_generations},
|
||||
models::{model_catalog, model_detail, model_info, models},
|
||||
responses::{responses, responses_websocket, review_responses},
|
||||
models::{model_detail, models},
|
||||
responses::{responses, responses_websocket},
|
||||
search::standalone_search,
|
||||
};
|
||||
|
||||
use crate::ApiState;
|
||||
@@ -19,11 +20,9 @@ pub(crate) fn router() -> Router<ApiState> {
|
||||
Router::new()
|
||||
.route("/v1/images/generations", post(image_generations))
|
||||
.route("/v1/images/edits", post(image_edits))
|
||||
.route("/v1/alpha/search", post(standalone_search))
|
||||
.route("/v1/responses", get(responses_websocket).post(responses))
|
||||
.route("/v1/responses/review", post(review_responses))
|
||||
.route("/v1/models", get(models))
|
||||
.route("/v1/models/catalog", get(model_catalog))
|
||||
.route("/v1/models/{model_id}/info", get(model_info))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束。
|
||||
.route("/v1/models/{model_id}", get(model_detail))
|
||||
// OpenAI 数据面正文属于客户端/上游协议;代理不能用私有大小上限提前拒绝
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Codex standalone search 非流式 HTTP adapter。
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::HeaderMap,
|
||||
response::Response,
|
||||
};
|
||||
use gateway_core::error::{GatewayError, GatewayErrorKind};
|
||||
use gateway_core::operation::{Operation, RawJsonPayload, StandaloneSearchRequest};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
error::gateway_error_response,
|
||||
provider_endpoint::collect_raw_json_response,
|
||||
responses::request_client_context,
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const TURN_METADATA_CONTEXT_KEY: &str = "turn_metadata";
|
||||
|
||||
/// `POST /v1/alpha/search`。
|
||||
pub(crate) async fn standalone_search(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers) {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
let (client_ip, user_agent) = request_client_context(
|
||||
&headers,
|
||||
connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
);
|
||||
let operation = match search_operation(body, &headers) {
|
||||
Ok(operation) => operation,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let started = match service
|
||||
.start_provider_endpoint(client, operation, client_ip, user_agent, "/v1/alpha/search")
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
collect_raw_json_response(started).await
|
||||
}
|
||||
|
||||
fn search_operation(body: Bytes, headers: &HeaderMap) -> Result<Operation, GatewayError> {
|
||||
let mut context = Map::new();
|
||||
if let Some(turn_metadata) = headers
|
||||
.get("x-codex-turn-metadata")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
context.insert(
|
||||
TURN_METADATA_CONTEXT_KEY.to_owned(),
|
||||
Value::String(turn_metadata.to_owned()),
|
||||
);
|
||||
}
|
||||
let payload = RawJsonPayload::new(OPENAI_PROTOCOL, body)
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"OpenAI protocol identifier is invalid",
|
||||
)
|
||||
})?
|
||||
.with_context(context);
|
||||
Ok(Operation::Search(StandaloneSearchRequest::from_raw_json(
|
||||
payload,
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Codex standalone search HTTP adapter。
|
||||
|
||||
mod http;
|
||||
|
||||
pub(crate) use http::standalone_search;
|
||||
@@ -106,7 +106,7 @@ impl OpenAiService {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn start_image(
|
||||
pub(crate) async fn start_provider_endpoint(
|
||||
&self,
|
||||
client: AuthenticatedClient,
|
||||
operation: gateway_core::operation::Operation,
|
||||
|
||||
+71
-5
@@ -76,7 +76,7 @@ mod profile_statistics {
|
||||
ProviderProfileActivityInsights, ProviderProfileDailyUsage, ProviderProfileInvocation,
|
||||
ProviderProfileStatistics, ProviderProfileStatisticsSummary,
|
||||
};
|
||||
use gateway_api::admin::accounts::AccountProfileStatisticsData;
|
||||
use gateway_api::admin::accounts::{AccountProfileAvatarQuery, AccountProfileStatisticsData};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
@@ -134,6 +134,74 @@ mod profile_statistics {
|
||||
assert!(value.get("models").is_none());
|
||||
assert!(value.get("estimatedCost").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_avatar_query_accepts_only_bounded_cache_versions() {
|
||||
let valid: AccountProfileAvatarQuery = serde_json::from_value(json!({
|
||||
"accountId": "acct_openai",
|
||||
"version": "k9m2z1"
|
||||
}))
|
||||
.expect("decode avatar query");
|
||||
valid.validate().expect("validate avatar query");
|
||||
|
||||
for version in ["", "contains-dash", "x23456789012345678901234567890123"] {
|
||||
let query: AccountProfileAvatarQuery = serde_json::from_value(json!({
|
||||
"accountId": "acct_openai",
|
||||
"version": version
|
||||
}))
|
||||
.expect("decode invalid avatar query");
|
||||
assert_eq!(
|
||||
query.validate().expect_err("reject version").field(),
|
||||
"version"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<AccountProfileAvatarQuery>(json!({
|
||||
"accountId": "acct_openai",
|
||||
"sourceUrl": "https://example.test/avatar"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod profile_avatar {
|
||||
use axum::body::to_bytes;
|
||||
use bytes::Bytes;
|
||||
use futures::stream;
|
||||
use gateway_admin::model::provider_credentials::ProviderProfileAvatar;
|
||||
use gateway_api::admin::accounts::profile_avatar_response;
|
||||
|
||||
#[tokio::test]
|
||||
async fn profile_avatar_response_preserves_stream_metadata_and_private_cache() {
|
||||
let response = profile_avatar_response(ProviderProfileAvatar {
|
||||
content_type: Some("image/svg+xml".to_owned()),
|
||||
content_length: Some(6),
|
||||
etag: Some("\"v1\"".to_owned()),
|
||||
body: Box::pin(stream::iter([Ok(Bytes::from_static(b"avatar"))])),
|
||||
});
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(response.headers()["content-type"], "image/svg+xml");
|
||||
assert_eq!(response.headers()["content-length"], "6");
|
||||
assert_eq!(response.headers()["etag"], "\"v1\"");
|
||||
assert_eq!(response.headers()["cache-control"], "private, max-age=3600");
|
||||
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
|
||||
assert_eq!(
|
||||
response.headers()["cross-origin-resource-policy"],
|
||||
"same-origin"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers()["content-security-policy"],
|
||||
"sandbox; default-src 'none'"
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), 16)
|
||||
.await
|
||||
.expect("avatar response body"),
|
||||
Bytes::from_static(b"avatar")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod batch_update {
|
||||
@@ -336,10 +404,8 @@ mod actions {
|
||||
RotateAccountRequest, StartAccountAuthorizationRequest,
|
||||
};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
UpstreamSendState, credential::ProviderAccountId, probe::AccountProbeErrorSource,
|
||||
},
|
||||
error::GatewayErrorKind,
|
||||
account::ProviderAccountId, engine::probe::AccountProbeErrorSource,
|
||||
error::GatewayErrorKind, upstream::UpstreamSendState,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -25,6 +25,10 @@ use gateway_admin::{
|
||||
AccountsUpdateResult, BatchUpdateAccounts, DeleteAccounts, UpdateAccount,
|
||||
},
|
||||
auth::{AdminAuditEvent, AdminSession},
|
||||
client_distribution::{
|
||||
ClientArchitecture, ClientDownloadPackage, ClientDownloadSource,
|
||||
CodexDesktopWindowsDownloads,
|
||||
},
|
||||
client_keys::{
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, SetClientKeyEnabled, UpdateClientKey,
|
||||
@@ -50,6 +54,7 @@ use gateway_admin::{
|
||||
system::{SystemOperationAccepted, SystemUpdateDetail, SystemUpdateStatus, SystemVersion},
|
||||
},
|
||||
ports::{
|
||||
client_distribution::ClientDistributionResolver,
|
||||
provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind},
|
||||
store::{
|
||||
AccountGroupStore, AccountRuntimeStore, AccountStore, AdminAccountStorePorts,
|
||||
@@ -64,14 +69,11 @@ use gateway_admin::{
|
||||
};
|
||||
use gateway_api::admin::AdminSessionState;
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
credential::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
|
||||
probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
},
|
||||
account::{AccountStatusFacts, CredentialState, ProviderAccountId, QuotaState},
|
||||
engine::probe::{AccountProbe, AccountProbeError, AccountProbeRequest, AccountProbeResult},
|
||||
policy::{ClientApiKeyId, RateLimits},
|
||||
routing::{
|
||||
ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId, snapshot::SnapshotControl,
|
||||
},
|
||||
routing::{ConfigRevision, ProviderKind, PublicModelId, UpstreamModelId},
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
|
||||
mod account_groups;
|
||||
@@ -143,6 +145,7 @@ impl AdminTestFixture {
|
||||
providers,
|
||||
Arc::new(NoopSnapshot),
|
||||
Arc::new(NoopProbe),
|
||||
Arc::new(StaticClientDistribution),
|
||||
system,
|
||||
)
|
||||
.await
|
||||
@@ -165,6 +168,30 @@ impl AdminTestFixture {
|
||||
}
|
||||
}
|
||||
|
||||
struct StaticClientDistribution;
|
||||
|
||||
#[async_trait]
|
||||
impl ClientDistributionResolver for StaticClientDistribution {
|
||||
async fn resolve_codex_desktop_windows(&self, _: bool) -> CodexDesktopWindowsDownloads {
|
||||
CodexDesktopWindowsDownloads {
|
||||
resolved_at: Utc::now(),
|
||||
cached: false,
|
||||
warning: None,
|
||||
packages: vec![ClientDownloadPackage {
|
||||
architecture: ClientArchitecture::X64,
|
||||
source: ClientDownloadSource::MicrosoftStore,
|
||||
version: Some("26.825.6671.0".to_owned()),
|
||||
file_name: "OpenAI.Codex_26.825.6671.0_x64__2p2nqsd0c76g0.msix".to_owned(),
|
||||
size_bytes: Some(744_250_000),
|
||||
download_url:
|
||||
"https://dl.delivery.mp.microsoft.com/filestreamingservice/files/test"
|
||||
.to_owned(),
|
||||
expires_at: Some(Utc::now() + Duration::hours(1)),
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct AdminTestState(AdminServices);
|
||||
|
||||
@@ -319,6 +346,8 @@ impl SettingsStore for MemorySettingsStore {
|
||||
max_concurrent_per_account: command.max_concurrent_per_account,
|
||||
request_interval_ms: command.request_interval_ms,
|
||||
rotation_strategy: command.rotation_strategy,
|
||||
min_codex_desktop_version: command.min_codex_desktop_version,
|
||||
min_codex_cli_version: command.min_codex_cli_version,
|
||||
usage_retention_days: command.usage_retention_days,
|
||||
ops_event_retention_days: command.ops_event_retention_days,
|
||||
audit_retention_days: command.audit_retention_days,
|
||||
@@ -858,9 +887,10 @@ impl ObservabilityStore for UnusedStore {
|
||||
async fn list_usage_records(&self, query: UsageQuery) -> AdminStoreResult<UsagePage> {
|
||||
let items = self.usage_records.lock().expect("usage records").clone();
|
||||
Ok(UsagePage {
|
||||
total: query.include_total.then_some(items.len() as u64),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -888,9 +918,10 @@ impl ObservabilityStore for UnusedStore {
|
||||
async fn list_ops_errors(&self, query: OpsErrorQuery) -> AdminStoreResult<OpsErrorPage> {
|
||||
let items = self.ops_errors.lock().expect("ops errors").clone();
|
||||
Ok(OpsErrorPage {
|
||||
total: query.include_total.then_some(items.len() as u64),
|
||||
current_page: query.current_page,
|
||||
page_size: query.page_size.get(),
|
||||
total: items.len() as u64,
|
||||
items,
|
||||
next_cursor: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1071,6 +1102,8 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
max_concurrent_per_account: 3,
|
||||
request_interval_ms: 50,
|
||||
rotation_strategy: RotationStrategy::Smart,
|
||||
min_codex_desktop_version: None,
|
||||
min_codex_cli_version: None,
|
||||
usage_retention_days: 31,
|
||||
ops_event_retention_days: 30,
|
||||
audit_retention_days: 90,
|
||||
|
||||
@@ -19,14 +19,13 @@ fn dashboard_query_should_reject_unknown_trend_kind() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_bound_page_size_and_cursor() {
|
||||
fn usage_query_should_accept_element_plus_pagination() {
|
||||
let query: UsageQuery = serde_json::from_value(json!({
|
||||
"pageSize": 100,
|
||||
"cursor": "opaque"
|
||||
"currentPage": 129,
|
||||
"pageSize": 10
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(query.validate_page_size().unwrap(), 100);
|
||||
assert!(query.validate_cursor().is_ok());
|
||||
assert_eq!(query.validate_pagination().unwrap(), (129, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -35,14 +34,25 @@ fn usage_query_should_reject_removed_total_toggle() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_removed_offset_page_contract() {
|
||||
fn usage_query_should_reject_removed_cursor_contract() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"cursor": "opaque"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_query_should_reject_nonstandard_page_name() {
|
||||
assert!(serde_json::from_value::<UsageQuery>(json!({"page": 1})).is_err());
|
||||
}
|
||||
|
||||
#[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_size().unwrap_err().field(), "pageSize");
|
||||
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ops_query_should_reject_removed_filters() {
|
||||
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
|
||||
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
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, TokenDetailsView, TrendData,
|
||||
TrendKind, TrendPointView, TrendSummaryView,
|
||||
BillingView, CostCoverageView, DashboardAccountRequestBucketView, DashboardAccountUsageView,
|
||||
DashboardDesktopReleaseStatusView, DashboardWireAttributeView, DashboardWireProfileView,
|
||||
DashboardWireTargetView, PageData, TokenDetailsView, TrendData, TrendKind, TrendPointView,
|
||||
TrendSummaryView,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -14,12 +14,15 @@ use serde_json::json;
|
||||
fn usage_page_should_keep_terminal_camel_case_shape() {
|
||||
let data = PageData {
|
||||
items: vec![json!({"id": "request_1"})],
|
||||
total: Some(1),
|
||||
next_cursor: Some("cursor".to_owned()),
|
||||
current_page: 129,
|
||||
page_size: 10,
|
||||
total: 1287,
|
||||
};
|
||||
let value = serde_json::to_value(data).unwrap();
|
||||
assert_eq!(value["total"], 1);
|
||||
assert_eq!(value["nextCursor"], "cursor");
|
||||
assert_eq!(value["currentPage"], 129);
|
||||
assert_eq!(value["pageSize"], 10);
|
||||
assert_eq!(value["total"], 1287);
|
||||
assert!(value.get("nextCursor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,11 +157,7 @@ fn sensitive_response_views_do_not_require_debug_or_add_secret_fields() {
|
||||
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();
|
||||
let value = serde_json::to_value((&coverage, &token_details)).unwrap();
|
||||
assert!(value.to_string().contains("known"));
|
||||
assert!(!value.to_string().contains("secret"));
|
||||
}
|
||||
@@ -540,6 +539,10 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
client_api_key_ref: Some("key_err".to_owned()),
|
||||
component: "model_request".to_owned(),
|
||||
operation: "responses".to_owned(),
|
||||
protocol: Some("openai".to_owned()),
|
||||
client_transport: Some("http_sse".to_owned()),
|
||||
requested_model_id: Some("gpt-5.4".to_owned()),
|
||||
service_tier: Some("priority".to_owned()),
|
||||
endpoint: Some("/v1/responses".to_owned()),
|
||||
provider_kind: Some("openai".to_owned()),
|
||||
provider_account_ref: Some("acct_err".to_owned()),
|
||||
@@ -549,6 +552,7 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
upstream_model_id: Some("upstream-err".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
failure_kind: "upstream_error".to_owned(),
|
||||
upstream_send_state: Some("sent".to_owned()),
|
||||
client_status_code: Some(502),
|
||||
upstream_status_code: Some(502),
|
||||
provider_error_code: Some("upstream".to_owned()),
|
||||
@@ -556,7 +560,29 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
upstream_request_id: None,
|
||||
latency_ms: Some(90),
|
||||
message: "snapshot error".to_owned(),
|
||||
raw_upstream_error: Some(
|
||||
r#"{"error":{"code":"upstream","message":"raw upstream marker"}}"#.to_owned(),
|
||||
),
|
||||
occurrence_count: 1,
|
||||
client_ip: Some("203.0.113.8".to_owned()),
|
||||
user_agent: Some("codex-cli/0.144.0".to_owned()),
|
||||
reasoning_effort: Some("medium".to_owned()),
|
||||
reasoning_preset: None,
|
||||
request_kind: Some("root".to_owned()),
|
||||
subagent_kind: None,
|
||||
compact: Some(false),
|
||||
continuation_affinity_hash: Some("affinity-hash".to_owned()),
|
||||
continuation_previous_response_id_hash: Some("response-hash".to_owned()),
|
||||
continuation_unavailable_reason: Some("reused_connection_lost".to_owned()),
|
||||
upstream_connection_id: Some("connection-id".to_owned()),
|
||||
upstream_connection_exit_reason: Some("tcp_reset".to_owned()),
|
||||
upstream_connection_age_ms: Some(12_000),
|
||||
upstream_connection_idle_ms: Some(4_000),
|
||||
recovery_request_id: None,
|
||||
recovered_at: None,
|
||||
recovery_attempt_count: 0,
|
||||
recovery_retry_delay_ms: None,
|
||||
recovery_total_latency_ms: None,
|
||||
occurred_at: Utc::now(),
|
||||
stable_sort_id: "model_request:req_err".to_owned(),
|
||||
});
|
||||
@@ -586,6 +612,16 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"accountLabel": value["data"]["items"][0]["metadata"]["accountLabel"],
|
||||
"clientStatusCode": value["data"]["items"][0]["clientStatusCode"],
|
||||
"route": value["data"]["items"][0]["route"],
|
||||
"requestedModel": value["data"]["items"][0]["requestedModel"],
|
||||
"clientTransport": value["data"]["items"][0]["clientTransport"],
|
||||
"clientIp": value["data"]["items"][0]["clientIp"],
|
||||
"userAgent": value["data"]["items"][0]["userAgent"],
|
||||
"providerErrorCode": value["data"]["items"][0]["providerErrorCode"],
|
||||
"upstreamSendState": value["data"]["items"][0]["upstreamSendState"],
|
||||
"rawUpstreamError": value["data"]["items"][0]["rawUpstreamError"],
|
||||
"continuationUnavailableReason": value["data"]["items"][0]["metadata"]["continuationUnavailableReason"],
|
||||
"upstreamConnectionExitReason": value["data"]["items"][0]["metadata"]["upstreamConnectionExitReason"],
|
||||
"upstreamConnectionAgeMs": value["data"]["items"][0]["metadata"]["upstreamConnectionAgeMs"],
|
||||
}),
|
||||
serde_json::json!({
|
||||
"provider": "openai",
|
||||
@@ -595,6 +631,16 @@ async fn ops_errors_should_keep_account_label_and_authentication_contract() {
|
||||
"accountLabel": "err@example.invalid",
|
||||
"clientStatusCode": 502,
|
||||
"route": "/v1/responses",
|
||||
"requestedModel": "gpt-5.4",
|
||||
"clientTransport": "http_sse",
|
||||
"clientIp": "203.0.113.8",
|
||||
"userAgent": "codex-cli/0.144.0",
|
||||
"providerErrorCode": "upstream",
|
||||
"upstreamSendState": "sent",
|
||||
"rawUpstreamError": "{\"error\":{\"code\":\"upstream\",\"message\":\"raw upstream marker\"}}",
|
||||
"continuationUnavailableReason": "reused_connection_lost",
|
||||
"upstreamConnectionExitReason": "tcp_reset",
|
||||
"upstreamConnectionAgeMs": 12000,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ fn update_body() -> Value {
|
||||
"maxConcurrentPerAccount": 5,
|
||||
"requestIntervalMs": 25,
|
||||
"rotationStrategy": "round_robin",
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
"usageRetentionDays": 32,
|
||||
"opsEventRetentionDays": 31,
|
||||
"auditRetentionDays": 91
|
||||
@@ -62,6 +64,19 @@ fn settings_request_should_reject_unknown_rotation_strategy() {
|
||||
assert_eq!(request.validate().unwrap_err().field(), "rotationStrategy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_non_semver_client_min() {
|
||||
let mut body = update_body();
|
||||
body["minCodexCliVersion"] = json!("v0.40.0");
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
serde_json::from_value(body).expect("decode settings");
|
||||
|
||||
assert_eq!(
|
||||
request.validate().unwrap_err().field(),
|
||||
"minCodexCliVersion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
use std::collections::BTreeMap;
|
||||
@@ -70,7 +85,7 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
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::account::RotationStrategy;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
let settings = RuntimeSettings {
|
||||
@@ -90,6 +105,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
max_concurrent_per_account: 5,
|
||||
request_interval_ms: 25,
|
||||
rotation_strategy: RotationStrategy::RoundRobin,
|
||||
min_codex_desktop_version: Some("26.825.6671".to_owned()),
|
||||
min_codex_cli_version: Some("0.40.0".to_owned()),
|
||||
usage_retention_days: 32,
|
||||
ops_event_retention_days: 31,
|
||||
audit_retention_days: 91,
|
||||
@@ -112,6 +129,8 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
"maxConcurrentPerAccount": 5,
|
||||
"requestIntervalMs": 25,
|
||||
"rotationStrategy": "round_robin",
|
||||
"minCodexDesktopVersion": "26.825.6671",
|
||||
"minCodexCliVersion": "0.40.0",
|
||||
"usageRetentionDays": 32,
|
||||
"opsEventRetentionDays": 31,
|
||||
"auditRetentionDays": 91,
|
||||
@@ -127,7 +146,7 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
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::account::RotationStrategy;
|
||||
use gateway_core::routing::{PublicModelId, UpstreamModelId};
|
||||
|
||||
let request: UpdateRuntimeSettingsRequest =
|
||||
@@ -159,6 +178,8 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
request_interval_ms: request.request_interval_ms,
|
||||
rotation_strategy: RotationStrategy::parse(&request.rotation_strategy)
|
||||
.expect("fixture rotation strategy"),
|
||||
min_codex_desktop_version: request.min_codex_desktop_version,
|
||||
min_codex_cli_version: request.min_codex_cli_version,
|
||||
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"),
|
||||
@@ -234,6 +255,31 @@ async fn settings_post_should_replace_global_model_mappings() {
|
||||
assert_eq!(data["modelMappings"]["grok-latest"], "grok-4.5");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_downloads_should_return_validated_direct_links() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let response = app(fixture.state())
|
||||
.oneshot(request(
|
||||
Method::GET,
|
||||
"/api/admin/settings/client-downloads/codex-desktop/windows?refresh=true",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.expect("client downloads response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let data = response_json(response).await["data"].clone();
|
||||
assert_eq!(data["packages"][0]["architecture"], "x64");
|
||||
assert_eq!(data["packages"][0]["source"], "microsoft_store");
|
||||
assert_eq!(data["packages"][0]["version"], "26.825.6671.0");
|
||||
assert!(
|
||||
data["packages"][0]["downloadUrl"]
|
||||
.as_str()
|
||||
.is_some_and(|url| url.starts_with("https://dl.delivery.mp.microsoft.com/"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_should_reject_invalid_model_mapping_name() {
|
||||
let mut body = update_body();
|
||||
|
||||
@@ -34,7 +34,11 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"src/admin/account_groups.rs",
|
||||
"src/admin/accounts.rs",
|
||||
"src/admin/accounts/credentials.rs",
|
||||
"src/admin/accounts/handlers.rs",
|
||||
"src/admin/accounts/mod.rs",
|
||||
"src/admin/accounts/presenter.rs",
|
||||
"src/admin/accounts/wire.rs",
|
||||
"src/admin/auth.rs",
|
||||
"src/admin/backups.rs",
|
||||
"src/admin/client_keys.rs",
|
||||
@@ -57,6 +61,7 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/images/mod.rs",
|
||||
"src/openai/mod.rs",
|
||||
"src/openai/models.rs",
|
||||
"src/openai/provider_endpoint.rs",
|
||||
"src/openai/responses/error.rs",
|
||||
"src/openai/responses/http.rs",
|
||||
"src/openai/responses/mod.rs",
|
||||
@@ -67,6 +72,8 @@ fn source_tree_should_match_frozen_machine_manifest() {
|
||||
"src/openai/responses/websocket/mod.rs",
|
||||
"src/openai/responses/websocket/protocol.rs",
|
||||
"src/openai/router.rs",
|
||||
"src/openai/search/http.rs",
|
||||
"src/openai/search/mod.rs",
|
||||
"src/openai/service.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
@@ -81,7 +88,7 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
actual.sort();
|
||||
let mut expected = vec![
|
||||
"tests/admin/account_groups.rs",
|
||||
"tests/admin/accounts.rs",
|
||||
"tests/admin/accounts/mod.rs",
|
||||
"tests/admin/auth.rs",
|
||||
"tests/admin/client_keys.rs",
|
||||
"tests/admin/errors.rs",
|
||||
@@ -97,15 +104,17 @@ fn test_tree_should_match_frozen_rust_mirror() {
|
||||
"tests/main.rs",
|
||||
"tests/openai/auth.rs",
|
||||
"tests/openai/error.rs",
|
||||
"tests/openai/images.rs",
|
||||
"tests/openai/images/mod.rs",
|
||||
"tests/openai/mod.rs",
|
||||
"tests/openai/models.rs",
|
||||
"tests/openai/provider_endpoint.rs",
|
||||
"tests/openai/responses/http.rs",
|
||||
"tests/openai/responses/mod.rs",
|
||||
"tests/openai/responses/websocket/connection.rs",
|
||||
"tests/openai/responses/websocket/mod.rs",
|
||||
"tests/openai/responses/websocket/protocol.rs",
|
||||
"tests/openai/router.rs",
|
||||
"tests/openai/search/mod.rs",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use axum::http::{HeaderMap, HeaderValue, header::AUTHORIZATION};
|
||||
|
||||
use gateway_api::openai::auth::{ClientApiKeyAuthError, bearer_client_api_key};
|
||||
use gateway_api::openai::auth::{
|
||||
ClientApiKeyAuthError, bearer_client_api_key, identify_codex_client,
|
||||
};
|
||||
use gateway_core::policy::CodexClientKind;
|
||||
|
||||
#[test]
|
||||
fn bearer_client_api_key_should_reject_missing_authorization() {
|
||||
@@ -88,3 +91,48 @@ fn client_auth_failure_reasons_should_be_stable_and_secret_free() {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_headers_should_take_precedence_over_embedded_cli_marker() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("originator", HeaderValue::from_static("Codex Desktop"));
|
||||
headers.insert("version", HeaderValue::from_static("26.825.6671"));
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("codex_cli_rs/0.39.0 terminal (Codex Desktop; 26.1.0)"),
|
||||
);
|
||||
|
||||
let identified = identify_codex_client(&headers).expect("recognized desktop");
|
||||
|
||||
assert_eq!(identified.kind(), CodexClientKind::Desktop);
|
||||
assert_eq!(
|
||||
identified.version().map(ToString::to_string).as_deref(),
|
||||
Some("26.825.6671")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_user_agent_should_expose_semver_or_recognized_missing_version() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("codex_cli_rs/0.39.0 (Linux; x86_64)"),
|
||||
);
|
||||
let identified = identify_codex_client(&headers).expect("recognized CLI");
|
||||
assert_eq!(identified.kind(), CodexClientKind::Cli);
|
||||
assert_eq!(
|
||||
identified.version().map(ToString::to_string).as_deref(),
|
||||
Some("0.39.0")
|
||||
);
|
||||
|
||||
headers.insert(
|
||||
"user-agent",
|
||||
HeaderValue::from_static("codex-cli/not-a-version"),
|
||||
);
|
||||
assert!(
|
||||
identify_codex_client(&headers)
|
||||
.expect("recognized invalid CLI")
|
||||
.version()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use axum::{body::to_bytes, http::StatusCode};
|
||||
use gateway_core::engine::{EngineError, UpstreamSendState};
|
||||
use gateway_core::engine::EngineError;
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
|
||||
StoreError, StoreErrorKind,
|
||||
};
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
|
||||
use gateway_api::openai::error::{
|
||||
gateway_error_contract, gateway_error_from_engine, gateway_error_response,
|
||||
@@ -397,3 +398,38 @@ async fn gateway_error_response_should_expose_only_structured_client_visible_ups
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_recovery_should_preserve_the_official_retry_signal() {
|
||||
let error = GatewayError::from_provider(
|
||||
&ProviderError::new(
|
||||
ProviderErrorKind::ContinuationRecoveryRequired,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
"Previous response was not found. Retrying the full request.",
|
||||
Some("previous_response_not_found".to_owned()),
|
||||
Some("invalid_request_error".to_owned()),
|
||||
)),
|
||||
);
|
||||
|
||||
let response = gateway_error_response(&error);
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read continuation recovery response");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&body).expect("continuation recovery JSON");
|
||||
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"error": {
|
||||
"message": "Previous response was not found. Retrying the full request.",
|
||||
"type": "invalid_request_error",
|
||||
"code": "previous_response_not_found"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,6 @@ use axum::{
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::UpstreamSendState;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
|
||||
ExecutionSession, StartExecution, StartProviderExecution, StartedExecution,
|
||||
@@ -22,6 +21,7 @@ use gateway_core::error::{
|
||||
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
|
||||
use gateway_core::operation::{ImageRequestKind, Operation};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -2,8 +2,10 @@ mod auth;
|
||||
mod error;
|
||||
mod images;
|
||||
mod models;
|
||||
mod provider_endpoint;
|
||||
mod responses;
|
||||
mod router;
|
||||
mod search;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
@@ -12,6 +14,7 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::account::{AccountSelectionPolicy, ProviderAccountId, RotationStrategy};
|
||||
use gateway_core::engine::admission::{
|
||||
ClientAdmissionDecision, ClientAdmissionError, ClientAdmissionPort, ClientAdmissionRecovery,
|
||||
ClientAdmissionRequest, ClientAdmissionRestoreResult,
|
||||
@@ -19,9 +22,6 @@ use gateway_core::engine::admission::{
|
||||
use gateway_core::engine::continuation::{
|
||||
NativeContinuationPin, NativeContinuationPort, NativeContinuationStoreError, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountSelectionPolicy, ProviderAccountId, RotationStrategy,
|
||||
};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientApiKeyUsageSink, DefaultExecutionService, ExecutionService,
|
||||
ProviderCircuitDecision, ProviderCircuitError, ProviderCircuitPort,
|
||||
@@ -29,17 +29,21 @@ use gateway_core::engine::execution::{
|
||||
use gateway_core::engine::provider::ProviderRegistry;
|
||||
use gateway_core::engine::{
|
||||
AttemptRecord, ExecutionStore, IntermediateFailure, ModelRequestFinalization, ModelRequestId,
|
||||
NewModelRequest, RecoveryReport, UpstreamSendState,
|
||||
NewModelRequest, RecoveryReport,
|
||||
};
|
||||
use gateway_core::error::StoreError;
|
||||
use gateway_core::health::{WorkerHealthSnapshot, WorkerHealthSource};
|
||||
use gateway_core::lifecycle::{ConnectionDraining, ConnectionGuard, ConnectionLifecycle};
|
||||
use gateway_core::policy::{ClientApiKeyId, ClientPolicy, PlaintextClientApiKey, RateLimits};
|
||||
use gateway_core::routing::snapshot::RuntimeSnapshotHandle;
|
||||
use gateway_core::policy::{
|
||||
ClientApiKeyId, ClientPolicy, CodexClientMinVersions, CodexClientVersion,
|
||||
PlaintextClientApiKey, RateLimits,
|
||||
};
|
||||
use gateway_core::routing::{
|
||||
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
|
||||
ProviderModel, RuntimeAccount, RuntimeAccountDirectory, RuntimeSnapshot, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::runtime::RuntimeSnapshotHandle;
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
|
||||
pub(super) async fn api_router(execution: Arc<dyn ExecutionService>) -> axum::Router {
|
||||
api_router_with_origins(execution, Vec::new()).await
|
||||
@@ -109,6 +113,30 @@ pub(super) fn authenticated_client_for_provider(
|
||||
.expect("authenticated client")
|
||||
}
|
||||
|
||||
pub(super) fn authenticated_client_with_min_versions(
|
||||
plaintext: &str,
|
||||
desktop: Option<&str>,
|
||||
cli: Option<&str>,
|
||||
) -> AuthenticatedClient {
|
||||
let snapshot =
|
||||
snapshot(plaintext, "openai").with_min_codex_client_versions(CodexClientMinVersions::new(
|
||||
desktop.map(|version| CodexClientVersion::parse(version).expect("Desktop min version")),
|
||||
cli.map(|version| CodexClientVersion::parse(version).expect("CLI min version")),
|
||||
));
|
||||
let source = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
Arc::new(UnusedExecutionStore),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedCircuits),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(IgnoredClientApiKeyUsage),
|
||||
);
|
||||
source
|
||||
.authenticate(plaintext)
|
||||
.expect("authenticated client")
|
||||
}
|
||||
|
||||
struct IgnoredClientApiKeyUsage;
|
||||
|
||||
impl ClientApiKeyUsageSink for IgnoredClientApiKeyUsage {
|
||||
@@ -160,7 +188,7 @@ fn snapshot(plaintext: &str, provider_name: &str) -> RuntimeSnapshot {
|
||||
|
||||
#[derive(Default)]
|
||||
struct TestLifecycle {
|
||||
cancellation: gateway_core::engine::CancellationToken,
|
||||
cancellation: gateway_core::lifecycle::CancellationToken,
|
||||
}
|
||||
|
||||
struct TestConnectionGuard;
|
||||
@@ -172,7 +200,7 @@ impl ConnectionLifecycle for TestLifecycle {
|
||||
Ok(Box::new(TestConnectionGuard))
|
||||
}
|
||||
|
||||
fn cancellation(&self) -> gateway_core::engine::CancellationToken {
|
||||
fn cancellation(&self) -> gateway_core::lifecycle::CancellationToken {
|
||||
self.cancellation.clone()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ use gateway_core::routing::{
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{api_router, authenticated_client};
|
||||
use super::{api_router, authenticated_client, authenticated_client_with_min_versions};
|
||||
|
||||
pub(super) struct ModelsExecution {
|
||||
client: AuthenticatedClient,
|
||||
@@ -36,6 +36,24 @@ impl ModelsExecution {
|
||||
profiles: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_cli_min() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client_with_min_versions("sk_models_test", None, Some("0.40.0")),
|
||||
profiles: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn with_desktop_min() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client_with_min_versions(
|
||||
"sk_models_test",
|
||||
Some("26.825.51511"),
|
||||
None,
|
||||
),
|
||||
profiles: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionService for ModelsExecution {
|
||||
@@ -144,6 +162,111 @@ async fn models_should_encode_the_service_visible_catalog() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_reject_recognized_cli_below_configured_min() {
|
||||
let request = Request::get("/v1/models")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("user-agent", "codex_cli_rs/0.39.0 (Linux; x86_64)")
|
||||
.body(Body::empty())
|
||||
.expect("build models request");
|
||||
let response = api_router(ModelsExecution::with_cli_min())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("list models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read version rejection body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
|
||||
assert_eq!(value["error"]["code"], "client_version_too_old");
|
||||
assert_eq!(value["error"]["client"], "codex_cli");
|
||||
assert_eq!(value["error"]["current_version"], "0.39.0");
|
||||
assert_eq!(value["error"]["min_version"], "0.40.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_reject_recognized_cli_with_invalid_version() {
|
||||
let request = Request::get("/v1/models")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("user-agent", "codex-cli/not-a-version")
|
||||
.body(Body::empty())
|
||||
.expect("build models request");
|
||||
let response = api_router(ModelsExecution::with_cli_min())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("list models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read version rejection body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
|
||||
assert_eq!(value["error"]["code"], "client_version_unavailable");
|
||||
assert!(value["error"]["current_version"].is_null());
|
||||
assert_eq!(value["error"]["min_version"], "0.40.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_reject_desktop_below_its_configured_min() {
|
||||
let request = Request::get("/v1/models")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("originator", "Codex Desktop")
|
||||
.header("version", "26.825.50000")
|
||||
.header("user-agent", "codex_cli_rs/99.0.0 (Codex Desktop)")
|
||||
.body(Body::empty())
|
||||
.expect("build models request");
|
||||
let response = api_router(ModelsExecution::with_desktop_min())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("list models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read version rejection body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("version rejection JSON");
|
||||
assert_eq!(value["error"]["code"], "client_version_too_old");
|
||||
assert_eq!(value["error"]["client"], "codex_desktop");
|
||||
assert_eq!(value["error"]["current_version"], "26.825.50000");
|
||||
assert_eq!(value["error"]["min_version"], "26.825.51511");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_allow_cli_at_its_configured_min() {
|
||||
let request = Request::get("/v1/models")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("user-agent", "codex_cli_rs/0.40.0 (Linux; x86_64)")
|
||||
.body(Body::empty())
|
||||
.expect("build models request");
|
||||
let response = api_router(ModelsExecution::with_cli_min())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("list models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_leave_unknown_clients_unrestricted() {
|
||||
let request = Request::get("/v1/models")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.header("user-agent", "curl/8.14.1")
|
||||
.body(Body::empty())
|
||||
.expect("build models request");
|
||||
let response = api_router(ModelsExecution::with_cli_min())
|
||||
.await
|
||||
.oneshot(request)
|
||||
.await
|
||||
.expect("list models response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_encode_provider_profiles_for_current_codex_clients() {
|
||||
let response = api_router(ModelsExecution::with_profiles())
|
||||
@@ -261,51 +384,12 @@ async fn model_detail_should_hide_unknown_models() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_catalog_should_keep_the_codex_catalog_contract() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/catalog"))
|
||||
.await
|
||||
.expect("catalog response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read catalog body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("catalog JSON");
|
||||
assert_eq!(value[0]["id"], "model-a");
|
||||
assert_eq!(value[0]["displayName"], "model-a");
|
||||
assert_eq!(value[0]["source"], "gateway");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_info_should_return_a_single_visible_catalog_entry() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-a/info"))
|
||||
.await
|
||||
.expect("model info response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn model_info_should_hide_unknown_models() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
.await
|
||||
.oneshot(authorized_request("/v1/models/model-private/info"))
|
||||
.await
|
||||
.expect("missing model info response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn models_should_require_a_valid_bearer_client_key() {
|
||||
let response = api_router(ModelsExecution::new())
|
||||
async fn models_should_authenticate_before_applying_the_version_gate() {
|
||||
let response = api_router(ModelsExecution::with_cli_min())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models")
|
||||
.header("user-agent", "codex_cli_rs/0.1.0 (Linux; x86_64)")
|
||||
.body(Body::empty())
|
||||
.expect("build unauthenticated request"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
use std::sync::{Arc, Mutex, atomic::AtomicBool, atomic::Ordering};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::execution::ExecutionSession;
|
||||
use gateway_core::engine::{CoordinatedEvent, EngineError};
|
||||
use gateway_core::error::ProviderError;
|
||||
use gateway_core::event::{ProtocolWireEvent, ProviderEvent, ProviderResponseHeader};
|
||||
|
||||
pub(super) struct BufferedJsonSession {
|
||||
body: Option<Bytes>,
|
||||
response_headers: Vec<ProviderResponseHeader>,
|
||||
response_status: Option<u16>,
|
||||
committed_statuses: Arc<Mutex<Vec<u16>>>,
|
||||
finalized: AtomicBool,
|
||||
failure: Option<ProviderError>,
|
||||
}
|
||||
|
||||
impl BufferedJsonSession {
|
||||
pub(super) fn success(
|
||||
body: Bytes,
|
||||
response_status: u16,
|
||||
response_headers: Vec<ProviderResponseHeader>,
|
||||
committed_statuses: Arc<Mutex<Vec<u16>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
body: Some(body),
|
||||
response_headers,
|
||||
response_status: Some(response_status),
|
||||
committed_statuses,
|
||||
finalized: AtomicBool::new(false),
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn failure(error: ProviderError) -> Self {
|
||||
Self {
|
||||
body: None,
|
||||
response_headers: Vec::new(),
|
||||
response_status: None,
|
||||
committed_statuses: Arc::new(Mutex::new(Vec::new())),
|
||||
finalized: AtomicBool::new(false),
|
||||
failure: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionSession for BufferedJsonSession {
|
||||
fn next_event(&mut self) -> BoxFuture<'_, Result<Option<CoordinatedEvent>, EngineError>> {
|
||||
Box::pin(async { unreachable!("buffered JSON delivery does not stream") })
|
||||
}
|
||||
|
||||
fn collect_uncommitted(&mut self) -> BoxFuture<'_, Result<Vec<ProviderEvent>, EngineError>> {
|
||||
Box::pin(async move {
|
||||
if let Some(error) = self.failure.take() {
|
||||
return Err(EngineError::Provider(error));
|
||||
}
|
||||
Ok(vec![ProviderEvent::wire(
|
||||
ProtocolWireEvent::raw_json(
|
||||
"openai",
|
||||
self.body.take().expect("single buffered JSON response"),
|
||||
)
|
||||
.expect("OpenAI protocol"),
|
||||
)])
|
||||
})
|
||||
}
|
||||
|
||||
fn response_headers(&self) -> &[ProviderResponseHeader] {
|
||||
&self.response_headers
|
||||
}
|
||||
|
||||
fn response_status_code(&self) -> Option<u16> {
|
||||
self.response_status
|
||||
}
|
||||
|
||||
fn commit_downstream(
|
||||
&mut self,
|
||||
client_status_code: Option<u16>,
|
||||
) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async move {
|
||||
self.committed_statuses
|
||||
.lock()
|
||||
.expect("committed status lock")
|
||||
.push(client_status_code.expect("buffered JSON response status"));
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn record_client_status(&mut self, _: u16) -> BoxFuture<'_, Result<(), EngineError>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn is_finalized(&self) -> bool {
|
||||
self.finalized.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
fn detach_finalize(self: Box<Self>) -> BoxFuture<'static, ()> {
|
||||
Box::pin(async move {
|
||||
self.finalized.store(true, Ordering::Release);
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamError, ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind,
|
||||
ProviderError, ProviderErrorKind,
|
||||
@@ -30,12 +30,13 @@ use gateway_core::event::{
|
||||
};
|
||||
use gateway_core::operation::{Operation, OperationKind};
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use gateway_api::openai::responses::{collect_execution_response, stream_execution_response};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::openai::{api_router, authenticated_client, authenticated_client_for_provider};
|
||||
use crate::openai::{api_router, authenticated_client_for_provider};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Trace {
|
||||
@@ -688,92 +689,6 @@ async fn xai_private_headers_should_not_enter_openai_request_facts() {
|
||||
assert!(captured.prompt_cache_key.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn review_route_should_inject_review_subagent_and_record_its_endpoint() {
|
||||
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")
|
||||
.header("x-openai-subagent", "compact")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"model": "smart-code",
|
||||
"input": "review this",
|
||||
"client_metadata": {"existing": "preserved"}
|
||||
})
|
||||
.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.endpoint, "/v1/responses/review");
|
||||
assert_eq!(
|
||||
captured
|
||||
.client_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("x-openai-subagent")),
|
||||
Some(&json!("review"))
|
||||
);
|
||||
assert_eq!(
|
||||
captured
|
||||
.client_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.get("existing")),
|
||||
Some(&json!("preserved"))
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
@@ -196,7 +196,13 @@ fn decoder_should_preserve_connection_metadata_outside_the_openai_wire_body() {
|
||||
HeaderValue::from_static("{\"kind\":\"review\"}"),
|
||||
);
|
||||
headers.insert("conversation-id", HeaderValue::from_static("conversation"));
|
||||
let body = json!({"model": "smart-code", "input": "hello"});
|
||||
let body = json!({
|
||||
"model": "smart-code",
|
||||
"input": "hello",
|
||||
"client_metadata": {
|
||||
"x-codex-turn-metadata": "{\"kind\":\"turn\"}"
|
||||
}
|
||||
});
|
||||
|
||||
let decoded = decode_request_with_headers(body.to_string().as_bytes(), &headers)
|
||||
.expect("request should decode");
|
||||
@@ -320,8 +326,8 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
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()]
|
||||
openai_protocol_context(&decoded).get("version"),
|
||||
Some(&json!("future-v1"))
|
||||
);
|
||||
assert_eq!(
|
||||
values("openai-beta"),
|
||||
@@ -349,6 +355,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
|
||||
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
|
||||
"user-agent",
|
||||
"originator",
|
||||
"version",
|
||||
"x-oai-attestation",
|
||||
"x-oai-is",
|
||||
"x-oai-is-update",
|
||||
|
||||
@@ -16,7 +16,7 @@ use axum::{
|
||||
use futures::{Sink, SinkExt, Stream, StreamExt, future::BoxFuture};
|
||||
use gateway_core::{
|
||||
engine::{
|
||||
CancellationToken, CommitRequirement, CoordinatedEvent, EngineError,
|
||||
CommitRequirement, CoordinatedEvent, EngineError,
|
||||
execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
@@ -24,6 +24,7 @@ use gateway_core::{
|
||||
},
|
||||
error::GatewayError,
|
||||
event::{GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta},
|
||||
lifecycle::CancellationToken,
|
||||
routing::PublicModelId,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -190,16 +191,7 @@ fn crossing_limit_terminal_batch() -> CoordinatedEvent {
|
||||
.expect("terminal WebSocket batch")
|
||||
}
|
||||
|
||||
// 连接 runtime 没有正式公共 API;镜像测试直接编译该私有模块,以测试 socket pump,
|
||||
// 不把测试钩子暴露给 gateway-api 的消费者。
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "the mirrored test only exercises pump-facing seams"
|
||||
)]
|
||||
#[path = "../../../../src/openai/responses/websocket/connection.rs"]
|
||||
mod connection_under_test;
|
||||
|
||||
use connection_under_test::{
|
||||
use gateway_api::openai::responses::websocket::connection::{
|
||||
ConnectionConfig, ConnectionEvent, ConnectionWriteError, FramePhase,
|
||||
ResponsesWebSocketConnection, WriteContext, spawn_connection,
|
||||
};
|
||||
|
||||
@@ -9,22 +9,27 @@ use std::time::{Duration, SystemTime};
|
||||
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::{SinkExt, StreamExt, future::BoxFuture};
|
||||
use gateway_api::openai::responses::ResponseCreateFrameError;
|
||||
use gateway_api::openai::responses::{
|
||||
DecodedResponsesRequest, OpenAiRequestHeaders, ResponseCreateFrameError,
|
||||
decode_response_create_with_context,
|
||||
};
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ExecutionService, ExecutionSession,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError, UpstreamSendState};
|
||||
use gateway_core::engine::{CommitRequirement, CoordinatedEvent, EngineError};
|
||||
use gateway_core::error::{GatewayError, ProviderError, ProviderErrorKind};
|
||||
use gateway_core::event::{
|
||||
GatewayEvent, ProtocolWireEvent, ProviderEvent, ProviderResponseHeader, ResponseMeta,
|
||||
};
|
||||
use gateway_core::operation::Operation;
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
use gateway_protocol::openai::codex_responses_request_semantics;
|
||||
use serde_json::{Value, json};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as ClientMessage;
|
||||
@@ -34,6 +39,22 @@ use tower::ServiceExt;
|
||||
use super::decode_response_create;
|
||||
use crate::openai::{api_router, authenticated_client, models::ModelsExecution};
|
||||
|
||||
fn decode_response_create_with_turn_header(
|
||||
payload: Value,
|
||||
opening_turn_metadata: &'static str,
|
||||
) -> DecodedResponsesRequest {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-codex-turn-metadata",
|
||||
HeaderValue::from_static(opening_turn_metadata),
|
||||
);
|
||||
decode_response_create_with_context(
|
||||
&payload.to_string(),
|
||||
&OpenAiRequestHeaders::from_headers(&headers),
|
||||
)
|
||||
.expect("response.create should decode")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_create_should_default_to_the_websocket_streaming_contract() {
|
||||
let decoded = decode_response_create(
|
||||
@@ -118,6 +139,87 @@ fn response_create_should_preserve_compaction_trigger_for_openai() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_create_should_prefer_frame_turn_metadata_over_opening_headers() {
|
||||
let decoded = decode_response_create_with_turn_header(
|
||||
json!({
|
||||
"type": "response.create",
|
||||
"model": "smart-code",
|
||||
"input": "hello",
|
||||
"client_metadata": {
|
||||
"x-codex-turn-metadata": r#"{"request_kind":"turn"}"#
|
||||
}
|
||||
}),
|
||||
r#"{"request_kind":"compaction"}"#,
|
||||
);
|
||||
let Operation::Generate(request) = decoded.operation() else {
|
||||
panic!("Responses must map to Generate");
|
||||
};
|
||||
|
||||
let semantics = codex_responses_request_semantics(
|
||||
request.protocol_payload().body(),
|
||||
request.protocol_payload().context(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(semantics.request_kind.as_deref(), semantics.compact),
|
||||
(Some("turn"), false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_create_should_accept_frame_compaction_after_a_turn_opening_header() {
|
||||
let decoded = decode_response_create_with_turn_header(
|
||||
json!({
|
||||
"type": "response.create",
|
||||
"model": "smart-code",
|
||||
"input": "hello",
|
||||
"client_metadata": {
|
||||
"x-codex-turn-metadata": r#"{"request_kind":"compaction"}"#
|
||||
}
|
||||
}),
|
||||
r#"{"request_kind":"turn"}"#,
|
||||
);
|
||||
let Operation::Generate(request) = decoded.operation() else {
|
||||
panic!("Responses must map to Generate");
|
||||
};
|
||||
|
||||
let semantics = codex_responses_request_semantics(
|
||||
request.protocol_payload().body(),
|
||||
request.protocol_payload().context(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(semantics.request_kind.as_deref(), semantics.compact),
|
||||
(Some("compaction"), true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_create_should_fall_back_to_opening_turn_metadata_when_the_frame_omits_it() {
|
||||
let decoded = decode_response_create_with_turn_header(
|
||||
json!({
|
||||
"type": "response.create",
|
||||
"model": "smart-code",
|
||||
"input": "hello"
|
||||
}),
|
||||
r#"{"request_kind":"compaction"}"#,
|
||||
);
|
||||
let Operation::Generate(request) = decoded.operation() else {
|
||||
panic!("Responses must map to Generate");
|
||||
};
|
||||
|
||||
let semantics = codex_responses_request_semantics(
|
||||
request.protocol_payload().body(),
|
||||
request.protocol_payload().context(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
(semantics.request_kind.as_deref(), semantics.compact),
|
||||
(Some("compaction"), true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_create_should_reject_explicit_non_streaming_requests() {
|
||||
let error = decode_response_create(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode},
|
||||
http::{Method, Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -9,6 +9,54 @@ use super::models::ModelsExecution;
|
||||
|
||||
const REMOVED_BODY_LIMIT_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_responses_review_route_should_not_reach_the_responses_handler() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/responses/review")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed review request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed review request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_models_catalog_extension_should_use_standard_model_lookup() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models/catalog")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed model catalog request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed model catalog request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removed_model_info_route_should_return_not_found() {
|
||||
let response = api_router_with_origins(ModelsExecution::new(), Vec::new())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::get("/v1/models/model-a/info")
|
||||
.header(AUTHORIZATION, "Bearer sk_models_test")
|
||||
.body(Body::empty())
|
||||
.expect("build removed model info request"),
|
||||
)
|
||||
.await
|
||||
.expect("route removed model info request");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn responses_body_should_accept_payload_above_the_removed_private_limit() {
|
||||
let router = api_router_with_origins(ModelsExecution::new(), Vec::new()).await;
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use axum::{
|
||||
body::{Body, to_bytes},
|
||||
http::{Request, StatusCode, header::AUTHORIZATION},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::ModelRequestId;
|
||||
use gateway_core::engine::execution::{
|
||||
AuthenticatedClient, ClientAuthenticationError, ClientTransport, ExecutionService,
|
||||
StartExecution, StartProviderExecution, StartedExecution,
|
||||
};
|
||||
use gateway_core::error::{
|
||||
ClientVisibleUpstreamResponse, GatewayError, GatewayErrorKind, ProviderError, ProviderErrorKind,
|
||||
};
|
||||
use gateway_core::event::ProviderResponseHeader;
|
||||
use gateway_core::operation::Operation;
|
||||
use gateway_core::routing::PublicModelId;
|
||||
use gateway_core::upstream::UpstreamSendState;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::provider_endpoint::BufferedJsonSession;
|
||||
use super::{api_router, authenticated_client};
|
||||
|
||||
const SEARCH_RESPONSE: &[u8] = br#"{ "encrypted_output":"ciphertext", "output":"search result", "results":[{"type":"text_result","ref_id":"turn0search0","future":9007199254740993}] }"#;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct CapturedSearchRequest {
|
||||
provider: String,
|
||||
endpoint: String,
|
||||
transport: ClientTransport,
|
||||
body: Bytes,
|
||||
context: Value,
|
||||
}
|
||||
|
||||
struct SearchExecution {
|
||||
client: AuthenticatedClient,
|
||||
captured: Mutex<Vec<CapturedSearchRequest>>,
|
||||
committed_statuses: Arc<Mutex<Vec<u16>>>,
|
||||
fail_with_upstream_response: bool,
|
||||
}
|
||||
|
||||
impl SearchExecution {
|
||||
fn new(fail_with_upstream_response: bool) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
client: authenticated_client("sk_search_test"),
|
||||
captured: Mutex::new(Vec::new()),
|
||||
committed_statuses: Arc::new(Mutex::new(Vec::new())),
|
||||
fail_with_upstream_response,
|
||||
})
|
||||
}
|
||||
|
||||
fn captured(&self) -> Vec<CapturedSearchRequest> {
|
||||
self.captured.lock().expect("capture lock").clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl ExecutionService for SearchExecution {
|
||||
fn authenticate(
|
||||
&self,
|
||||
plaintext: &str,
|
||||
) -> Result<AuthenticatedClient, ClientAuthenticationError> {
|
||||
(plaintext == "sk_search_test")
|
||||
.then(|| self.client.clone())
|
||||
.ok_or(ClientAuthenticationError::InvalidKey)
|
||||
}
|
||||
|
||||
fn public_models(&self, _: &AuthenticatedClient) -> Vec<PublicModelId> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn contains_public_model(&self, _: &AuthenticatedClient, _: &PublicModelId) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn start(&self, _: StartExecution) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async {
|
||||
Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"search test must use provider endpoint execution",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn start_provider_endpoint(
|
||||
&self,
|
||||
request: StartProviderExecution,
|
||||
) -> BoxFuture<'_, Result<StartedExecution, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let Operation::Search(search) = &request.operation else {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"search test received a non-search operation",
|
||||
));
|
||||
};
|
||||
let payload = search.payload();
|
||||
self.captured
|
||||
.lock()
|
||||
.expect("capture lock")
|
||||
.push(CapturedSearchRequest {
|
||||
provider: request.provider.as_str().to_owned(),
|
||||
endpoint: request.metadata.endpoint,
|
||||
transport: request.metadata.transport,
|
||||
body: payload.body().clone(),
|
||||
context: Value::Object(payload.context().clone()),
|
||||
});
|
||||
let session = if self.fail_with_upstream_response {
|
||||
BufferedJsonSession::failure(
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::Sent,
|
||||
)
|
||||
.with_status(StatusCode::UNPROCESSABLE_ENTITY.as_u16())
|
||||
.with_client_visible_upstream_response(
|
||||
ClientVisibleUpstreamResponse::new(
|
||||
StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
|
||||
Some(b"application/problem+json".to_vec()),
|
||||
Bytes::from_static(
|
||||
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#,
|
||||
),
|
||||
)
|
||||
.with_headers(vec![ProviderResponseHeader::new(
|
||||
"x-future-search-error",
|
||||
Bytes::from_static(b"preserved"),
|
||||
)]),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
BufferedJsonSession::success(
|
||||
Bytes::from_static(SEARCH_RESPONSE),
|
||||
StatusCode::CREATED.as_u16(),
|
||||
vec![ProviderResponseHeader::new(
|
||||
"x-search-rate-limit",
|
||||
Bytes::from_static(b"42"),
|
||||
)],
|
||||
Arc::clone(&self.committed_statuses),
|
||||
)
|
||||
};
|
||||
Ok(StartedExecution {
|
||||
request_id: ModelRequestId::new("req_search_test").expect("request ID"),
|
||||
created_at: SystemTime::now(),
|
||||
stream: false,
|
||||
session: Box::new(session),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_route_should_preserve_request_and_success_response_bytes() {
|
||||
let execution = SearchExecution::new(false);
|
||||
let request_body = br#"{ "id":"search-session", "model":"gpt-future", "commands":{"search_query":[{"q":"private query"}]}, "future":1, "future":2 }"#;
|
||||
let turn_metadata =
|
||||
r#"{"session_id":"session","thread_id":"thread","turn_id":"turn","future":true}"#;
|
||||
let response = api_router(execution.clone())
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/alpha/search")
|
||||
.header(AUTHORIZATION, "Bearer sk_search_test")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-codex-turn-metadata", turn_metadata)
|
||||
.body(Body::from(request_body.to_vec()))
|
||||
.expect("search request"),
|
||||
)
|
||||
.await
|
||||
.expect("search response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
assert_eq!(
|
||||
response.headers().get("x-search-rate-limit"),
|
||||
Some(&"42".parse().expect("header value"))
|
||||
);
|
||||
let response_body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read search response");
|
||||
assert_eq!(response_body.as_ref(), SEARCH_RESPONSE);
|
||||
|
||||
let captured = execution.captured();
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].provider, "openai");
|
||||
assert_eq!(captured[0].endpoint, "/v1/alpha/search");
|
||||
assert_eq!(captured[0].transport, ClientTransport::HttpJson);
|
||||
assert_eq!(captured[0].body.as_ref(), request_body);
|
||||
assert_eq!(captured[0].context, json!({"turn_metadata": turn_metadata}));
|
||||
assert_eq!(
|
||||
*execution
|
||||
.committed_statuses
|
||||
.lock()
|
||||
.expect("committed status lock"),
|
||||
vec![StatusCode::CREATED.as_u16()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_route_should_return_the_exact_upstream_error_response() {
|
||||
let response = api_router(SearchExecution::new(true))
|
||||
.await
|
||||
.oneshot(
|
||||
Request::post("/v1/alpha/search")
|
||||
.header(AUTHORIZATION, "Bearer sk_search_test")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
br#"{ "model":"gpt-future", "future_invalid":9007199254740993 }"#.to_vec(),
|
||||
))
|
||||
.expect("search request"),
|
||||
)
|
||||
.await
|
||||
.expect("search response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(
|
||||
response.headers().get("content-type"),
|
||||
Some(&"application/problem+json".parse().expect("content type"))
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get("x-future-search-error"),
|
||||
Some(&"preserved".parse().expect("header value"))
|
||||
);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read search response");
|
||||
assert_eq!(
|
||||
body.as_ref(),
|
||||
br#"{ "error":{"message":"future search validation"}, "future":9007199254740993 }"#
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ futures = "0.3.32"
|
||||
futures-timer = "3.0.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = { version = "1.0.150", features = ["arbitrary_precision", "preserve_order"] }
|
||||
semver = "1.0.27"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Provider credential 值对象构造错误。
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Credential 值对象构造错误。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum CredentialError {
|
||||
#[error("credential revision must be greater than zero")]
|
||||
InvalidRevision,
|
||||
#[error("credential revision overflow")]
|
||||
RevisionOverflow,
|
||||
#[error("credential CAS profile belongs to a different account")]
|
||||
ProfileAccountMismatch,
|
||||
#[error("credential refresh schedule requires a refresh token")]
|
||||
InvalidRefreshSchedule,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//! Provider 账号领域、持久化端口与同一 target 内的账号选择。
|
||||
|
||||
mod error;
|
||||
mod model;
|
||||
mod selection;
|
||||
mod store;
|
||||
|
||||
pub use error::CredentialError;
|
||||
pub use model::*;
|
||||
pub use selection::*;
|
||||
pub use store::ProviderAccountStore;
|
||||
+4
-811
@@ -1,20 +1,16 @@
|
||||
//! Provider 账号、明文 credential port 与同一 target 内的账号选择。
|
||||
//! Provider 账号、明文 credential 与持久状态值对象。
|
||||
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fmt;
|
||||
use std::num::{NonZeroU16, NonZeroU32, NonZeroU64};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Map, Value};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::error::{IdentifierError, StoreError, validate_text};
|
||||
use crate::error::{IdentifierError, validate_text};
|
||||
use crate::routing::ProviderKind;
|
||||
|
||||
use super::CredentialError;
|
||||
|
||||
/// `provider_accounts.id` 的核心值对象。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ProviderAccountId(String);
|
||||
@@ -1257,806 +1253,3 @@ pub struct AccountStateChange {
|
||||
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// `provider_accounts` 的数据库中立端口。
|
||||
#[async_trait]
|
||||
pub trait ProviderAccountStore: Send + Sync {
|
||||
async fn create_account(&self, account: NewProviderAccount) -> Result<(), StoreError>;
|
||||
|
||||
async fn get_account(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
) -> Result<Option<ProviderAccount>, StoreError>;
|
||||
|
||||
async fn list_accounts(&self) -> Result<Vec<ProviderAccount>, StoreError>;
|
||||
|
||||
async fn list_for_provider(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
) -> Result<Vec<ProviderAccount>, StoreError>;
|
||||
|
||||
/// 一次有界查询返回账号事实和 revision-fenced 明文 credential。
|
||||
async fn list_refresh_candidates(
|
||||
&self,
|
||||
query: ProviderRefreshQuery,
|
||||
) -> Result<Vec<LoadedCredential>, StoreError>;
|
||||
|
||||
async fn load_credential(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
expected_revision: CredentialRevision,
|
||||
) -> Result<LoadedCredential, StoreError>;
|
||||
|
||||
/// 读取账号当前 credential 及其 revision,不做任何版本比对。
|
||||
///
|
||||
/// 管理写入必须在临近 CAS 时用它取 fence,而不是携带调用方持有的旧 revision:
|
||||
/// 后台刷新随时会推进 revision,用陈旧快照会把正常的恢复操作误判为冲突。
|
||||
async fn load_current_credential(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
) -> Result<LoadedCredential, StoreError>;
|
||||
|
||||
async fn compare_and_swap_credential(
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, StoreError>;
|
||||
|
||||
async fn get_quotas(
|
||||
&self,
|
||||
accounts: &[ProviderAccountId],
|
||||
) -> Result<Vec<QuotaObservation>, StoreError>;
|
||||
|
||||
async fn compare_and_swap_quota(
|
||||
&self,
|
||||
observation: QuotaObservation,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), StoreError>;
|
||||
|
||||
async fn update_account(&self, update: ProviderAccountUpdate) -> Result<(), StoreError>;
|
||||
|
||||
async fn set_enabled(
|
||||
&self,
|
||||
account: &ProviderAccountId,
|
||||
enabled: bool,
|
||||
) -> Result<(), StoreError>;
|
||||
|
||||
async fn delete_account(&self, account: &ProviderAccountId) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
/// `runtime_settings.rotation_strategy` 的稳定值。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RotationStrategy {
|
||||
Smart,
|
||||
QuotaResetPriority,
|
||||
RoundRobin,
|
||||
Sticky,
|
||||
}
|
||||
|
||||
impl RotationStrategy {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Smart => "smart",
|
||||
Self::QuotaResetPriority => "quota_reset_priority",
|
||||
Self::RoundRobin => "round_robin",
|
||||
Self::Sticky => "sticky",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"smart" => Some(Self::Smart),
|
||||
"quota_reset_priority" => Some(Self::QuotaResetPriority),
|
||||
"round_robin" => Some(Self::RoundRobin),
|
||||
"sticky" => Some(Self::Sticky),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `runtime_settings` 冻结到一次请求计划的账号调度策略。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountSelectionPolicy {
|
||||
strategy: RotationStrategy,
|
||||
max_concurrent_per_account: NonZeroU32,
|
||||
request_interval: Duration,
|
||||
}
|
||||
|
||||
impl AccountSelectionPolicy {
|
||||
#[must_use]
|
||||
pub const fn new(
|
||||
strategy: RotationStrategy,
|
||||
max_concurrent_per_account: NonZeroU32,
|
||||
request_interval: Duration,
|
||||
) -> Self {
|
||||
Self {
|
||||
strategy,
|
||||
max_concurrent_per_account,
|
||||
request_interval,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn strategy(self) -> RotationStrategy {
|
||||
self.strategy
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn max_concurrent_per_account(self) -> NonZeroU32 {
|
||||
self.max_concurrent_per_account
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn request_interval(self) -> Duration {
|
||||
self.request_interval
|
||||
}
|
||||
}
|
||||
|
||||
/// Store 提供并发事实,Provider 叠加自己解释的额度事实;全部信号均可重建。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRuntimeSignals {
|
||||
pub in_flight: u32,
|
||||
pub last_started_at: Option<SystemTime>,
|
||||
pub quota_reset_at: Option<SystemTime>,
|
||||
pub quota_remaining_rank: Option<u64>,
|
||||
pub rate_limited_until: Option<SystemTime>,
|
||||
pub failure_rate_basis_points: Option<u16>,
|
||||
pub first_output_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
|
||||
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
|
||||
|
||||
/// 一次真实上游 attempt 对账号级 Smart 调度产生的中立反馈。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountAttemptFeedback {
|
||||
Succeeded { first_output_ms: Option<u64> },
|
||||
Failed { first_output_ms: Option<u64> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct AccountFeedbackKey {
|
||||
provider_kind: ProviderKind,
|
||||
account_id: ProviderAccountId,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AccountFeedback {
|
||||
failure_rate: AtomicU64,
|
||||
first_output_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl Default for AccountFeedback {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// 新账号从健康基线开始,首个失败样本按 EWMA 平滑,而不是直接判死。
|
||||
failure_rate: AtomicU64::new(0.0_f64.to_bits()),
|
||||
first_output_ms: AtomicU64::new(EMPTY_FEEDBACK_SAMPLE),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountFeedback {
|
||||
fn report(&self, feedback: AccountAttemptFeedback) {
|
||||
let (failure, first_output_ms) = match feedback {
|
||||
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
|
||||
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
|
||||
};
|
||||
update_feedback_ewma(&self.failure_rate, failure);
|
||||
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
|
||||
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
|
||||
}
|
||||
}
|
||||
|
||||
fn scheduling_signals(&self) -> (Option<u16>, Option<u64>) {
|
||||
let failure_rate = load_feedback_ewma(&self.failure_rate).map(|value| {
|
||||
let basis_points = (value.clamp(0.0, 1.0) * 10_000.0).round();
|
||||
basis_points as u16
|
||||
});
|
||||
let first_output_ms = load_feedback_ewma(&self.first_output_ms)
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
.map(|value| value.round() as u64);
|
||||
(failure_rate, first_output_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// 所有 Provider 共享、按 Provider 和账号隔离的进程内 Smart 健康反馈。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AccountFeedbackStats {
|
||||
accounts: RwLock<HashMap<AccountFeedbackKey, AccountFeedback>>,
|
||||
}
|
||||
|
||||
impl AccountFeedbackStats {
|
||||
/// 读取账号当前的错误率与首个有效输出延迟 EWMA。
|
||||
#[must_use]
|
||||
pub fn scheduling_signals(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
account_id: &ProviderAccountId,
|
||||
) -> (Option<u16>, Option<u64>) {
|
||||
let key = AccountFeedbackKey {
|
||||
provider_kind: provider_kind.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
self.accounts
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&key)
|
||||
.map(AccountFeedback::scheduling_signals)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 回灌一次已经真实发送的上游 attempt。
|
||||
pub fn report(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
account_id: &ProviderAccountId,
|
||||
feedback: AccountAttemptFeedback,
|
||||
) {
|
||||
let key = AccountFeedbackKey {
|
||||
provider_kind: provider_kind.clone(),
|
||||
account_id: account_id.clone(),
|
||||
};
|
||||
if let Some(account) = self
|
||||
.accounts
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&key)
|
||||
{
|
||||
account.report(feedback);
|
||||
return;
|
||||
}
|
||||
self.accounts
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.entry(key)
|
||||
.or_default()
|
||||
.report(feedback);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_feedback_ewma(value: &AtomicU64) -> Option<f64> {
|
||||
let value = f64::from_bits(value.load(Ordering::Relaxed));
|
||||
(!value.is_nan()).then_some(value)
|
||||
}
|
||||
|
||||
fn update_feedback_ewma(target: &AtomicU64, sample: f64) {
|
||||
let mut current = target.load(Ordering::Relaxed);
|
||||
loop {
|
||||
let previous = f64::from_bits(current);
|
||||
let next = if previous.is_nan() {
|
||||
sample
|
||||
} else {
|
||||
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * previous
|
||||
};
|
||||
match target.compare_exchange_weak(
|
||||
current,
|
||||
next.to_bits(),
|
||||
Ordering::Relaxed,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(observed) => current = observed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AccountRuntimeSignals {
|
||||
#[must_use]
|
||||
pub fn with_provider_quota(mut self, quota: Option<AccountQuotaSignals>) -> Self {
|
||||
if let Some(quota) = quota {
|
||||
self.quota_reset_at = quota.reset_at;
|
||||
self.quota_remaining_rank = quota.remaining_rank;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_rate_limit(mut self, rate_limited_until: Option<SystemTime>) -> Self {
|
||||
self.rate_limited_until = rate_limited_until;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_runtime_health(
|
||||
mut self,
|
||||
failure_rate_basis_points: Option<u16>,
|
||||
first_output_latency_ms: Option<u64>,
|
||||
) -> Self {
|
||||
self.failure_rate_basis_points = failure_rate_basis_points;
|
||||
self.first_output_latency_ms = first_output_latency_ms;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 从私有 quota JSON 投影出的中立调度事实。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AccountQuotaSignals {
|
||||
reset_at: Option<SystemTime>,
|
||||
remaining_rank: Option<u64>,
|
||||
}
|
||||
|
||||
impl AccountQuotaSignals {
|
||||
#[must_use]
|
||||
pub const fn new(reset_at: Option<SystemTime>, remaining_rank: Option<u64>) -> Self {
|
||||
Self {
|
||||
reset_at,
|
||||
remaining_rank,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn reset_at(self) -> Option<SystemTime> {
|
||||
self.reset_at
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn remaining_rank(self) -> Option<u64> {
|
||||
self.remaining_rank
|
||||
}
|
||||
}
|
||||
|
||||
/// 账号持久事实与可重建运行信号的请求级组合。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountCandidate {
|
||||
pub account: ProviderAccount,
|
||||
pub signals: AccountRuntimeSignals,
|
||||
}
|
||||
|
||||
/// 一次账号选择看到的可调度并发槽快照。
|
||||
///
|
||||
/// `used_slots` 包含刚刚获取成功的当前请求;快照只覆盖本次请求范围内、
|
||||
/// 模型可用且未被显式排除的账号池。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AccountCapacitySnapshot {
|
||||
used_slots: u64,
|
||||
total_slots: u64,
|
||||
}
|
||||
|
||||
impl AccountCapacitySnapshot {
|
||||
#[must_use]
|
||||
pub const fn used_slots(self) -> u64 {
|
||||
self.used_slots
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn total_slots(self) -> u64 {
|
||||
self.total_slots
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_acquired_request(self) -> Self {
|
||||
Self {
|
||||
used_slots: self.used_slots.saturating_add(1).min(self.total_slots),
|
||||
total_slots: self.total_slots,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次账号选择使用的全局策略快照。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AccountSelectionContext {
|
||||
pub policy: AccountSelectionPolicy,
|
||||
pub now: SystemTime,
|
||||
pub excluded_accounts: BTreeSet<ProviderAccountId>,
|
||||
pub preferred_account: Option<ProviderAccountId>,
|
||||
pub round_robin_cursor: u64,
|
||||
pub eligibility: AccountEligibilityPolicy,
|
||||
pub account_scope: Option<std::sync::Arc<crate::routing::FrozenAccountScope>>,
|
||||
}
|
||||
|
||||
/// 选择账号时是否执行本地调度资格投影。
|
||||
///
|
||||
/// 管理端对指定账号执行诊断时,会直接向上游确认实际状态;该模式跳过
|
||||
/// `enabled`、可用性、冷却和 token 到期等本地投影,仍保留租约、并发和
|
||||
/// 请求间隔约束,且只能与固定账号约束组合使用。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AccountEligibilityPolicy {
|
||||
#[default]
|
||||
Enforce,
|
||||
BypassForDiagnostic,
|
||||
}
|
||||
|
||||
impl AccountEligibilityPolicy {
|
||||
#[must_use]
|
||||
pub const fn bypasses_local_eligibility(self) -> bool {
|
||||
matches!(self, Self::BypassForDiagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
/// 候选账号未进入调度池的约束。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AccountSchedulingBlocker {
|
||||
OutsideClientScope,
|
||||
LocalAvailability,
|
||||
Excluded,
|
||||
ConcurrencyLimit,
|
||||
RequestInterval,
|
||||
LowerWeight,
|
||||
}
|
||||
|
||||
/// 优先账号在本次选择中的处理结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PreferredAccountSelection {
|
||||
NotRequested,
|
||||
Hit,
|
||||
Missing,
|
||||
Blocked(AccountSchedulingBlocker),
|
||||
}
|
||||
|
||||
/// 一次完整账号选择;同时保留优先账号决策,供 Provider 记录调度遥测。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AccountSelection<'a> {
|
||||
candidate: &'a AccountCandidate,
|
||||
preferred: PreferredAccountSelection,
|
||||
}
|
||||
|
||||
impl<'a> AccountSelection<'a> {
|
||||
#[must_use]
|
||||
pub const fn candidate(self) -> &'a AccountCandidate {
|
||||
self.candidate
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn preferred(self) -> PreferredAccountSelection {
|
||||
self.preferred
|
||||
}
|
||||
}
|
||||
|
||||
/// 同一 target 内唯一的账号排序器。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct AccountSelector;
|
||||
|
||||
impl AccountSelector {
|
||||
/// 汇总与本次调度约束一致的并发容量,供请求级观测使用。
|
||||
#[must_use]
|
||||
pub fn capacity_snapshot(
|
||||
&self,
|
||||
candidates: &[AccountCandidate],
|
||||
context: &AccountSelectionContext,
|
||||
) -> Option<AccountCapacitySnapshot> {
|
||||
let (used_slots, total_slots) = candidates
|
||||
.iter()
|
||||
.filter(|candidate| {
|
||||
!matches!(
|
||||
self.scheduling_blocker(candidate, context),
|
||||
Some(
|
||||
AccountSchedulingBlocker::OutsideClientScope
|
||||
| AccountSchedulingBlocker::LocalAvailability
|
||||
| AccountSchedulingBlocker::Excluded
|
||||
)
|
||||
)
|
||||
})
|
||||
.fold((0_u64, 0_u64), |(used, total), candidate| {
|
||||
let capacity = u64::from(
|
||||
candidate
|
||||
.account
|
||||
.effective_concurrency(context.policy.max_concurrent_per_account())
|
||||
.get(),
|
||||
);
|
||||
(
|
||||
used.saturating_add(u64::from(candidate.signals.in_flight)),
|
||||
total.saturating_add(capacity),
|
||||
)
|
||||
});
|
||||
(total_slots > 0).then_some(AccountCapacitySnapshot {
|
||||
used_slots: used_slots.min(total_slots),
|
||||
total_slots,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从可调度账号中确定一个候选;这里只消费 Provider 已解析的额度投影。
|
||||
#[must_use]
|
||||
pub fn select<'a>(
|
||||
&self,
|
||||
candidates: &'a [AccountCandidate],
|
||||
context: &AccountSelectionContext,
|
||||
) -> Option<AccountSelection<'a>> {
|
||||
let mut eligible = candidates
|
||||
.iter()
|
||||
.filter(|candidate| self.scheduling_blocker(candidate, context).is_none())
|
||||
.collect::<Vec<_>>();
|
||||
if eligible.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let highest_weight = eligible
|
||||
.iter()
|
||||
.map(|candidate| candidate.account.weight())
|
||||
.max()?;
|
||||
let preferred = if let Some(preferred) = context.preferred_account.as_ref() {
|
||||
match candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.account.id() == preferred)
|
||||
{
|
||||
Some(candidate) => match self.scheduling_blocker(candidate, context) {
|
||||
Some(blocker) => PreferredAccountSelection::Blocked(blocker),
|
||||
None if candidate.account.weight() < highest_weight => {
|
||||
PreferredAccountSelection::Blocked(AccountSchedulingBlocker::LowerWeight)
|
||||
}
|
||||
None => {
|
||||
return Some(AccountSelection {
|
||||
candidate,
|
||||
preferred: PreferredAccountSelection::Hit,
|
||||
});
|
||||
}
|
||||
},
|
||||
None => PreferredAccountSelection::Missing,
|
||||
}
|
||||
} else {
|
||||
PreferredAccountSelection::NotRequested
|
||||
};
|
||||
eligible.retain(|candidate| candidate.account.weight() == highest_weight);
|
||||
|
||||
let candidate = match context.policy.strategy() {
|
||||
RotationStrategy::QuotaResetPriority => {
|
||||
let default_concurrency = context.policy.max_concurrent_per_account();
|
||||
eligible.sort_by(|left, right| {
|
||||
(
|
||||
left.signals.quota_reset_at.is_none(),
|
||||
left.signals.quota_reset_at,
|
||||
)
|
||||
.cmp(&(
|
||||
right.signals.quota_reset_at.is_none(),
|
||||
right.signals.quota_reset_at,
|
||||
))
|
||||
.then_with(|| {
|
||||
capacity_utilization(left, default_concurrency)
|
||||
.total_cmp(&capacity_utilization(right, default_concurrency))
|
||||
})
|
||||
.then_with(|| {
|
||||
left.signals
|
||||
.last_started_at
|
||||
.cmp(&right.signals.last_started_at)
|
||||
})
|
||||
.then_with(|| left.account.id().cmp(right.account.id()))
|
||||
});
|
||||
eligible.first().copied()?
|
||||
}
|
||||
RotationStrategy::RoundRobin => {
|
||||
eligible.sort_by_key(|candidate| candidate.account.id().clone());
|
||||
let index = context.round_robin_cursor as usize % eligible.len();
|
||||
eligible.get(index).copied()?
|
||||
}
|
||||
RotationStrategy::Smart => select_smart_candidate(
|
||||
&eligible,
|
||||
context.policy.max_concurrent_per_account(),
|
||||
context.round_robin_cursor,
|
||||
)?,
|
||||
RotationStrategy::Sticky => {
|
||||
eligible.sort_by_key(|candidate| {
|
||||
(
|
||||
Reverse(candidate.signals.last_started_at),
|
||||
candidate.account.id().clone(),
|
||||
)
|
||||
});
|
||||
eligible.first().copied()?
|
||||
}
|
||||
};
|
||||
Some(AccountSelection {
|
||||
candidate,
|
||||
preferred,
|
||||
})
|
||||
}
|
||||
|
||||
fn scheduling_blocker(
|
||||
&self,
|
||||
candidate: &AccountCandidate,
|
||||
context: &AccountSelectionContext,
|
||||
) -> Option<AccountSchedulingBlocker> {
|
||||
if context
|
||||
.account_scope
|
||||
.as_ref()
|
||||
.is_some_and(|scope| !scope.allows(candidate.account.id()))
|
||||
{
|
||||
return Some(AccountSchedulingBlocker::OutsideClientScope);
|
||||
}
|
||||
if !context.eligibility.bypasses_local_eligibility() {
|
||||
let status = candidate
|
||||
.account
|
||||
.status_projection(context.now, candidate.signals.rate_limited_until)
|
||||
.status;
|
||||
if status != AccountStatus::Normal {
|
||||
return Some(AccountSchedulingBlocker::LocalAvailability);
|
||||
}
|
||||
}
|
||||
if context.excluded_accounts.contains(candidate.account.id()) {
|
||||
return Some(AccountSchedulingBlocker::Excluded);
|
||||
}
|
||||
if candidate.signals.in_flight
|
||||
>= candidate
|
||||
.account
|
||||
.effective_concurrency(context.policy.max_concurrent_per_account())
|
||||
.get()
|
||||
{
|
||||
return Some(AccountSchedulingBlocker::ConcurrencyLimit);
|
||||
}
|
||||
if candidate
|
||||
.signals
|
||||
.last_started_at
|
||||
.is_some_and(|last_started| {
|
||||
!context
|
||||
.now
|
||||
.duration_since(last_started)
|
||||
.is_ok_and(|elapsed| elapsed >= context.policy.request_interval())
|
||||
})
|
||||
{
|
||||
return Some(AccountSchedulingBlocker::RequestInterval);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
const SMART_LOAD_WEIGHT: f64 = 1.0;
|
||||
const SMART_QUOTA_WEIGHT: f64 = 0.8;
|
||||
const SMART_FAILURE_WEIGHT: f64 = 1.0;
|
||||
const SMART_LATENCY_WEIGHT: f64 = 0.5;
|
||||
|
||||
fn capacity_utilization(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
|
||||
f64::from(candidate.signals.in_flight)
|
||||
/ f64::from(
|
||||
candidate
|
||||
.account
|
||||
.effective_concurrency(default_concurrency)
|
||||
.get(),
|
||||
)
|
||||
}
|
||||
|
||||
struct SmartNormalization {
|
||||
max_capacity_utilization: f64,
|
||||
min_quota: Option<u64>,
|
||||
max_quota: Option<u64>,
|
||||
min_latency_ms: Option<u64>,
|
||||
max_latency_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SmartNormalization {
|
||||
fn from_candidates(candidates: &[&AccountCandidate], default_concurrency: NonZeroU32) -> Self {
|
||||
let max_capacity_utilization = candidates
|
||||
.iter()
|
||||
.map(|candidate| capacity_utilization(candidate, default_concurrency))
|
||||
.reduce(f64::max)
|
||||
.unwrap_or_default();
|
||||
let min_quota = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
|
||||
.min();
|
||||
let max_quota = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.quota_remaining_rank)
|
||||
.max();
|
||||
let min_latency_ms = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
|
||||
.filter(|latency| *latency > 0)
|
||||
.min();
|
||||
let max_latency_ms = candidates
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate.signals.first_output_latency_ms)
|
||||
.filter(|latency| *latency > 0)
|
||||
.max();
|
||||
Self {
|
||||
max_capacity_utilization,
|
||||
min_quota,
|
||||
max_quota,
|
||||
min_latency_ms,
|
||||
max_latency_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn select_smart_candidate<'a>(
|
||||
candidates: &[&'a AccountCandidate],
|
||||
default_concurrency: NonZeroU32,
|
||||
cursor: u64,
|
||||
) -> Option<&'a AccountCandidate> {
|
||||
let normalization = SmartNormalization::from_candidates(candidates, default_concurrency);
|
||||
let mut ranked = candidates
|
||||
.iter()
|
||||
.map(|candidate| {
|
||||
(
|
||||
*candidate,
|
||||
smart_score(candidate, default_concurrency, &normalization),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ranked.sort_by(|(left, left_score), (right, right_score)| {
|
||||
right_score
|
||||
.total_cmp(left_score)
|
||||
.then_with(|| left.account.id().cmp(right.account.id()))
|
||||
});
|
||||
let best_score = ranked.first()?.1.to_bits();
|
||||
let tied = ranked
|
||||
.iter()
|
||||
.take_while(|(_, score)| score.to_bits() == best_score)
|
||||
.count();
|
||||
let index = cursor as usize % tied;
|
||||
Some(ranked[index].0)
|
||||
}
|
||||
|
||||
fn smart_score(
|
||||
candidate: &AccountCandidate,
|
||||
default_concurrency: NonZeroU32,
|
||||
normalization: &SmartNormalization,
|
||||
) -> f64 {
|
||||
let load = lower_ratio_is_better(
|
||||
capacity_utilization(candidate, default_concurrency),
|
||||
normalization.max_capacity_utilization,
|
||||
);
|
||||
let quota = candidate.signals.quota_remaining_rank.map_or(0.5, |quota| {
|
||||
higher_is_better(
|
||||
quota,
|
||||
normalization.min_quota.unwrap_or(quota),
|
||||
normalization.max_quota.unwrap_or(quota),
|
||||
)
|
||||
});
|
||||
let failure = 1.0
|
||||
- f64::from(
|
||||
candidate
|
||||
.signals
|
||||
.failure_rate_basis_points
|
||||
.unwrap_or_default()
|
||||
.min(10_000),
|
||||
) / 10_000.0;
|
||||
let latency = candidate
|
||||
.signals
|
||||
.first_output_latency_ms
|
||||
.filter(|latency| *latency > 0)
|
||||
.map_or(1.0, |latency| {
|
||||
lower_is_better(
|
||||
latency,
|
||||
normalization.min_latency_ms.unwrap_or(latency),
|
||||
normalization.max_latency_ms.unwrap_or(latency),
|
||||
)
|
||||
});
|
||||
|
||||
SMART_LOAD_WEIGHT * load
|
||||
+ SMART_QUOTA_WEIGHT * quota
|
||||
+ SMART_FAILURE_WEIGHT * failure
|
||||
+ SMART_LATENCY_WEIGHT * latency
|
||||
}
|
||||
|
||||
fn lower_ratio_is_better(value: f64, maximum: f64) -> f64 {
|
||||
if maximum <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (value / maximum).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn lower_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
|
||||
if maximum <= minimum {
|
||||
return 1.0;
|
||||
}
|
||||
1.0 - (value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn higher_is_better(value: u64, minimum: u64, maximum: u64) -> f64 {
|
||||
if maximum <= minimum {
|
||||
return 1.0;
|
||||
}
|
||||
(value.saturating_sub(minimum) as f64 / (maximum - minimum) as f64).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Credential 值对象构造错误。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum CredentialError {
|
||||
#[error("credential revision must be greater than zero")]
|
||||
InvalidRevision,
|
||||
#[error("credential revision overflow")]
|
||||
RevisionOverflow,
|
||||
#[error("credential CAS profile belongs to a different account")]
|
||||
ProfileAccountMismatch,
|
||||
#[error("credential refresh schedule requires a refresh token")]
|
||||
InvalidRefreshSchedule,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user