mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-11 00:01:15 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a0552df73 | ||
|
|
c72136c689 | ||
|
|
a2972d6455 | ||
|
|
b5365b5c38 | ||
|
|
a4e60c3d57 | ||
|
|
e3befd862a | ||
|
|
2fc347508a | ||
|
|
e079ba6e4b | ||
|
|
13edec0b07 | ||
|
|
725de6ead6 | ||
|
|
758bd24b32 | ||
|
|
114baf669b | ||
|
|
628533216a | ||
|
|
dd65665128 | ||
|
|
63db306ba2 | ||
|
|
de58b436b1 | ||
|
|
bec4c2d2a6 | ||
|
|
092789f5ff | ||
|
|
c6350fd234 | ||
|
|
620db1b877 | ||
|
|
4bf1208024 | ||
|
|
cf7a9bd501 | ||
|
|
c622f08574 | ||
|
|
5741683ea1 | ||
|
|
16b3671733 | ||
|
|
a708301cb4 | ||
|
|
65fd34991b | ||
|
|
03186b5411 | ||
|
|
47ce36f4bf | ||
|
|
462cef5dd5 | ||
|
|
5647dc8dcb | ||
|
|
0d44492309 | ||
|
|
31f471b051 | ||
|
|
e7ef31d882 | ||
|
|
197e9e517a | ||
|
|
e770c94a9e | ||
|
|
ce620f1b53 | ||
|
|
290aecb7a4 | ||
|
|
1a2a4a56ae | ||
|
|
6dd0da1fbd |
@@ -125,7 +125,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
cache-bin: false
|
||||
save-if: ${{ github.event_name != 'pull_request' }}
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -340,7 +340,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -434,10 +434,10 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -539,7 +539,7 @@ jobs:
|
||||
|
||||
- name: Upload GitHub Release assets
|
||||
id: release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
uses: softprops/action-gh-release@8a0cd651e5b2365bd640854a3fa5dfc70e4875f3 # v3.0.4
|
||||
with:
|
||||
tag_name: ${{ needs.release-info.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1
|
||||
uses: taiki-e/install-action@3a3daafd3b7f23b8f65d9f5ba2db203fb59b79e2 # v2.90.2
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
ref: ${{ inputs.ref || github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
/.runtime/
|
||||
*.log
|
||||
|
||||
# Local planning documents
|
||||
/docs/plans/
|
||||
|
||||
# Local configuration and secrets
|
||||
/deploy/config.yaml
|
||||
.env
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
|
||||
> [!NOTE]
|
||||
> 本项目提供 Responses API 和 Chat Completions API;Chat Completions 请求通过 Responses 链路执行,支持范围见 [API 参考](docs/api.md#chat-completions-请求)
|
||||
> 生成请求支持 [OpenAI Responses](docs/api.md#responses-请求与传输)、[Chat Completions](docs/api.md#chat-completions-请求) 和 [Anthropic Messages](docs/api.md#messages-请求) 三种协议
|
||||
|
||||
## 快速预览
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
|
||||
以下命令适用于 Linux amd64/arm64,需要 Docker Engine、Docker Compose Plugin、curl 和 OpenSSL
|
||||
|
||||
Windows x64 / ARM64 使用发行包部署,步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
macOS 从[发行页](https://github.com/zyycn/codex-proxy-rs/releases)下载对应的归档:Intel 机型选 `darwin_amd64`,Apple Silicon 机型选 `darwin_arm64`。
|
||||
Windows x64 / ARM64 的安装步骤见 [Windows 二进制部署](deploy/README.md#windows-二进制部署)
|
||||
|
||||
已有部署请先看 [升级说明](deploy/README.md#镜像升级与源码构建),不要覆盖原配置
|
||||
|
||||
@@ -99,12 +100,12 @@ API Key 持有者可在登录页切换身份,查看自己的用量与额度,
|
||||
|
||||
完整步骤、生图配置与排障见[客户端配置](deploy/README.md#客户端配置)
|
||||
|
||||
**其他 Responses / Chat Completions API 客户端**:填写以下信息:
|
||||
**其他 API 客户端**:API Key 填写管理端创建的客户端密钥。按客户端自动追加的请求路径填写 Base URL;远程接入时将本机地址换成服务器的 HTTPS 地址
|
||||
|
||||
| 配置 | 值 |
|
||||
| 客户端自动追加的路径 | Base URL(本机部署) |
|
||||
| --- | --- |
|
||||
| Base URL | `http://127.0.0.1:8080/v1`;远程接入使用服务器的 HTTPS 地址 |
|
||||
| API Key | 管理端创建的客户端密钥 |
|
||||
| OpenAI Responses:`/responses`;Chat Completions:`/chat/completions` | `http://127.0.0.1:8080/v1` |
|
||||
| Anthropic Messages:`/v1/messages` | `http://127.0.0.1:8080` |
|
||||
|
||||
查询该密钥可见的模型目录:
|
||||
|
||||
|
||||
Generated
+84
-30
@@ -102,6 +102,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -147,9 +153,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.51"
|
||||
version = "0.4.52"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
|
||||
checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -716,6 +722,17 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -942,9 +959,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.46"
|
||||
version = "0.4.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -966,7 +983,7 @@ dependencies = [
|
||||
"pathdiff",
|
||||
"serde_core",
|
||||
"winnow 1.0.3",
|
||||
"yaml-rust2",
|
||||
"yaml-rust2 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1126,9 +1143,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.7.6"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb08299a6046833a879ace115697f12fd2bd6c8d3e93c909ffc9e0738e7cae0"
|
||||
checksum = "914c195c4d7fbc5ed7f88d14cbf85914d5c664967658d43404b263913a477e82"
|
||||
dependencies = [
|
||||
"cpubits",
|
||||
"ctutils",
|
||||
@@ -1186,9 +1203,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.5"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
"subtle",
|
||||
@@ -1773,9 +1790,11 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"cron",
|
||||
"event-listener",
|
||||
"futures",
|
||||
"futures-timer",
|
||||
"gateway-protocol",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"semver",
|
||||
@@ -1834,7 +1853,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"wiremock",
|
||||
"yaml-rust2",
|
||||
"yaml-rust2 0.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2065,6 +2084,15 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.2"
|
||||
@@ -2577,9 +2605,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
@@ -2936,15 +2964,14 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -2983,9 +3010,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -3334,6 +3361,7 @@ dependencies = [
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"lru",
|
||||
"native-tls",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
@@ -3352,6 +3380,7 @@ dependencies = [
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tiktoken-rs",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
@@ -4218,7 +4247,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"sqlx-core",
|
||||
"sqlx-macros",
|
||||
@@ -4230,7 +4259,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4245,7 +4274,7 @@ dependencies = [
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"hashbrown 0.16.1",
|
||||
"hashlink",
|
||||
"hashlink 0.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"memchr",
|
||||
@@ -4267,7 +4296,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4279,7 +4308,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-macros-core"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"dotenvy",
|
||||
@@ -4304,7 +4333,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-mysql"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
@@ -4331,7 +4360,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-postgres"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"base64 0.22.1",
|
||||
@@ -4367,7 +4396,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "sqlx-sqlite"
|
||||
version = "0.9.0"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=f1e94ec34dd5f11ad6488a3a8ac7988439a3f757#f1e94ec34dd5f11ad6488a3a8ac7988439a3f757"
|
||||
source = "git+https://github.com/transact-rs/sqlx?rev=10462fa4414c6c7f1ed1d39bb31678e3b726878c#10462fa4414c6c7f1ed1d39bb31678e3b726878c"
|
||||
dependencies = [
|
||||
"atoi",
|
||||
"chrono",
|
||||
@@ -4594,6 +4623,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken-rs"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2aeff724640cfe13037336ddf35befdffd2909cbdb65cf041cc8a4cf8c584cfa"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"bstr",
|
||||
"fancy-regex",
|
||||
"regex",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
@@ -5519,13 +5562,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "631a50d867fafb7093e709d75aaee9e0e0d5deb934021fcea25ac2fe09edc51e"
|
||||
checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"encoding_rs",
|
||||
"hashlink",
|
||||
"hashlink 0.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust2"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"encoding_rs",
|
||||
"hashlink 0.12.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5647,9 +5701,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.2.2"
|
||||
version = "9.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e408c6cc4699dd017d53dc080cd51c2cea2b5354af57a1479bd265ed42911487"
|
||||
checksum = "3cec733c9820347a592f6375c3c0089b479ba0a0b1cbf3dd1958b02959b7c033"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
|
||||
+2
-2
@@ -61,5 +61,5 @@ codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[patch.crates-io]
|
||||
# 固定上游 BEGIN 取消回滚修复,避免未结束事务复用;见 transact-rs/sqlx#4394
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "f1e94ec34dd5f11ad6488a3a8ac7988439a3f757" }
|
||||
# 固定包含 BEGIN 取消回滚及 SQLite 安全版本支持的上游提交;见 transact-rs/sqlx#4394、#4502
|
||||
sqlx = { git = "https://github.com/transact-rs/sqlx", rev = "10462fa4414c6c7f1ed1d39bb31678e3b726878c" }
|
||||
|
||||
@@ -7,6 +7,7 @@ use gateway_admin::{
|
||||
ports::{
|
||||
plugin_accounts::PluginAccountAccess, plugin_client_keys::PluginClientKeyAccess,
|
||||
plugin_resources::PluginResourceAccess, provider::ProviderAdminRegistry,
|
||||
proxy::PluginProxyAccess,
|
||||
},
|
||||
};
|
||||
use gateway_core::runtime::{RuntimeSnapshotHandle, SnapshotControl};
|
||||
@@ -35,6 +36,7 @@ pub(super) struct ManagementPorts {
|
||||
pub(super) accounts: Arc<dyn PluginAccountAccess>,
|
||||
keys: Arc<dyn PluginClientKeyAccess>,
|
||||
resources: Arc<dyn PluginResourceAccess>,
|
||||
proxies: Arc<dyn PluginProxyAccess>,
|
||||
}
|
||||
|
||||
impl ManagementPorts {
|
||||
@@ -46,6 +48,7 @@ impl ManagementPorts {
|
||||
) -> Self {
|
||||
let ports = store.admin_ports();
|
||||
Self {
|
||||
proxies: gateway_admin::initialize_plugin_proxies(ports.proxies()),
|
||||
accounts: gateway_admin::initialize_plugin_accounts(
|
||||
providers.clone(),
|
||||
ports.accounts(),
|
||||
@@ -66,6 +69,7 @@ impl ManagementPorts {
|
||||
|
||||
pub(super) fn bind(&self, runtime: &PluginRuntime) -> Result<(), AdminError> {
|
||||
runtime.bind_account_ports(&self.accounts)?;
|
||||
runtime.bind_proxy_ports(&self.proxies)?;
|
||||
runtime.bind_client_key_ports(&self.keys)?;
|
||||
runtime.bind_resource_ports(&self.resources)
|
||||
}
|
||||
|
||||
@@ -128,9 +128,11 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
let mut probes = store.health_probes();
|
||||
probes.extend(core.health_probes());
|
||||
probes.push(host.logging_health_probe());
|
||||
let peer_node = or_shutdown!(plugin_runtime, store.peer_routing_id().await);
|
||||
let api = or_shutdown!(
|
||||
plugin_runtime,
|
||||
gateway_api::initialize(
|
||||
peer_node,
|
||||
config.api,
|
||||
core.execution_service(),
|
||||
admin.services(),
|
||||
|
||||
@@ -509,6 +509,7 @@ const ALLOWED_INTERNAL_EDGES: &[(&str, &str)] = &[
|
||||
("gateway-api", "gateway-admin"),
|
||||
("gateway-api", "gateway-core"),
|
||||
("gateway-api", "gateway-protocol"),
|
||||
("gateway-core", "gateway-protocol"),
|
||||
("gateway-host", "gateway-admin"),
|
||||
("gateway-host", "gateway-core"),
|
||||
("gateway-host", "gateway-protocol"),
|
||||
|
||||
@@ -264,6 +264,14 @@ pub fn initialize_plugin_accounts(
|
||||
))
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建代理目录与当前出口解析的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_proxies(
|
||||
store: Arc<dyn ports::proxy::ProxyStore>,
|
||||
) -> Arc<dyn ports::proxy::PluginProxyAccess> {
|
||||
Arc::new(use_case::proxies::DefaultPluginProxyAccess { store })
|
||||
}
|
||||
|
||||
/// 为 Runtime 创建非秘密 Client Key 目录与预算重置的窄端口
|
||||
#[must_use]
|
||||
pub fn initialize_plugin_client_keys(
|
||||
|
||||
@@ -42,8 +42,8 @@ pub use use_case::{
|
||||
|
||||
pub use bootstrap::{
|
||||
initialize, initialize_accounts, initialize_plugin_accounts, initialize_plugin_client_keys,
|
||||
initialize_plugin_resources, initialize_settings, initialize_usage_data,
|
||||
initialize_with_plugin_accounts,
|
||||
initialize_plugin_proxies, initialize_plugin_resources, initialize_settings,
|
||||
initialize_usage_data, initialize_with_plugin_accounts,
|
||||
};
|
||||
|
||||
use ports::{
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use gateway_core::account::{
|
||||
/// 账号可用的管理操作
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProviderAccountCapabilities {
|
||||
pub quota_limits_supported: bool,
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
@@ -111,6 +112,8 @@ pub enum AccountGroupFilter {
|
||||
/// 账号公共存储投影;Provider 专属字段不进入此结构
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountRecord {
|
||||
pub quota_paused: bool,
|
||||
pub quota_limits: gateway_core::account::AccountQuotaLimits,
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub id: String,
|
||||
pub provider_kind: ProviderKind,
|
||||
@@ -251,6 +254,7 @@ pub struct AccountUpdateResult {
|
||||
/// 仅修改显式字段的批量账号设置命令
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BatchUpdateAccounts {
|
||||
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub account_ids: Vec<String>,
|
||||
pub enabled: Option<bool>,
|
||||
@@ -261,6 +265,16 @@ pub struct BatchUpdateAccounts {
|
||||
pub outbound_proxy: Option<super::proxies::AccountProxySelection>,
|
||||
}
|
||||
|
||||
/// 已按拟提交策略重算的账号暂停结果,以快照版本防止旧判断覆盖新观测
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedQuotaPause {
|
||||
pub observed_at: Option<DateTime<Utc>>,
|
||||
pub revision: Revision,
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
pub type PreparedQuotaPauses = std::collections::BTreeMap<String, PreparedQuotaPause>;
|
||||
|
||||
/// 批量账号更新结果
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountsUpdateResult {
|
||||
@@ -293,6 +307,7 @@ pub enum AccountConnectionTestEvent {
|
||||
Failed {
|
||||
source: AccountProbeErrorSource,
|
||||
gateway_error_code: GatewayErrorKind,
|
||||
proxy_hint: Option<gateway_core::error::ProxyConnectionHint>,
|
||||
send_state: Option<UpstreamSendState>,
|
||||
message: String,
|
||||
provider_error_code: Option<String>,
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum MutationAuditOperation {
|
||||
AccountGroupEnabled { enabled: bool },
|
||||
AccountGroupDelete,
|
||||
ClientApiKeyResetBudget,
|
||||
ClientApiKeySetBudgetWindow,
|
||||
ProviderAccountUpdate,
|
||||
BackupStorageUpdate,
|
||||
BackupScheduleUpdate,
|
||||
@@ -76,6 +77,7 @@ impl MutationAuditOperation {
|
||||
}
|
||||
Self::AccountGroupDelete => ("delete", "account_group"),
|
||||
Self::ClientApiKeyResetBudget => ("reset_budget", "client_api_key"),
|
||||
Self::ClientApiKeySetBudgetWindow => ("set_budget_window", "client_api_key"),
|
||||
Self::ProviderAccountUpdate => ("update", "provider_account"),
|
||||
Self::BackupStorageUpdate => ("backup.s3_config_updated", "backup_settings"),
|
||||
Self::BackupScheduleUpdate => ("backup.schedule_updated", "backup_settings"),
|
||||
|
||||
@@ -233,13 +233,56 @@ pub enum ClientKeyBudgetPeriod {
|
||||
All,
|
||||
}
|
||||
|
||||
/// 清零所选窗口已用金额,保留限额、到期时间与历史费用
|
||||
/// 清零所选窗口并恢复日历规则,保留限额与历史费用
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResetClientKeyBudget {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetPeriod,
|
||||
}
|
||||
|
||||
/// 显式窗口复用已有日/周金额上限,外部周期的选择归调用方
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ClientKeyBudgetWindowPeriod {
|
||||
Daily,
|
||||
Weekly,
|
||||
}
|
||||
|
||||
impl ClientKeyBudgetWindowPeriod {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daily => "daily",
|
||||
Self::Weekly => "weekly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 按请求完成时间归属的左闭右开预算窗口
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExplicitClientKeyBudgetWindow {
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// 窗口配置版本独立于费用累计与全局配置版本
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClientKeyBudgetWindow {
|
||||
pub revision: u64,
|
||||
pub explicit: bool,
|
||||
pub starts_at: Option<DateTime<Utc>>,
|
||||
pub ends_at: Option<DateTime<Utc>>,
|
||||
pub used_usd: gateway_core::metering::Decimal,
|
||||
}
|
||||
|
||||
/// 当前窗口版本标识一次操作,同版本同参数可重试;None 释放显式控制
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetClientKeyBudgetWindow {
|
||||
pub id: ClientApiKeyId,
|
||||
pub period: ClientKeyBudgetWindowPeriod,
|
||||
pub expected_revision: u64,
|
||||
pub window: Option<ExplicitClientKeyBudgetWindow>,
|
||||
}
|
||||
|
||||
/// 仅更新指定金额上限;省略的周期保持不变,不修改已用金额或窗口
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateClientKeyBudgetLimits {
|
||||
|
||||
@@ -690,11 +690,12 @@ pub struct RequestMetricPoint {
|
||||
pub costs: Vec<CurrencyCost>,
|
||||
}
|
||||
|
||||
/// 账号池的统一五态统计
|
||||
/// 账号池的状态统计
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AccountPoolMetrics {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
@@ -804,10 +805,33 @@ pub struct DashboardObservation {
|
||||
pub recent_requests: Vec<UsageListRecord>,
|
||||
}
|
||||
|
||||
/// 根据已记录事实推导的错误位置,不表示责任归属
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UsageErrorOrigin {
|
||||
Proxy,
|
||||
Upstream,
|
||||
Connection,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl UsageErrorOrigin {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Proxy => "proxy",
|
||||
Self::Upstream => "upstream",
|
||||
Self::Connection => "connection",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用记录表格的窄读模型
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UsageListRecord {
|
||||
pub outcome: RequestOutcome,
|
||||
/// 已保存的上游错误对象含非空字符串 code、type 或 message
|
||||
pub has_upstream_error: bool,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
@@ -816,6 +840,7 @@ pub struct UsageListRecord {
|
||||
pub upstream_connection_exit_reason: Option<String>,
|
||||
pub recovered: bool,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub client_api_key_name: Option<String>,
|
||||
pub id: String,
|
||||
pub endpoint: String,
|
||||
@@ -1002,7 +1027,8 @@ pub struct UsageAttempt {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_send_state: Option<String>,
|
||||
pub outcome: RequestOutcome,
|
||||
pub downstream_committed: bool,
|
||||
/// 未记录尝试交付边界时保持未知,不以 false 代替
|
||||
pub downstream_committed: Option<bool>,
|
||||
pub status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub failure_kind: Option<String>,
|
||||
|
||||
@@ -845,6 +845,7 @@ pub struct ProviderQuotaReadRequest<'a> {
|
||||
/// Provider 已解析的 quota 结果及其不透明差异字段
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct ProviderQuota {
|
||||
pub paused_windows: Vec<String>,
|
||||
pub upstream: Option<super::upstream_info::ProviderUpstreamInfo>,
|
||||
/// 上游额度响应明确提供的套餐,可用于补全账号展示
|
||||
pub plan_type: Option<String>,
|
||||
@@ -852,7 +853,7 @@ pub struct ProviderQuota {
|
||||
pub refresh_token_expires_at: Option<DateTime<Utc>>,
|
||||
pub windows: Vec<ProviderQuotaWindow>,
|
||||
pub credits: Option<ProviderQuotaCredits>,
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号五态派生
|
||||
/// 展示用快照级触顶事实(顶层或任一窗口触顶);不参与账号状态派生
|
||||
pub limit_reached: bool,
|
||||
pub provider_data: Option<ProviderDocument>,
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ pub struct PendingResetCredit {
|
||||
pub struct ResetCreditAutomationState {
|
||||
pub next_discovery_at: DateTime<Utc>,
|
||||
pub credits: Vec<PlannedResetCredit>,
|
||||
pub next_weekly_check_at: Option<DateTime<Utc>>,
|
||||
/// 同一次低额度只消费一次,等待消费后的恢复观测再允许提前用卡
|
||||
pub awaiting_weekly_recovery_since: Option<DateTime<Utc>>,
|
||||
pub pending: Option<PendingResetCredit>,
|
||||
pub retry_at: Option<DateTime<Utc>>,
|
||||
pub failures: u32,
|
||||
@@ -39,6 +42,7 @@ pub struct ResetCreditAutomation {
|
||||
pub struct ResetCreditAutomationStatus {
|
||||
pub enabled: bool,
|
||||
pub lead_minutes: u32,
|
||||
pub weekly_trigger_remaining_percent: Option<u32>,
|
||||
pub next_discovery_at: Option<DateTime<Utc>>,
|
||||
pub next_consume_at: Option<DateTime<Utc>>,
|
||||
pub last_result: Option<String>,
|
||||
|
||||
@@ -70,8 +70,10 @@ pub struct RuntimeSettingsValues {
|
||||
pub audit_retention_days: u32,
|
||||
pub account_error_protection: gateway_core::account::AccountErrorProtection,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_cron: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reset_credit_on_low_weekly_quota: bool,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
@@ -233,7 +235,7 @@ impl RuntimeSettingsValues {
|
||||
self.account_error_protection.validate()?;
|
||||
gateway_core::provider_ports::ProviderWarmupPolicy::validate_values(
|
||||
self.account_warmup_enabled,
|
||||
&self.account_warmup_schedule_time,
|
||||
&self.account_warmup_cron,
|
||||
self.account_warmup_model.as_deref(),
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -5,13 +5,28 @@ use gateway_core::{engine::budget::ClientBudgetStatus, policy::ClientApiKeyId};
|
||||
|
||||
use crate::model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{ResetClientKeyBudget, UpdateClientKeyBudgetLimits},
|
||||
client_keys::{
|
||||
ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod, ResetClientKeyBudget,
|
||||
SetClientKeyBudgetWindow, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{PluginClientKeyFacts, PluginClientKeyListQuery, PluginClientKeyPage},
|
||||
plugin_resources::PluginResourceOwner,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait PluginClientKeyAccess: Send + Sync {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError>;
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
context: &MutationContext,
|
||||
) -> Result<u64, AdminError>;
|
||||
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError>;
|
||||
|
||||
async fn budget(&self, id: &ClientApiKeyId) -> Result<ClientBudgetStatus, AdminError>;
|
||||
|
||||
@@ -5,13 +5,20 @@ use gateway_core::account::{OutboundProxy, ProviderAccountId};
|
||||
|
||||
use super::store::AdminStoreResult;
|
||||
use crate::model::{
|
||||
MutationContext, Revision,
|
||||
AdminError, MutationContext, Revision,
|
||||
proxies::{
|
||||
ImportProxyBinding, NewProxy, ProxyAccountListQuery, ProxyAccountPage, ProxyListQuery,
|
||||
ProxyMutation, ProxyPage, ProxyRecord, ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
|
||||
/// 插件受管网络读取代理的窄端口,连接配置仅供宿主发送请求
|
||||
#[async_trait]
|
||||
pub trait PluginProxyAccess: Send + Sync {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError>;
|
||||
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxyStore: Send + Sync {
|
||||
/// 在凭据交换到提交期间保护选定代理的连接配置和测试结果
|
||||
|
||||
@@ -22,8 +22,9 @@ use crate::model::{
|
||||
},
|
||||
auth::{AdminAuditEvent, AuthSession},
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyListQuery, ClientKeyPage, ClientKeyRecord,
|
||||
ClientKeySecret, DeleteClientKey, NewClientKey, ResetClientKeyBudget, SetClientKeyEnabled,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyListQuery, ClientKeyPage, ClientKeyRecord, ClientKeySecret, DeleteClientKey,
|
||||
NewClientKey, ResetClientKeyBudget, SetClientKeyBudgetWindow, SetClientKeyEnabled,
|
||||
UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
observability::{
|
||||
@@ -254,6 +255,7 @@ pub trait AccountStore:
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
pauses: crate::model::accounts::PreparedQuotaPauses,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult>;
|
||||
|
||||
@@ -365,6 +367,19 @@ pub trait AuthStore: Send + Sync {
|
||||
/// Client API Key 资料读取与管理写入
|
||||
#[async_trait]
|
||||
pub trait ClientKeyStore: Send + Sync {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
id: &gateway_core::policy::ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> AdminStoreResult<ClientKeyBudgetWindow>;
|
||||
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<u64>;
|
||||
|
||||
/// 按已验证的 ID 读取资料,不读取完整明文 Key
|
||||
async fn get_client_key(
|
||||
&self,
|
||||
|
||||
@@ -759,6 +759,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
self.accounts
|
||||
.batch_update_accounts(
|
||||
BatchUpdateAccounts {
|
||||
quota_limits: None,
|
||||
auto_use_quota_credits: None,
|
||||
account_ids: vec![account_id.to_string()],
|
||||
enabled: Some(true),
|
||||
@@ -768,6 +769,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
group_ids: None,
|
||||
outbound_proxy: None,
|
||||
},
|
||||
Default::default(),
|
||||
context,
|
||||
)
|
||||
.await
|
||||
@@ -841,8 +843,52 @@ impl AccountsService for DefaultAccountsService {
|
||||
Vec<ProviderAccountId>,
|
||||
),
|
||||
>::new();
|
||||
let mut pauses = BTreeMap::new();
|
||||
for account_id in &account_ids {
|
||||
let (item, provider) = self.provider_for_account(account_id).await?;
|
||||
let (mut item, provider) = self.provider_for_account(account_id).await?;
|
||||
if let Some(limits) = &command.quota_limits {
|
||||
if !provider
|
||||
.account_capabilities(account_id, &item.account.authentication_kind)
|
||||
.quota_limits_supported
|
||||
{
|
||||
return Err(AdminError::invalid("该账号不支持通用额度窗口上限"));
|
||||
}
|
||||
let new_keys = limits
|
||||
.keys()
|
||||
.filter(|key| !item.account.quota_limits.contains(key))
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
if !limits.is_empty() {
|
||||
item.account.quota_limits = limits.clone();
|
||||
let quota = provider
|
||||
.read_quotas(&[ProviderQuotaReadRequest {
|
||||
account: &item.account,
|
||||
rolling_usage: None,
|
||||
}])
|
||||
.await
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| AdminError::invalid("缺少账号额度观测结果"))?
|
||||
.map_err(|error| map_provider_error(error, "provider quota"))?;
|
||||
if new_keys.iter().any(|key| {
|
||||
!quota.windows.iter().any(|window| {
|
||||
window.key == *key
|
||||
&& window.local_usage_attribution
|
||||
== QuotaLocalUsageAttribution::AccountWide
|
||||
})
|
||||
}) {
|
||||
return Err(AdminError::invalid("只能设置已观测的通用额度窗口"));
|
||||
}
|
||||
pauses.insert(
|
||||
item.account.id.clone(),
|
||||
crate::model::accounts::PreparedQuotaPause {
|
||||
revision: item.account.credential_revision,
|
||||
observed_at: quota.observed_at,
|
||||
paused: !quota.paused_windows.is_empty(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if command.auto_use_quota_credits.is_some()
|
||||
&& !provider
|
||||
.account_capabilities(account_id, &item.account.authentication_kind)
|
||||
@@ -864,7 +910,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let enabled = command.enabled;
|
||||
let result = self
|
||||
.accounts
|
||||
.batch_update_accounts(command, context)
|
||||
.batch_update_accounts(command, pauses, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "provider accounts"))?;
|
||||
for (provider, provider_ids, _) in providers.values() {
|
||||
@@ -1214,6 +1260,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
vec![AccountConnectionTestEvent::Failed {
|
||||
source: error.source(),
|
||||
gateway_error_code: error.kind(),
|
||||
proxy_hint: error.proxy_hint(),
|
||||
send_state: error.send_state(),
|
||||
message,
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
|
||||
@@ -2,12 +2,66 @@
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::model::{MutationActor, reset_credit_automation::*};
|
||||
use crate::model::{MutationActor, reset_credit_automation::*, settings::RuntimeSettingsValues};
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_core::time::DeploymentTimeZone;
|
||||
use rand_core::{OsRng, RngCore as _};
|
||||
use sha2::{Digest as _, Sha256};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Trigger {
|
||||
Expiry {
|
||||
lead: u32,
|
||||
max_remaining: Option<u32>,
|
||||
},
|
||||
Weekly {
|
||||
max_remaining: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl Trigger {
|
||||
fn from_settings(settings: &RuntimeSettingsValues) -> Self {
|
||||
if settings.reset_credit_on_low_weekly_quota {
|
||||
Self::Weekly {
|
||||
max_remaining: settings
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.unwrap_or(0),
|
||||
}
|
||||
} else {
|
||||
Self::Expiry {
|
||||
lead: settings.reset_credit_lead_minutes,
|
||||
max_remaining: settings.reset_credit_max_weekly_remaining_percent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn weekly_trigger(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Expiry { .. } => None,
|
||||
Self::Weekly { max_remaining } => Some(max_remaining),
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_limit(self) -> Option<u32> {
|
||||
match self {
|
||||
Self::Expiry { max_remaining, .. } => max_remaining,
|
||||
Self::Weekly { max_remaining } => Some(max_remaining),
|
||||
}
|
||||
}
|
||||
|
||||
fn eligible(self, remaining: f64, limit: u32) -> bool {
|
||||
// 周额度模式即使配置 100% 也不消耗卡片重置满额窗口
|
||||
match self {
|
||||
Self::Expiry { .. } => remaining <= f64::from(limit),
|
||||
Self::Weekly { .. } => weekly_low(remaining, limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn expiry_due(self, account_id: &str, credit: &PlannedResetCredit, now: DateTime<Utc>) -> bool {
|
||||
matches!(self, Self::Expiry { lead, .. } if consume_at(account_id, credit, lead) <= now)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultAccountsService {
|
||||
pub(super) async fn reset_credits_upstream(
|
||||
&self,
|
||||
@@ -74,7 +128,9 @@ impl DefaultAccountsService {
|
||||
account_id: &ProviderAccountId,
|
||||
) -> Result<ResetCreditAutomationStatus, AdminError> {
|
||||
self.require_reset_credit_automation(account_id).await?;
|
||||
let lead_minutes = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead_minutes = settings.reset_credit_lead_minutes;
|
||||
let weekly_trigger_remaining_percent = Trigger::from_settings(&settings).weekly_trigger();
|
||||
let record = self
|
||||
.accounts
|
||||
.reset_credit_automation(account_id.as_str())
|
||||
@@ -83,20 +139,23 @@ impl DefaultAccountsService {
|
||||
Ok(ResetCreditAutomationStatus {
|
||||
enabled: record.as_ref().is_some_and(|r| r.enabled),
|
||||
lead_minutes,
|
||||
weekly_trigger_remaining_percent,
|
||||
next_discovery_at: record
|
||||
.as_ref()
|
||||
.filter(|r| r.enabled)
|
||||
.map(|r| r.state.next_discovery_at),
|
||||
next_consume_at: record.as_ref().filter(|r| r.enabled).and_then(|r| {
|
||||
next_consumption(r, lead_minutes)
|
||||
.or_else(|| {
|
||||
r.state
|
||||
.pending
|
||||
.as_ref()
|
||||
.map(|pending| consume_at(&r.account_id, &pending.credit, lead_minutes))
|
||||
})
|
||||
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
|
||||
}),
|
||||
next_consume_at: record
|
||||
.as_ref()
|
||||
.filter(|r| r.enabled && weekly_trigger_remaining_percent.is_none())
|
||||
.and_then(|r| {
|
||||
next_consumption(r, lead_minutes)
|
||||
.or_else(|| {
|
||||
r.state.pending.as_ref().map(|pending| {
|
||||
consume_at(&r.account_id, &pending.credit, lead_minutes)
|
||||
})
|
||||
})
|
||||
.map(|due| r.state.retry_at.map_or(due, |retry| due.max(retry)))
|
||||
}),
|
||||
last_result: record.and_then(|r| {
|
||||
if r.state
|
||||
.pending
|
||||
@@ -104,6 +163,15 @@ impl DefaultAccountsService {
|
||||
.is_some_and(|pending| pending.credit.expires_at <= Utc::now())
|
||||
{
|
||||
Some("expired_unknown".to_owned())
|
||||
} else if r.enabled
|
||||
&& weekly_trigger_remaining_percent.is_some()
|
||||
&& r.state.awaiting_weekly_recovery_since.is_some()
|
||||
&& matches!(
|
||||
r.state.last_result.as_deref(),
|
||||
Some("reset" | "already_redeemed")
|
||||
)
|
||||
{
|
||||
Some("awaiting_weekly_recovery".to_owned())
|
||||
} else {
|
||||
r.state.last_result
|
||||
}
|
||||
@@ -145,6 +213,8 @@ impl DefaultAccountsService {
|
||||
revision: 0,
|
||||
next_run_at: now,
|
||||
state: ResetCreditAutomationState {
|
||||
next_weekly_check_at: None,
|
||||
awaiting_weekly_recovery_since: None,
|
||||
next_discovery_at: now,
|
||||
credits: vec![],
|
||||
pending: None,
|
||||
@@ -160,8 +230,8 @@ impl DefaultAccountsService {
|
||||
record.state.next_discovery_at =
|
||||
now + chrono::Duration::seconds(i64::from(OsRng.next_u32() % 60));
|
||||
}
|
||||
let lead = self.automation_settings().await?.reset_credit_lead_minutes;
|
||||
reschedule(&mut record, lead, now);
|
||||
let trigger = Trigger::from_settings(&self.automation_settings().await?);
|
||||
reschedule(&mut record, trigger, now);
|
||||
self.save_automation(&mut record, Some(context)).await?;
|
||||
let settings = self
|
||||
.settings
|
||||
@@ -246,7 +316,7 @@ impl DefaultAccountsService {
|
||||
self.observe_reset_credits(&mut record, credits, now)?;
|
||||
reschedule(
|
||||
&mut record,
|
||||
self.automation_settings().await?.reset_credit_lead_minutes,
|
||||
Trigger::from_settings(&self.automation_settings().await?),
|
||||
now,
|
||||
);
|
||||
self.save_automation(&mut record, None).await
|
||||
@@ -255,18 +325,16 @@ impl DefaultAccountsService {
|
||||
async fn automation_failed(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
trigger: Trigger,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(), AdminError> {
|
||||
if record.state.pending.is_none()
|
||||
&& !next_consumption(record, lead).is_some_and(|due| due <= now)
|
||||
{
|
||||
if record.state.pending.is_none() && record.state.retry_at.is_none() {
|
||||
record.state.next_discovery_at =
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("discovery_failed".to_owned());
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
return self.save_automation(record, None).await;
|
||||
}
|
||||
record.state.failures = record.state.failures.saturating_add(1);
|
||||
@@ -281,7 +349,7 @@ impl DefaultAccountsService {
|
||||
}
|
||||
.to_owned(),
|
||||
);
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
@@ -289,7 +357,7 @@ impl DefaultAccountsService {
|
||||
async fn defer_automation(
|
||||
&self,
|
||||
record: &mut ResetCreditAutomation,
|
||||
lead: u32,
|
||||
trigger: Trigger,
|
||||
reason: &str,
|
||||
) -> Result<(), AdminError> {
|
||||
let now = Utc::now();
|
||||
@@ -305,38 +373,24 @@ impl DefaultAccountsService {
|
||||
);
|
||||
record.state.last_result = Some(reason.to_owned());
|
||||
record.state.failures = 0;
|
||||
reschedule(record, lead, now);
|
||||
reschedule(record, trigger, now);
|
||||
self.save_automation(record, None).await
|
||||
}
|
||||
|
||||
async fn weekly_reset_credit_gate(
|
||||
async fn reset_credit_quota(
|
||||
&self,
|
||||
account_id: &ProviderAccountId,
|
||||
max_remaining: u32,
|
||||
) -> Result<Option<&'static str>, AdminError> {
|
||||
refresh: bool,
|
||||
) -> Result<ProviderQuota, AdminError> {
|
||||
let (_, provider) = self.provider_for_account(account_id).await?;
|
||||
let quota = provider
|
||||
provider
|
||||
.quota(ProviderQuotaRequest {
|
||||
account_id: account_id.clone(),
|
||||
refresh: true,
|
||||
refresh,
|
||||
rolling_usage: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))?;
|
||||
// 复用账号周窗口归属,不把短期、月度或模型专属桶当作剩余周额度
|
||||
let remaining = quota
|
||||
.usage_windows()
|
||||
.find(|(_, period)| *period == AccountUsagePeriod::Weekly)
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0));
|
||||
Ok(match remaining {
|
||||
Some(remaining) if remaining > f64::from(max_remaining) => {
|
||||
Some("weekly_quota_above_threshold")
|
||||
}
|
||||
Some(_) => None,
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
})
|
||||
.map_err(|error| map_provider_error(error, "reset-credit weekly quota"))
|
||||
}
|
||||
|
||||
pub(super) async fn reconcile_automation(
|
||||
@@ -368,7 +422,7 @@ impl DefaultAccountsService {
|
||||
}
|
||||
let now = Utc::now();
|
||||
let settings = self.automation_settings().await?;
|
||||
let lead = settings.reset_credit_lead_minutes;
|
||||
let trigger = Trigger::from_settings(&settings);
|
||||
record
|
||||
.state
|
||||
.credits
|
||||
@@ -382,18 +436,51 @@ impl DefaultAccountsService {
|
||||
record.state.pending = None;
|
||||
record.state.retry_at = None;
|
||||
record.state.last_result = Some("expired_unknown".to_owned());
|
||||
record.state.awaiting_weekly_recovery_since = Some(now);
|
||||
}
|
||||
if record.state.retry_at.is_some_and(|retry| retry > now) {
|
||||
reschedule(&mut record, lead, now);
|
||||
reschedule(&mut record, trigger, now);
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "reset-credit-automation".to_owned(),
|
||||
};
|
||||
let due_credit = next_consumption(&record, lead).is_some_and(|due| due <= now);
|
||||
let due_credit = record
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.any(|credit| trigger.expiry_due(account_id.as_str(), credit, now));
|
||||
let early_limit = trigger.weekly_trigger();
|
||||
// 错峰读取本地快照,等待恢复期间不因卡片用完而漏过下一轮额度
|
||||
record.state.next_weekly_check_at = early_limit
|
||||
.map(|_| now + chrono::Duration::seconds(60 + i64::from(OsRng.next_u32() % 30)));
|
||||
let mut early_due = false;
|
||||
if let Some(limit) = early_limit
|
||||
&& record.state.pending.is_none()
|
||||
&& needs_weekly_observation(&record.state)
|
||||
{
|
||||
match self.reset_credit_quota(account_id, false).await {
|
||||
Ok(quota) => {
|
||||
observe_weekly_recovery(&mut record.state, "a, limit);
|
||||
early_due = !record.state.credits.is_empty()
|
||||
&& record.state.awaiting_weekly_recovery_since.is_none()
|
||||
&& weekly_remaining("a)
|
||||
.is_some_and(|remaining| trigger.eligible(remaining, limit));
|
||||
}
|
||||
Err(error) => {
|
||||
// 额度读取失败不能阻塞独立的卡片发现计划
|
||||
tracing::warn!(
|
||||
account_id = account_id.as_str(),
|
||||
error_kind = error.kind().as_str(),
|
||||
"重置卡周额度快照读取失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if record.state.pending.is_none()
|
||||
&& (due_credit
|
||||
|| early_due
|
||||
|| record.state.next_discovery_at <= now
|
||||
|| record.state.retry_at.is_some())
|
||||
{
|
||||
@@ -403,10 +490,10 @@ impl DefaultAccountsService {
|
||||
next_discovery(now, self.timezone, OsRng.next_u64())
|
||||
.ok_or_else(|| AdminError::internal("无法计算重置卡发现时间"))?;
|
||||
}
|
||||
if due_credit || record.state.retry_at.is_some() {
|
||||
if due_credit || early_due || record.state.retry_at.is_some() {
|
||||
record.state.retry_at = Some(now + chrono::Duration::seconds(30));
|
||||
}
|
||||
reschedule(&mut record, lead, now);
|
||||
reschedule(&mut record, trigger, now);
|
||||
self.save_automation(&mut record, None).await?;
|
||||
// 真正消费前重新核对卡片;每日发现结果也可直接承担本次核对
|
||||
match self
|
||||
@@ -415,7 +502,7 @@ impl DefaultAccountsService {
|
||||
{
|
||||
Ok(credits) => self.observe_reset_credits(&mut record, &credits, Utc::now())?,
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
self.automation_failed(&mut record, trigger, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -424,24 +511,28 @@ impl DefaultAccountsService {
|
||||
.state
|
||||
.credits
|
||||
.iter()
|
||||
.find(|credit| consume_at(account_id.as_str(), credit, lead) <= Utc::now())
|
||||
.find(|credit| {
|
||||
early_due || trigger.expiry_due(account_id.as_str(), credit, Utc::now())
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
if let Some(max_remaining) = settings.reset_credit_max_weekly_remaining_percent {
|
||||
// 已发出的未决请求跳过此检查,继续沿原幂等键确认结果
|
||||
match self
|
||||
.weekly_reset_credit_gate(account_id, max_remaining)
|
||||
.await
|
||||
{
|
||||
Ok(Some(reason)) => {
|
||||
return self.defer_automation(&mut record, lead, reason).await;
|
||||
}
|
||||
Ok(None) => {}
|
||||
if let Some(limit) = trigger.quota_limit() {
|
||||
// 缓存只能唤醒检查,实际选中的卡片与额度都要重新核实
|
||||
let quota = match self.reset_credit_quota(account_id, true).await {
|
||||
Ok(quota) => quota,
|
||||
Err(error) => {
|
||||
self.defer_automation(&mut record, lead, "weekly_quota_unavailable")
|
||||
self.defer_automation(&mut record, trigger, "weekly_quota_unavailable")
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let reason = match weekly_remaining("a) {
|
||||
Some(remaining) if trigger.eligible(remaining, limit) => None,
|
||||
Some(_) => Some("weekly_quota_above_threshold"),
|
||||
None => Some("weekly_quota_unavailable"),
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return self.defer_automation(&mut record, trigger, reason).await;
|
||||
}
|
||||
}
|
||||
// 刷新额度期间卡片可能到期,不能把已过期卡片持久化为待消费
|
||||
@@ -450,7 +541,7 @@ impl DefaultAccountsService {
|
||||
.state
|
||||
.credits
|
||||
.retain(|credit| credit.expires_at > Utc::now());
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
}
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
@@ -459,13 +550,13 @@ impl DefaultAccountsService {
|
||||
});
|
||||
record.state.last_result = Some("pending".to_owned());
|
||||
record.state.retry_at = Some(Utc::now() + chrono::Duration::seconds(30));
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
// 必须在发送消费请求前落盘,进程退出或响应丢失继续使用相同幂等键
|
||||
self.save_automation(&mut record, None).await?;
|
||||
}
|
||||
}
|
||||
let Some(pending) = record.state.pending.clone() else {
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
return self.save_automation(&mut record, None).await;
|
||||
};
|
||||
let command = ConsumeProviderResetCredit {
|
||||
@@ -478,9 +569,12 @@ impl DefaultAccountsService {
|
||||
Ok(result) => {
|
||||
let confirmed = matches!(result.code.as_str(), "reset" | "already_redeemed");
|
||||
record.state.pending = None;
|
||||
if confirmed {
|
||||
record.state.awaiting_weekly_recovery_since = Some(Utc::now());
|
||||
}
|
||||
if result.code == "nothing_to_reset" {
|
||||
return self
|
||||
.defer_automation(&mut record, lead, "nothing_to_reset")
|
||||
.defer_automation(&mut record, trigger, "nothing_to_reset")
|
||||
.await;
|
||||
}
|
||||
record
|
||||
@@ -494,15 +588,20 @@ impl DefaultAccountsService {
|
||||
} else {
|
||||
"not_redeemed".to_owned()
|
||||
});
|
||||
reschedule(&mut record, lead, Utc::now());
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
// 已确认的结果先落盘,再刷新额度;观测失败不能重放已完成的消费
|
||||
self.save_automation(&mut record, None).await?;
|
||||
if confirmed {
|
||||
let _ = self.quota(account_id, true).await;
|
||||
if confirmed
|
||||
&& let Ok(account) = self.quota(account_id, true).await
|
||||
&& let Some(limit) = early_limit
|
||||
&& observe_weekly_recovery(&mut record.state, &account.quota, limit)
|
||||
{
|
||||
reschedule(&mut record, trigger, Utc::now());
|
||||
self.save_automation(&mut record, None).await?;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.automation_failed(&mut record, lead, Utc::now())
|
||||
self.automation_failed(&mut record, trigger, Utc::now())
|
||||
.await?;
|
||||
return Err(error);
|
||||
}
|
||||
@@ -511,6 +610,42 @@ impl DefaultAccountsService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 复用账号周窗口归属,忽略短期、模型专属和过期窗口
|
||||
fn weekly_remaining(quota: &ProviderQuota) -> Option<f64> {
|
||||
quota
|
||||
.usage_windows()
|
||||
.find(|(window, period)| {
|
||||
*period == AccountUsagePeriod::Weekly
|
||||
&& window.reset_at.is_some_and(|end| end > Utc::now())
|
||||
})
|
||||
.and_then(|(window, _)| window.used_percent)
|
||||
.filter(|used| used.is_finite())
|
||||
.map(|used| 100.0 - used.clamp(0.0, 100.0))
|
||||
}
|
||||
|
||||
fn weekly_low(remaining: f64, limit: u32) -> bool {
|
||||
remaining < 100.0 && remaining <= f64::from(limit)
|
||||
}
|
||||
|
||||
fn needs_weekly_observation(state: &ResetCreditAutomationState) -> bool {
|
||||
!state.credits.is_empty() || state.awaiting_weekly_recovery_since.is_some()
|
||||
}
|
||||
|
||||
fn observe_weekly_recovery(
|
||||
state: &mut ResetCreditAutomationState,
|
||||
quota: &ProviderQuota,
|
||||
limit: u32,
|
||||
) -> bool {
|
||||
if let Some(since) = state.awaiting_weekly_recovery_since
|
||||
&& quota.observed_at.is_some_and(|at| at > since)
|
||||
&& weekly_remaining(quota).is_some_and(|remaining| !weekly_low(remaining, limit))
|
||||
{
|
||||
state.awaiting_weekly_recovery_since = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 当地次日随机发现时间;随机数由调用者提供,结果持久化后不会因重启重抽
|
||||
fn next_discovery(
|
||||
now: DateTime<Utc>,
|
||||
@@ -550,14 +685,21 @@ fn next_consumption(record: &ResetCreditAutomation, lead_minutes: u32) -> Option
|
||||
.min()
|
||||
}
|
||||
|
||||
fn reschedule(record: &mut ResetCreditAutomation, lead_minutes: u32, now: DateTime<Utc>) {
|
||||
fn reschedule(record: &mut ResetCreditAutomation, trigger: Trigger, now: DateTime<Utc>) {
|
||||
record.next_run_at = if let Some(retry) = record.state.retry_at {
|
||||
retry
|
||||
} else if record.state.pending.is_some() {
|
||||
now
|
||||
} else {
|
||||
next_consumption(record, lead_minutes).map_or(record.state.next_discovery_at, |due| {
|
||||
due.min(record.state.next_discovery_at)
|
||||
let scheduled = match trigger {
|
||||
Trigger::Expiry { lead, .. } => next_consumption(record, lead),
|
||||
Trigger::Weekly { .. } if needs_weekly_observation(&record.state) => {
|
||||
Some(record.state.next_weekly_check_at.unwrap_or(now))
|
||||
}
|
||||
Trigger::Weekly { .. } => None,
|
||||
};
|
||||
scheduled.map_or(record.state.next_discovery_at, |at| {
|
||||
at.min(record.state.next_discovery_at)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursorValue, ClientKeyListQuery,
|
||||
ClientKeyMutation, ClientKeyPage, ClientKeyRecord, ClientKeySecret, ClientKeySortField,
|
||||
CreateClientKey, CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyEnabled, UpdateClientKey, UpdateClientKeyBudgetLimits,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyCursorValue, ClientKeyListQuery, ClientKeyMutation, ClientKeyPage,
|
||||
ClientKeyRecord, ClientKeySecret, ClientKeySortField, CreateClientKey,
|
||||
CreatedClientKey, DeleteClientKey, NewClientKey, ResetClientKeyBudget,
|
||||
SetClientKeyBudgetWindow, SetClientKeyEnabled, UpdateClientKey,
|
||||
UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
},
|
||||
ports::store::{AdminStoreError, AdminStoreErrorKind, ClientKeyStore},
|
||||
@@ -28,6 +30,18 @@ use super::{map_store_error, publish_committed};
|
||||
/// API 消费的 Client Key 管理服务
|
||||
#[async_trait]
|
||||
pub trait ClientKeyService: Send + Sync {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError>;
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<u64, AdminError>;
|
||||
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError>;
|
||||
async fn list(&self, query: ClientKeyListQuery) -> Result<ClientKeyPage, AdminError>;
|
||||
async fn reveal(&self, id: &ClientApiKeyId) -> Result<ClientKeySecret, AdminError>;
|
||||
@@ -89,6 +103,35 @@ impl DefaultClientKeyService {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyService for DefaultClientKeyService {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError> {
|
||||
self.store
|
||||
.client_key_budget_window(id, period)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))
|
||||
}
|
||||
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
origin: ClientKeyBudgetMutationOrigin,
|
||||
) -> Result<u64, AdminError> {
|
||||
if command
|
||||
.window
|
||||
.is_some_and(|window| window.starts_at >= window.ends_at)
|
||||
{
|
||||
return Err(AdminError::invalid("预算窗口结束时间必须晚于开始时间"));
|
||||
}
|
||||
self.store
|
||||
.set_client_key_budget_window(command, origin, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "client API key"))
|
||||
}
|
||||
|
||||
async fn get(&self, id: &ClientApiKeyId) -> Result<ClientKeyRecord, AdminError> {
|
||||
self.store
|
||||
.get_client_key(id)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! 从已保存的错误事实投影管理端来源,不参与执行错误分类或重试
|
||||
|
||||
use crate::model::observability::{UsageErrorOrigin, UsageListRecord, UsageRecord};
|
||||
|
||||
impl UsageListRecord {
|
||||
#[must_use]
|
||||
pub fn error_origin(&self) -> UsageErrorOrigin {
|
||||
error_origin(
|
||||
self.has_upstream_error,
|
||||
self.error_code.as_deref(),
|
||||
self.websocket_close_code,
|
||||
self.upstream_status_code,
|
||||
self.error_kind.as_deref(),
|
||||
self.continuation_unavailable_reason.as_deref(),
|
||||
self.upstream_connection_exit_reason.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageRecord {
|
||||
#[must_use]
|
||||
pub fn error_origin(&self) -> UsageErrorOrigin {
|
||||
let has_upstream_error = self.upstream_error.as_ref().is_some_and(|error| {
|
||||
[&error.message, &error.code, &error.error_type]
|
||||
.into_iter()
|
||||
.any(|value| value.as_ref().is_some_and(|value| !value.is_empty()))
|
||||
});
|
||||
error_origin(
|
||||
has_upstream_error,
|
||||
self.provider_error_code.as_deref(),
|
||||
self.websocket_close_code,
|
||||
self.upstream_status_code,
|
||||
self.error_kind.as_deref(),
|
||||
self.continuation_unavailable_reason.as_deref(),
|
||||
self.upstream_connection_exit_reason.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游原始事实优先;客户端 HTTP 状态和任意错误文案不能证明错误来源
|
||||
fn error_origin(
|
||||
has_upstream_error: bool,
|
||||
upstream_code: Option<&str>,
|
||||
websocket_close_code: Option<u16>,
|
||||
upstream_status_code: Option<u16>,
|
||||
error_kind: Option<&str>,
|
||||
continuation_reason: Option<&str>,
|
||||
connection_exit_reason: Option<&str>,
|
||||
) -> UsageErrorOrigin {
|
||||
if has_upstream_error
|
||||
|| upstream_code.is_some_and(|code| !code.is_empty())
|
||||
|| websocket_close_code.is_some()
|
||||
|| upstream_status_code.is_some_and(|status| status >= 400)
|
||||
|| matches!(
|
||||
continuation_reason,
|
||||
Some("upstream_rejected" | "quota_exhausted")
|
||||
)
|
||||
|| matches!(
|
||||
error_kind,
|
||||
// 这里匹配内部 errorKind;message_too_big 只由收到的上游 close 1009 产生
|
||||
Some("permission_denied" | "message_too_big" | "upstream_capacity_unavailable")
|
||||
)
|
||||
{
|
||||
return UsageErrorOrigin::Upstream;
|
||||
}
|
||||
if matches!(
|
||||
continuation_reason,
|
||||
Some(
|
||||
"scope_unavailable"
|
||||
| "pool_unavailable"
|
||||
| "fresh_connection_required"
|
||||
| "connection_busy"
|
||||
| "latest_response_mismatch"
|
||||
| "reused_connection_lost"
|
||||
| "transport_payload_too_large"
|
||||
)
|
||||
) || matches!(
|
||||
error_kind,
|
||||
Some(
|
||||
"process_interrupted"
|
||||
| "process_terminated"
|
||||
| "internal_error"
|
||||
| "model_not_found"
|
||||
| "no_available_provider"
|
||||
| "no_eligible_account"
|
||||
| "account_capacity_unavailable"
|
||||
| "middleware_capacity_unavailable"
|
||||
| "concurrency_queue_full"
|
||||
| "concurrency_queue_timeout"
|
||||
| "provider_infrastructure_unavailable"
|
||||
| "session_policy_restricted"
|
||||
| "request_policy_denied"
|
||||
| "policy_denied"
|
||||
| "budget_exhausted"
|
||||
)
|
||||
) || matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"command_channel_closed"
|
||||
| "local_close"
|
||||
| "message_receiver_closed"
|
||||
| "client_cancelled"
|
||||
| "downstream_send_failed"
|
||||
| "downstream_connection_closed"
|
||||
| "downstream_rebound"
|
||||
| "pool_shutdown"
|
||||
| "max_age_expired"
|
||||
| "idle_timeout_expired"
|
||||
| "proxy_capacity_evicted"
|
||||
| "search_activation_required"
|
||||
)
|
||||
) {
|
||||
return UsageErrorOrigin::Proxy;
|
||||
}
|
||||
if matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"upstream_close"
|
||||
| "normal_close"
|
||||
| "unexpected_binary_event"
|
||||
| "incomplete_response"
|
||||
| "failed_response"
|
||||
)
|
||||
) {
|
||||
return UsageErrorOrigin::Upstream;
|
||||
}
|
||||
if error_kind == Some("transport")
|
||||
|| matches!(
|
||||
connection_exit_reason,
|
||||
Some(
|
||||
"eof"
|
||||
| "unexpected_eof"
|
||||
| "transport_error"
|
||||
| "connection_reset"
|
||||
| "connection_aborted"
|
||||
| "broken_pipe"
|
||||
| "tcp_reset"
|
||||
| "reset_without_closing_handshake"
|
||||
| "transport_timeout"
|
||||
| "outbound_transport_error"
|
||||
| "receive_idle_timeout"
|
||||
| "pong_timeout"
|
||||
| "ping_write_timeout"
|
||||
| "pong_write_timeout"
|
||||
)
|
||||
)
|
||||
{
|
||||
return UsageErrorOrigin::Connection;
|
||||
}
|
||||
// invalid_request、protocol、rate_limited 等分类同时用于本地和上游失败
|
||||
// 发送状态与笼统的连接退出摘要也不能替代缺失的来源事实
|
||||
UsageErrorOrigin::Unknown
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const HEALTH_TIMELINE_SLOT_MINUTES: i64 = 15;
|
||||
const HEALTH_TIMELINE_MIN_SAMPLE_SIZE: u64 = 10;
|
||||
const HEALTH_TIMELINE_UNAVAILABLE_FAILURE_THRESHOLD: u64 = 3;
|
||||
const HEALTH_TIMELINE_STABLE_RELIABILITY: f64 = 95.0;
|
||||
mod errors;
|
||||
mod export;
|
||||
mod store_cache;
|
||||
pub(crate) use store_cache::CachedObservabilityStore;
|
||||
|
||||
@@ -8,7 +8,9 @@ use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use gateway_core::{engine::execution::AuthenticatedClient, policy::ClientPeerSharing};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId, engine::execution::AuthenticatedClient, policy::ClientPeerSharing,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use crate::{
|
||||
@@ -113,10 +115,7 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
let mut result = PeerLinkExport {
|
||||
observed_at: now,
|
||||
expires_at: now + TimeDelta::minutes(5),
|
||||
budget_access: key
|
||||
.budget
|
||||
.limits
|
||||
.access(key.budget.daily_used_usd, key.budget.weekly_used_usd),
|
||||
budget_access: key.budget.access_at(budget_observed_at.into()),
|
||||
key_limits: key.limits,
|
||||
budget: key.budget,
|
||||
budget_observed_at,
|
||||
@@ -154,16 +153,37 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
.filter(|group| group.enabled)
|
||||
.map(|group| group.id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let scope = client.policy().account_scope();
|
||||
let direct_ids = ids
|
||||
.iter()
|
||||
.filter(|id| {
|
||||
scope
|
||||
.directory()
|
||||
.account(id)
|
||||
.is_some_and(|account| account.is_direct_account())
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let accounts = self
|
||||
.accounts
|
||||
.load_accounts_in_enabled_groups(&ids, &groups)
|
||||
.load_accounts_in_enabled_groups(&direct_ids, &groups)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "peer accounts"))?
|
||||
.into_iter()
|
||||
.filter(|account| {
|
||||
self.providers
|
||||
.require(&account.provider_kind)
|
||||
.is_ok_and(|provider| provider.is_direct_account(&account.authentication_kind))
|
||||
ProviderAccountId::new(account.id.clone())
|
||||
.ok()
|
||||
.and_then(|id| scope.directory().account(&id))
|
||||
.is_some_and(|frozen| {
|
||||
frozen.direct_authentication_kind()
|
||||
== Some(account.authentication_kind.as_str())
|
||||
})
|
||||
&& self
|
||||
.providers
|
||||
.require(&account.provider_kind)
|
||||
.is_ok_and(|provider| {
|
||||
provider.is_direct_account(&account.authentication_kind)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let account_ids = accounts
|
||||
@@ -232,26 +252,18 @@ impl PeerLinkService for DefaultPeerLinkService {
|
||||
));
|
||||
}
|
||||
if sharing.share_quota {
|
||||
let (quota, mut models) = projection::quota_and_models(
|
||||
let (quota, models) = projection::quota_and_models(
|
||||
client,
|
||||
&accounts,
|
||||
"as,
|
||||
&self.providers,
|
||||
&runtime,
|
||||
&public_models,
|
||||
&ids,
|
||||
now,
|
||||
limits.max_response_bytes,
|
||||
)
|
||||
.await?;
|
||||
// 发布窗口内缺失或已变更的账号不能被静默剔除后推导整个模型池耗尽
|
||||
if accounts.len() != ids.len() {
|
||||
for model in &mut models {
|
||||
model.complete = false;
|
||||
if model.state == crate::model::peer_link::PeerModelState::Exhausted {
|
||||
model.state = crate::model::peer_link::PeerModelState::Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.quota = Some(quota);
|
||||
result.models = models;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 从已授权的本地账号事实派生公开汇总,保留原始观测时间
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, TimeDelta, Utc};
|
||||
use gateway_core::{
|
||||
@@ -38,7 +38,7 @@ pub(super) fn pool_summary(
|
||||
for account in accounts {
|
||||
match status(account, runtime, now) {
|
||||
AccountStatus::Normal => summary.ready += 1,
|
||||
AccountStatus::RateLimited => summary.cooldown += 1,
|
||||
AccountStatus::QuotaPaused | AccountStatus::RateLimited => summary.cooldown += 1,
|
||||
AccountStatus::QuotaExhausted => summary.exhausted += 1,
|
||||
AccountStatus::Disabled => summary.disabled += 1,
|
||||
AccountStatus::Error => summary.error += 1,
|
||||
@@ -67,6 +67,7 @@ pub(super) async fn quota_and_models(
|
||||
providers: &ProviderAdminRegistry,
|
||||
runtime: &AccountRuntimeSnapshot,
|
||||
models: &[gateway_core::routing::PublicModelId],
|
||||
authorized: &[ProviderAccountId],
|
||||
now: DateTime<Utc>,
|
||||
max_response_bytes: usize,
|
||||
) -> Result<(Vec<PeerAccountQuota>, Vec<PeerModelSummary>), super::PeerLinkError> {
|
||||
@@ -151,6 +152,28 @@ pub(super) async fn quota_and_models(
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
// 转供账号及发布窗口内消失的账号没有本地观测,只影响它们获准执行的模型
|
||||
if accounts.len() != authorized.len() {
|
||||
let exported = accounts
|
||||
.iter()
|
||||
.map(|account| account.id.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let missing = authorized
|
||||
.iter()
|
||||
.filter(|id| !exported.contains(id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
for (index, model) in models.iter().enumerate() {
|
||||
if index % 16 == 0 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
if missing
|
||||
.iter()
|
||||
.any(|id| snapshot.allows_public_model_for_account(model, scope, id))
|
||||
{
|
||||
observations.entry(model.as_str()).or_default().incomplete = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let summaries = models
|
||||
.iter()
|
||||
.map(|model| {
|
||||
@@ -248,6 +271,7 @@ fn status(
|
||||
) -> AccountStatus {
|
||||
resolve_account_status(
|
||||
&AccountStatusFacts {
|
||||
quota_paused: account.quota_paused,
|
||||
enabled: account.enabled,
|
||||
credential_state: account.credential_state,
|
||||
access_token_expires_at: account.access_token_expires_at.map(Into::into),
|
||||
|
||||
@@ -214,6 +214,7 @@ impl PeerInfoService {
|
||||
}
|
||||
}
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: Some(info),
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
|
||||
@@ -170,6 +170,7 @@ impl PeerInfoService {
|
||||
.and_then(|peer| quota::observe(&peer, started_at, now))
|
||||
.unwrap_or(QuotaState::unknown());
|
||||
let observation = QuotaObservation {
|
||||
quota_pause: None,
|
||||
expected_connection: Some(connection.clone()),
|
||||
account_id: id.clone(),
|
||||
expected_revision: connection.credential_revision,
|
||||
|
||||
@@ -9,9 +9,10 @@ use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext,
|
||||
client_keys::{
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyCursor, ClientKeyCursorValue,
|
||||
ClientKeyListQuery, ClientKeyPageSize, ClientKeySort, ClientKeySortField,
|
||||
ResetClientKeyBudget, SortDirection, UpdateClientKeyBudgetLimits,
|
||||
ClientKeyBudgetMutationOrigin, ClientKeyBudgetWindow, ClientKeyBudgetWindowPeriod,
|
||||
ClientKeyCursor, ClientKeyCursorValue, ClientKeyListQuery, ClientKeyPageSize,
|
||||
ClientKeySort, ClientKeySortField, ResetClientKeyBudget, SetClientKeyBudgetWindow,
|
||||
SortDirection, UpdateClientKeyBudgetLimits,
|
||||
},
|
||||
plugin_client_keys::{
|
||||
PluginClientKey, PluginClientKeyCursor, PluginClientKeyFacts, PluginClientKeyListQuery,
|
||||
@@ -35,6 +36,29 @@ impl DefaultPluginClientKeyAccess {
|
||||
|
||||
#[async_trait]
|
||||
impl PluginClientKeyAccess for DefaultPluginClientKeyAccess {
|
||||
async fn budget_window(
|
||||
&self,
|
||||
id: &ClientApiKeyId,
|
||||
period: ClientKeyBudgetWindowPeriod,
|
||||
) -> Result<ClientKeyBudgetWindow, AdminError> {
|
||||
self.service.budget_window(id, period).await
|
||||
}
|
||||
|
||||
async fn set_budget_window(
|
||||
&self,
|
||||
owner: &PluginResourceOwner,
|
||||
command: SetClientKeyBudgetWindow,
|
||||
context: &MutationContext,
|
||||
) -> Result<u64, AdminError> {
|
||||
self.service
|
||||
.set_budget_window(
|
||||
context,
|
||||
command,
|
||||
ClientKeyBudgetMutationOrigin::Plugin(owner.clone()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn facts(&self, id: &ClientApiKeyId) -> Result<PluginClientKeyFacts, AdminError> {
|
||||
let key = self.service.get(id).await?;
|
||||
Ok(PluginClientKeyFacts {
|
||||
|
||||
@@ -365,7 +365,7 @@ fn current_platform() -> Option<Platform> {
|
||||
fn target_environment_matches(target: &str) -> bool {
|
||||
match target {
|
||||
"x86_64-unknown-linux-gnu" | "aarch64-unknown-linux-gnu" => cfg!(target_env = "gnu"),
|
||||
"aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
"x86_64-apple-darwin" | "aarch64-apple-darwin" => cfg!(target_os = "macos"),
|
||||
"x86_64-pc-windows-msvc" | "aarch64-pc-windows-msvc" => cfg!(target_env = "msvc"),
|
||||
_ => false,
|
||||
}
|
||||
@@ -386,6 +386,13 @@ const SUPPORTED_PLATFORMS: &[Platform] = &[
|
||||
runtime_os: "linux",
|
||||
runtime_architecture: "aarch64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "darwin",
|
||||
release_architecture: "amd64",
|
||||
target: "x86_64-apple-darwin",
|
||||
runtime_os: "macos",
|
||||
runtime_architecture: "x86_64",
|
||||
},
|
||||
Platform {
|
||||
release_os: "darwin",
|
||||
release_architecture: "arm64",
|
||||
|
||||
@@ -23,11 +23,51 @@ use crate::{
|
||||
},
|
||||
ports::{
|
||||
provider::ProviderAdminRegistry,
|
||||
proxy::{ProxyProbe, ProxyStore},
|
||||
proxy::{PluginProxyAccess, ProxyProbe, ProxyStore},
|
||||
store::AdminStoreErrorKind,
|
||||
},
|
||||
};
|
||||
|
||||
pub(crate) struct DefaultPluginProxyAccess {
|
||||
pub(crate) store: Arc<dyn ProxyStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PluginProxyAccess for DefaultPluginProxyAccess {
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
list_proxies(self.store.as_ref(), query).await
|
||||
}
|
||||
|
||||
async fn resolve(&self, id: &str) -> Result<OutboundProxy, AdminError> {
|
||||
if id.is_empty() || id.len() > 128 || id.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理 ID 不合法"));
|
||||
}
|
||||
let record = self
|
||||
.store
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))?;
|
||||
// 显式选择代理时必须有可用配置,不能沿用账号停用代理后直连的语义
|
||||
if !record.enabled {
|
||||
return Err(AdminError::invalid("代理已禁用,请启用后再选择"));
|
||||
}
|
||||
Ok(record.proxy)
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_proxies(
|
||||
store: &dyn ProxyStore,
|
||||
query: ProxyListQuery,
|
||||
) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control) {
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProxiesService: Send + Sync {
|
||||
async fn create_batch(
|
||||
@@ -268,14 +308,7 @@ impl ProxiesService for DefaultProxiesService {
|
||||
}
|
||||
|
||||
async fn list(&self, query: ProxyListQuery) -> Result<ProxyPage, AdminError> {
|
||||
if query.page == 0 || query.search.len() > 256 || query.search.chars().any(char::is_control)
|
||||
{
|
||||
return Err(AdminError::invalid("代理查询参数不合法"));
|
||||
}
|
||||
self.store
|
||||
.list(query)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "proxy"))
|
||||
list_proxies(self.store.as_ref(), query).await
|
||||
}
|
||||
|
||||
async fn create(
|
||||
|
||||
@@ -106,6 +106,7 @@ fn provider_document_debug_should_not_expose_opaque_material() {
|
||||
#[test]
|
||||
fn representative_quota_should_prefer_short_window_and_highest_usage() {
|
||||
let quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -129,6 +130,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
let mut model_specific = quota_window("shortTerm", Some(18_000), Some(64.0));
|
||||
model_specific.local_usage_attribution = QuotaLocalUsageAttribution::Unavailable;
|
||||
let quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -148,6 +150,7 @@ fn representative_quota_should_prefer_account_wide_window_over_model_specific_wi
|
||||
#[test]
|
||||
fn exhausted_quota_should_project_full_usage_to_only_the_representative_window() {
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -178,6 +181,7 @@ fn exhausted_quota_should_preserve_the_provider_identified_reached_window() {
|
||||
let mut reached = quota_window("monthly", Some(2_592_000), Some(98.0));
|
||||
reached.limit_reached = true;
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
|
||||
@@ -116,6 +116,7 @@ fn selected_usage_window(quota: &ProviderQuota) -> Option<(&str, AccountUsagePer
|
||||
|
||||
fn usage_quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -168,6 +169,7 @@ fn usage_window(key: &str, group: &str, seconds: u64) -> ProviderQuotaWindow {
|
||||
#[test]
|
||||
fn dashboard_quota_should_preserve_unknown_and_actual_window_facts() {
|
||||
let mut quota = ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
|
||||
@@ -59,6 +59,7 @@ fn window(key: &str, days: u64) -> ProviderQuotaWindow {
|
||||
|
||||
fn quota(windows: Vec<ProviderQuotaWindow>) -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
|
||||
@@ -377,6 +377,7 @@ fn member(account_id: &str, total_slots: u64) -> AccountGroupMemberFact {
|
||||
group_id: group_id(),
|
||||
account_id: account_id.to_owned(),
|
||||
status: AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
|
||||
@@ -714,6 +714,7 @@ impl FakeAccountStore {
|
||||
|
||||
fn page_item(account: AccountRecord) -> AccountPageItem {
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: account.enabled,
|
||||
credential_state: account.credential_state,
|
||||
access_token_expires_at: account.access_token_expires_at.map(Into::into),
|
||||
@@ -788,6 +789,7 @@ impl AccountStore for FakeAccountStore {
|
||||
summary: AccountSummary {
|
||||
total,
|
||||
normal: 1,
|
||||
quota_paused: 0,
|
||||
quota_exhausted: 0,
|
||||
rate_limited: 0,
|
||||
disabled: 0,
|
||||
@@ -1082,6 +1084,7 @@ impl AccountStore for FakeAccountStore {
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
command: BatchUpdateAccounts,
|
||||
_: gateway_admin::model::accounts::PreparedQuotaPauses,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
self.record("store.batch_update_accounts");
|
||||
@@ -1128,6 +1131,7 @@ impl AccountStore for FakeAccountStore {
|
||||
|
||||
#[derive(Default)]
|
||||
struct StaticSettingsStore {
|
||||
reset_credit_on_low_weekly_quota: bool,
|
||||
reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
|
||||
@@ -1183,9 +1187,10 @@ impl SettingsStore for StaticSettingsStore {
|
||||
audit_retention_days: 30,
|
||||
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
},
|
||||
@@ -1908,6 +1913,7 @@ async fn accounts_batch_update_should_commit_once_and_notify_each_provider() {
|
||||
.batch_update(
|
||||
&context("batch-update-request"),
|
||||
BatchUpdateAccounts {
|
||||
quota_limits: None,
|
||||
auto_use_quota_credits: None,
|
||||
model_access: Default::default(),
|
||||
outbound_proxy: None,
|
||||
@@ -2076,6 +2082,7 @@ async fn accounts_list_should_degrade_quota_failure_to_empty_window_without_drop
|
||||
let events = events();
|
||||
let openai = FakeProviderAdmin::new("openai", events.clone());
|
||||
openai.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2236,6 +2243,7 @@ async fn accounts_list_should_map_unknown_credential_to_error_not_normal() {
|
||||
async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2287,6 +2295,7 @@ async fn accounts_list_should_not_derive_rate_limited_from_provider_quota_view()
|
||||
async fn accounts_list_should_not_derive_exhaustion_from_provider_quota_view() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -2893,6 +2902,7 @@ async fn accounts_list_should_attach_local_usage_to_quota_windows() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::hours(1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3057,6 +3067,7 @@ async fn accounts_list_should_not_attach_account_usage_to_model_specific_quota_w
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let reset_at = Utc::now() + TimeDelta::days(7);
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3362,6 +3373,7 @@ pub(super) fn document() -> ProviderDocument {
|
||||
|
||||
fn empty_quota() -> ProviderQuota {
|
||||
ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
@@ -3399,6 +3411,8 @@ fn quota_local_usage(account_id: &str, total_tokens: u64) -> AccountUsage {
|
||||
pub(super) fn account_record(kind: &str) -> AccountRecord {
|
||||
let now = Utc::now();
|
||||
AccountRecord {
|
||||
quota_paused: false,
|
||||
quota_limits: Default::default(),
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
|
||||
@@ -231,6 +231,8 @@ fn plan(expires_at: chrono::DateTime<Utc>) -> ResetCreditAutomation {
|
||||
revision: 1,
|
||||
next_run_at: Utc::now(),
|
||||
state: ResetCreditAutomationState {
|
||||
next_weekly_check_at: None,
|
||||
awaiting_weekly_recovery_since: None,
|
||||
next_discovery_at: Utc::now() + TimeDelta::days(1),
|
||||
credits: vec![PlannedResetCredit {
|
||||
id: "card_1".into(),
|
||||
@@ -309,7 +311,7 @@ async fn automation_restores_pending_idempotency_after_service_restart() {
|
||||
.state
|
||||
.retry_at = Some(Utc::now() - TimeDelta::seconds(1));
|
||||
drop(services);
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0)).await;
|
||||
let restarted = threshold_services(provider.clone(), store.clone(), Some(0), true).await;
|
||||
restarted
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(account_id)
|
||||
@@ -712,10 +714,12 @@ async fn threshold_services(
|
||||
provider: Arc<FakeProviderAdmin>,
|
||||
store: Arc<FakeAccountStore>,
|
||||
max_remaining: Option<u32>,
|
||||
early: bool,
|
||||
) -> AdminServices {
|
||||
super::super::AdminHarness::new()
|
||||
.accounts(store)
|
||||
.settings(Arc::new(StaticSettingsStore {
|
||||
reset_credit_on_low_weekly_quota: early,
|
||||
reset_credit_max_weekly_remaining_percent: max_remaining,
|
||||
}))
|
||||
.provider(provider)
|
||||
@@ -783,7 +787,7 @@ async fn automation_weekly_threshold_is_optional_inclusive_and_uses_remaining_qu
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(used));
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), threshold, false).await;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
@@ -825,7 +829,7 @@ async fn automation_retains_skipped_credit_and_refreshes_quota_on_next_attempt()
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(weekly_quota(10.0));
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
services
|
||||
.accounts()
|
||||
@@ -897,7 +901,7 @@ async fn automation_defers_missing_invalid_or_failed_weekly_quota() {
|
||||
schedule_due_credit(&provider, &store);
|
||||
provider.set_quota(quota);
|
||||
*provider.quota_failure.lock().unwrap() = failure;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70)).await;
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(70), false).await;
|
||||
let result = services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(ProviderAccountId::new("acct_test").unwrap())
|
||||
@@ -971,3 +975,256 @@ async fn automation_nothing_to_reset_retains_credit_and_retries_with_a_new_key()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_use_checks_only_known_cards_and_confirms_low_quota_before_spending() {
|
||||
for (enabled, threshold, used, expiring, expected) in [
|
||||
(false, Some(10), 100.0, false, false),
|
||||
(true, None, 99.9, true, false),
|
||||
(true, None, 100.0, false, true),
|
||||
(true, Some(10), 90.0, false, true),
|
||||
(true, Some(100), 0.0, false, false),
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
enable_capability(&provider);
|
||||
let expiry = Utc::now()
|
||||
+ if expiring {
|
||||
TimeDelta::minutes(5)
|
||||
} else {
|
||||
TimeDelta::days(3)
|
||||
};
|
||||
provider.reset_credits.lock().unwrap().push(card(expiry));
|
||||
let mut record = plan(expiry);
|
||||
let credits = std::mem::take(&mut record.state.credits);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let services =
|
||||
threshold_services(provider.clone(), store.clone(), threshold, enabled).await;
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
provider.set_quota(weekly_quota(used));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(provider.quota_requests().is_empty());
|
||||
let no_card = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(no_card.next_run_at, no_card.state.next_discovery_at);
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_mut("acct_test")
|
||||
.unwrap()
|
||||
.state
|
||||
.credits = credits;
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
provider.reset_credit_commands().len(),
|
||||
usize::from(expected)
|
||||
);
|
||||
let status = services
|
||||
.accounts()
|
||||
.reset_credit_automation_status(ProviderAccountId::new("acct_test").unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
status.weekly_trigger_remaining_percent,
|
||||
enabled.then_some(threshold.unwrap_or(0))
|
||||
);
|
||||
if enabled {
|
||||
assert!(status.next_consume_at.is_none());
|
||||
}
|
||||
let requests = provider.quota_requests();
|
||||
assert_eq!(
|
||||
requests.iter().filter(|r| !r.refresh).count(),
|
||||
usize::from(enabled)
|
||||
);
|
||||
assert_eq!(requests.iter().any(|r| r.refresh), expected);
|
||||
if enabled && !expected {
|
||||
assert!(
|
||||
store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.next_run_at
|
||||
<= Utc::now() + TimeDelta::seconds(90)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_use_rechecks_live_quota_and_waits_for_a_new_recovery_after_restart() {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
let store = FakeAccountStore::new("openai", events());
|
||||
enable_capability(&provider);
|
||||
let expiry = Utc::now() + TimeDelta::days(3);
|
||||
let mut later = card(expiry + TimeDelta::days(1));
|
||||
later.id = "later".into();
|
||||
*provider.reset_credits.lock().unwrap() = vec![later, card(expiry)];
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), plan(expiry));
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
let high = ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(15.0)
|
||||
};
|
||||
provider.set_quota(weekly_quota(90.0));
|
||||
let (release, gate) = tokio::sync::oneshot::channel();
|
||||
*provider.reset_credit_query_gate.lock().unwrap() = Some(gate);
|
||||
let (result, ()) = tokio::join!(
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone()),
|
||||
async {
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
provider.reset_credit_query_started.notified(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
provider.set_quota(high.clone());
|
||||
release.send(()).unwrap();
|
||||
}
|
||||
);
|
||||
result.unwrap();
|
||||
assert!(provider.reset_credit_commands().is_empty());
|
||||
|
||||
advance_retry(&store);
|
||||
provider.set_quota(weekly_quota(95.0));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0].credit_id.as_deref(), Some("card_1"));
|
||||
provider
|
||||
.reset_credits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|credit| credit.id != "card_1");
|
||||
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
for quota in [weekly_quota(95.0), high, weekly_quota(95.0)] {
|
||||
provider.set_quota(quota);
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(provider.reset_credit_commands().len(), 1);
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(20.0)
|
||||
});
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
provider.set_quota(weekly_quota(100.0));
|
||||
services
|
||||
.accounts()
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[1].credit_id.as_deref(), Some("later"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn weekly_recovery_is_observed_without_cards_after_a_failed_quota_readback() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
enable_capability(&provider);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let expiry = Utc::now() + TimeDelta::days(3);
|
||||
let mut record = plan(expiry);
|
||||
record.state.pending = Some(PendingResetCredit {
|
||||
credit: record.state.credits[0].clone(),
|
||||
redeem_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
store
|
||||
.automations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("acct_test".into(), record);
|
||||
let id = ProviderAccountId::new("acct_test").unwrap();
|
||||
let services = threshold_services(provider.clone(), store.clone(), Some(10), true).await;
|
||||
let accounts = services.accounts();
|
||||
*provider.quota_failure.lock().unwrap() = Some(ProviderAdminErrorKind::Unavailable);
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let waiting = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(waiting.state.credits.is_empty());
|
||||
assert_eq!(waiting.state.last_result.as_deref(), Some("reset"));
|
||||
assert!(waiting.next_run_at <= Utc::now() + TimeDelta::seconds(90));
|
||||
|
||||
provider.set_quota(ProviderQuota {
|
||||
observed_at: Some(Utc::now()),
|
||||
..weekly_quota(20.0)
|
||||
});
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let recovered = store
|
||||
.reset_credit_automation("acct_test")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(recovered.next_run_at, recovered.state.next_discovery_at);
|
||||
assert!(!recorded(&events).contains(&"provider.reset_credits"));
|
||||
assert_eq!(
|
||||
provider
|
||||
.quota_requests()
|
||||
.iter()
|
||||
.map(|r| r.refresh)
|
||||
.collect::<Vec<_>>(),
|
||||
[true, false]
|
||||
);
|
||||
|
||||
provider.set_quota(weekly_quota(95.0));
|
||||
let mut new_card = card(expiry);
|
||||
new_card.id = "next".into();
|
||||
provider.reset_credits.lock().unwrap().push(new_card);
|
||||
accounts
|
||||
.reset_credits(&context("new-card"), id.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
accounts
|
||||
.reconcile_reset_credit_automation(id)
|
||||
.await
|
||||
.unwrap();
|
||||
let commands = provider.reset_credit_commands();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[1].credit_id.as_deref(), Some("next"));
|
||||
}
|
||||
|
||||
@@ -35,6 +35,32 @@ struct TestClientKeyStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for TestClientKeyStore {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
|
||||
> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
|
||||
@@ -73,9 +73,10 @@ fn runtime_settings(enabled: bool, probe_enabled: bool) -> RuntimeSettings {
|
||||
..Default::default()
|
||||
},
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -644,6 +644,7 @@ impl AccountStore for UnavailableStore {
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
_: BatchUpdateAccounts,
|
||||
_: gateway_admin::model::accounts::PreparedQuotaPauses,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
Err(unavailable("account batch update"))
|
||||
@@ -707,6 +708,32 @@ impl AccountRuntimeStore for UnavailableStore {
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for UnavailableStore {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
|
||||
> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! 验证管理端错误位置的证据优先级与未知边界
|
||||
|
||||
use gateway_admin::model::observability::{RequestOutcome, UsageErrorOrigin};
|
||||
|
||||
use super::{Utc, total_record};
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_does_not_infer_from_client_status_or_message() {
|
||||
let mut record = total_record("unknown", Some("openai"), "unavailable", Utc::now());
|
||||
record.client_status_code = Some(502);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_message = Some("Upstream error: connection reset".to_owned());
|
||||
// 这些分类既可能来自本地校验、选凭据、RPC、准入或取消,也可能来自上游
|
||||
for kind in [
|
||||
"invalid_request",
|
||||
"unsupported",
|
||||
"unauthorized",
|
||||
"rate_limited",
|
||||
"quota_exhausted",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"protocol",
|
||||
"unavailable",
|
||||
"upstream_unavailable",
|
||||
"continuation_recovery_required",
|
||||
"future_error_kind",
|
||||
] {
|
||||
record.error_kind = Some(kind.to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_recognizes_unambiguous_failure_kinds() {
|
||||
use UsageErrorOrigin::{Connection, Proxy, Upstream};
|
||||
|
||||
let mut record = total_record("classified", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
for (kind, expected) in [
|
||||
// 本地执行、选路、账号与准入策略已经确定了错误位置
|
||||
("internal_error", Proxy),
|
||||
("model_not_found", Proxy),
|
||||
("no_available_provider", Proxy),
|
||||
("no_eligible_account", Proxy),
|
||||
("account_capacity_unavailable", Proxy),
|
||||
("middleware_capacity_unavailable", Proxy),
|
||||
("concurrency_queue_full", Proxy),
|
||||
("concurrency_queue_timeout", Proxy),
|
||||
("provider_infrastructure_unavailable", Proxy),
|
||||
("session_policy_restricted", Proxy),
|
||||
("request_policy_denied", Proxy),
|
||||
("policy_denied", Proxy),
|
||||
("budget_exhausted", Proxy),
|
||||
("process_interrupted", Proxy),
|
||||
("process_terminated", Proxy),
|
||||
// Provider 的权限拒绝、消息过大和模型容量拒绝来自明确上游拒绝
|
||||
("permission_denied", Upstream),
|
||||
("message_too_big", Upstream),
|
||||
("upstream_capacity_unavailable", Upstream),
|
||||
("transport", Connection),
|
||||
] {
|
||||
record.error_kind = Some(kind.to_owned());
|
||||
assert_eq!(record.error_origin(), expected, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_distinguishes_upstream_and_local_size_rejections() {
|
||||
let mut record = total_record("size", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
// 收到上游 close 1009 才产生 MessageTooBig;本地发送前超限保存续接拒绝原因
|
||||
record.error_kind = Some("message_too_big".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.error_kind = Some("continuation_recovery_required".to_owned());
|
||||
record.continuation_unavailable_reason = Some("transport_payload_too_large".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.continuation_unavailable_reason = None;
|
||||
record.error_message = Some("message too big".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_prefers_upstream_facts_to_local_classification() {
|
||||
let mut record = total_record("upstream", Some("openai"), "unavailable", Utc::now());
|
||||
record.error_kind = Some("internal_error".to_owned());
|
||||
record.continuation_unavailable_reason = Some("reused_connection_lost".to_owned());
|
||||
record.client_status_code = Some(503);
|
||||
record.upstream_status_code = Some(429);
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_code = Some("previous_response_not_found".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.error_code = None;
|
||||
record.websocket_close_code = Some(1011);
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.websocket_close_code = None;
|
||||
record.has_upstream_error = true;
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_uses_connection_exit_boundaries() {
|
||||
use UsageErrorOrigin::{Connection, Proxy, Unknown, Upstream};
|
||||
|
||||
let mut record = total_record("connection", Some("openai"), "unavailable", Utc::now());
|
||||
record.outcome = RequestOutcome::Failed;
|
||||
// Provider 的传输和协议错误会统一保存为 upstream_unavailable,具体退出原因仍独立保存
|
||||
record.error_kind = Some("upstream_unavailable".to_owned());
|
||||
record.upstream_status_code = Some(200);
|
||||
for (reason, expected) in [
|
||||
// 本地通道、下游交付与连接池回收,不表示客户端取消应由代理承担责任
|
||||
("command_channel_closed", Proxy),
|
||||
("local_close", Proxy),
|
||||
("message_receiver_closed", Proxy),
|
||||
("client_cancelled", Proxy),
|
||||
("downstream_send_failed", Proxy),
|
||||
("downstream_connection_closed", Proxy),
|
||||
("downstream_rebound", Proxy),
|
||||
("pool_shutdown", Proxy),
|
||||
("max_age_expired", Proxy),
|
||||
("idle_timeout_expired", Proxy),
|
||||
("proxy_capacity_evicted", Proxy),
|
||||
("search_activation_required", Proxy),
|
||||
// 已收到明确的上游帧或终态,与无关闭帧的 EOF 区分
|
||||
("upstream_close", Upstream),
|
||||
("normal_close", Upstream),
|
||||
("unexpected_binary_event", Upstream),
|
||||
("incomplete_response", Upstream),
|
||||
("failed_response", Upstream),
|
||||
("eof", Connection),
|
||||
("unexpected_eof", Connection),
|
||||
("transport_error", Connection),
|
||||
("connection_reset", Connection),
|
||||
("connection_aborted", Connection),
|
||||
("broken_pipe", Connection),
|
||||
("tcp_reset", Connection),
|
||||
("reset_without_closing_handshake", Connection),
|
||||
("transport_timeout", Connection),
|
||||
("outbound_transport_error", Connection),
|
||||
("receive_idle_timeout", Connection),
|
||||
("pong_timeout", Connection),
|
||||
("ping_write_timeout", Connection),
|
||||
("pong_write_timeout", Connection),
|
||||
// stream_eof 也可能是本地收包任务退出,笼统退出摘要合并了多种底层失败
|
||||
("stream_eof", Unknown),
|
||||
("upstream_closed", Unknown),
|
||||
("upstream_receive_failed", Unknown),
|
||||
("exchange_failure", Unknown),
|
||||
("running", Unknown),
|
||||
("future_exit_reason", Unknown),
|
||||
] {
|
||||
record.upstream_connection_exit_reason = Some(reason.to_owned());
|
||||
assert_eq!(record.error_origin(), expected, "{reason}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_error_origin_distinguishes_local_policy_from_connection_failure() {
|
||||
let mut record = total_record("local", Some("openai"), "unavailable", Utc::now());
|
||||
record.client_status_code = Some(503);
|
||||
record.upstream_status_code = Some(200);
|
||||
record.error_kind = Some("budget_exhausted".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.error_kind = Some("transport".to_owned());
|
||||
record.upstream_connection_exit_reason = Some("unexpected_eof".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Connection);
|
||||
record.continuation_unavailable_reason = Some("latest_response_mismatch".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Proxy);
|
||||
record.continuation_unavailable_reason = Some("upstream_rejected".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
record.continuation_unavailable_reason = None;
|
||||
record.upstream_connection_exit_reason = Some("upstream_close".to_owned());
|
||||
assert_eq!(record.error_origin(), UsageErrorOrigin::Upstream);
|
||||
}
|
||||
@@ -34,6 +34,8 @@ use gateway_admin::{
|
||||
ports::store::{AdminStoreResult, ObservabilityStore, SettingsStore},
|
||||
};
|
||||
|
||||
mod errors;
|
||||
|
||||
#[test]
|
||||
fn external_observability_range_accepts_exactly_366_days() {
|
||||
let end = Utc::now();
|
||||
@@ -1437,9 +1439,10 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
audit_retention_days: 30,
|
||||
account_error_protection: gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
})
|
||||
@@ -1519,6 +1522,7 @@ fn total_record(
|
||||
) -> UsageListRecord {
|
||||
UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
has_upstream_error: false,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
error_kind: None,
|
||||
@@ -1527,6 +1531,7 @@ fn total_record(
|
||||
upstream_connection_exit_reason: None,
|
||||
recovered: false,
|
||||
client_status_code: None,
|
||||
upstream_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: id.to_owned(),
|
||||
|
||||
@@ -423,31 +423,37 @@ fn system_context() -> MutationContext {
|
||||
}
|
||||
|
||||
fn plugin_entry(id: &str, marker: u8) -> serde_json::Value {
|
||||
let (release_os, release_architecture, target) = release_platform();
|
||||
// 清单列出所有平台,夹具只提供本机归档,以验证导入时不会读取其他平台的包
|
||||
let artifacts = [
|
||||
("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("darwin", "amd64", "x86_64-apple-darwin"),
|
||||
("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
("windows", "amd64", "x86_64-pc-windows-msvc"),
|
||||
("windows", "arm64", "aarch64-pc-windows-msvc"),
|
||||
]
|
||||
.map(|(release_os, release_architecture, target)| {
|
||||
let os = if release_os == "darwin" {
|
||||
"macos"
|
||||
} else {
|
||||
release_os
|
||||
};
|
||||
let (architecture, _) = target.split_once('-').unwrap();
|
||||
serde_json::json!({
|
||||
"release_os": release_os,
|
||||
"release_architecture": release_architecture,
|
||||
"target": target,
|
||||
"os": os,
|
||||
"architecture": architecture,
|
||||
"archive": format!("{id}-1.0.0-{os}-{architecture}.tar.gz"),
|
||||
"sha256": format!("{marker:01x}").repeat(64),
|
||||
})
|
||||
});
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"version": "1.0.0",
|
||||
"publisher": "test",
|
||||
"stability": "experimental",
|
||||
"artifacts": [{
|
||||
"release_os": release_os,
|
||||
"release_architecture": release_architecture,
|
||||
"target": target,
|
||||
"os": std::env::consts::OS,
|
||||
"architecture": std::env::consts::ARCH,
|
||||
"archive": format!("{id}-1.0.0-{}-{}.tar.gz", std::env::consts::OS, std::env::consts::ARCH),
|
||||
"sha256": format!("{marker:01x}").repeat(64),
|
||||
}]
|
||||
"artifacts": artifacts,
|
||||
})
|
||||
}
|
||||
|
||||
fn release_platform() -> (&'static str, &'static str, &'static str) {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => ("linux", "amd64", "x86_64-unknown-linux-gnu"),
|
||||
("linux", "aarch64") => ("linux", "arm64", "aarch64-unknown-linux-gnu"),
|
||||
("macos", "aarch64") => ("darwin", "arm64", "aarch64-apple-darwin"),
|
||||
("windows", "x86_64") => ("windows", "amd64", "x86_64-pc-windows-msvc"),
|
||||
("windows", "aarch64") => ("windows", "arm64", "aarch64-pc-windows-msvc"),
|
||||
platform => panic!("unsupported test platform: {platform:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,13 @@ impl ProxyStore for TestProxies {
|
||||
guard: Box::new(ImportGuard(events.clone())),
|
||||
})
|
||||
}
|
||||
async fn list(&self, _: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
Err(super::unavailable("proxy"))
|
||||
async fn list(&self, query: ProxyListQuery) -> AdminStoreResult<ProxyPage> {
|
||||
Ok(ProxyPage {
|
||||
items: self.records.clone(),
|
||||
total: self.records.len() as u64,
|
||||
page: query.page,
|
||||
page_size: query.page_size.get(),
|
||||
})
|
||||
}
|
||||
async fn list_accounts(
|
||||
&self,
|
||||
@@ -114,6 +119,79 @@ impl ProxyStore for TestProxies {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_proxy_resolution_requires_enabled_configuration_without_account_or_probe() {
|
||||
use gateway_admin::model::{AdminErrorKind, PageSize};
|
||||
use std::sync::Arc;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let records: Vec<_> = [true, false]
|
||||
.into_iter()
|
||||
.map(|enabled| ProxyRecord {
|
||||
id: format!("proxy_{enabled}"),
|
||||
name: "Login".into(),
|
||||
proxy: OutboundProxy::parse("http://user:secret@127.0.0.1:8080").unwrap(),
|
||||
revision: Revision::new(1).unwrap(),
|
||||
enabled,
|
||||
auto_location: false,
|
||||
location: None,
|
||||
detected_location: None,
|
||||
account_count: 0,
|
||||
last_test_at: Some(now),
|
||||
last_test: Some(ProxyTestResult {
|
||||
location: Default::default(),
|
||||
success: false,
|
||||
latency_ms: 1,
|
||||
exit_ip: None,
|
||||
exit_ipv4: None,
|
||||
exit_ipv6: None,
|
||||
message: "fixture failure".into(),
|
||||
}),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.collect();
|
||||
let access = gateway_admin::initialize_plugin_proxies(Arc::new(TestProxies {
|
||||
records: records.clone(),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(
|
||||
access.resolve("proxy_true").await.unwrap(),
|
||||
records[0].proxy
|
||||
);
|
||||
assert_eq!(
|
||||
access.resolve("proxy_false").await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
assert!(access.resolve("missing").await.is_err());
|
||||
for id in ["", "bad\n", &"x".repeat(129)] {
|
||||
assert_eq!(
|
||||
access.resolve(id).await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
}
|
||||
let query = || ProxyListQuery {
|
||||
page: 1,
|
||||
page_size: PageSize::new(10).unwrap(),
|
||||
search: String::new(),
|
||||
};
|
||||
let page = access.list(query()).await.unwrap();
|
||||
assert_eq!(page.items.len(), 2);
|
||||
assert!(!page.items[1].enabled);
|
||||
for invalid in [
|
||||
ProxyListQuery { page: 0, ..query() },
|
||||
ProxyListQuery {
|
||||
search: "bad\n".into(),
|
||||
..query()
|
||||
},
|
||||
] {
|
||||
assert_eq!(
|
||||
access.list(invalid).await.unwrap_err().kind(),
|
||||
AdminErrorKind::Invalid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProxyProbe for TestProxies {
|
||||
async fn test(&self, _: Option<&OutboundProxy>, _: bool) -> ProxyTestResult {
|
||||
@@ -203,6 +281,7 @@ async fn linked_accounts_share_plan_resolution_and_only_read_cached_quota() {
|
||||
] {
|
||||
let provider = FakeProviderAdmin::new("openai", events());
|
||||
provider.set_quota(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
credits: None,
|
||||
plan_type: cached.map(str::to_owned),
|
||||
|
||||
@@ -351,9 +351,10 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
account_error_protection:
|
||||
gateway_core::account::AccountErrorProtection::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -276,6 +276,7 @@ impl UpdateAccountRequest {
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AccountConnectionUpdateRequest {
|
||||
pub protocol: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub transport: String,
|
||||
pub api_key: Option<String>,
|
||||
@@ -283,6 +284,13 @@ pub struct AccountConnectionUpdateRequest {
|
||||
|
||||
impl AccountConnectionUpdateRequest {
|
||||
fn validate(&self) -> Result<(), WireValidationError> {
|
||||
if self
|
||||
.protocol
|
||||
.as_deref()
|
||||
.is_some_and(|protocol| !matches!(protocol, "responses" | "messages"))
|
||||
{
|
||||
return Err(WireValidationError::new("connection.protocol"));
|
||||
}
|
||||
if let Some(base_url) = &self.base_url {
|
||||
require_text(base_url, 2048, "connection.baseUrl")?;
|
||||
}
|
||||
@@ -302,6 +310,9 @@ impl AccountConnectionUpdateRequest {
|
||||
fn into_document(self) -> ProviderDocument {
|
||||
let mut material =
|
||||
Map::from_iter([("transport".to_owned(), Value::String(self.transport))]);
|
||||
if let Some(protocol) = self.protocol {
|
||||
material.insert("protocol".to_owned(), Value::String(protocol));
|
||||
}
|
||||
if let Some(base_url) = self.base_url {
|
||||
material.insert("base_url".to_owned(), Value::String(base_url));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ pub(super) fn account_page_data(
|
||||
summary: AccountSummaryView {
|
||||
total: result.summary.total,
|
||||
normal: result.summary.normal,
|
||||
quota_paused: result.summary.quota_paused,
|
||||
quota_exhausted: result.summary.quota_exhausted,
|
||||
rate_limited: result.summary.rate_limited,
|
||||
disabled: result.summary.disabled,
|
||||
@@ -116,6 +117,7 @@ pub(super) fn account_view(
|
||||
let (mut quota, refresh_token_expires_at) = account_quota_view(quota, cooldown, now, time);
|
||||
quota.protection_rules = protection_rules;
|
||||
AccountView {
|
||||
quota_limits: account.quota_limits,
|
||||
auto_use_quota_credits: account.auto_use_quota_credits,
|
||||
upstream,
|
||||
capabilities: capabilities.into(),
|
||||
@@ -272,6 +274,7 @@ pub(super) fn account_quota_view(
|
||||
let recovery_probe_required = cooldown.is_some_and(|value| value.kind.requires_probe());
|
||||
(
|
||||
AccountQuotaView {
|
||||
paused_windows: quota.paused_windows,
|
||||
refreshed_at: quota.observed_at.map(|value| time.rfc3339(&value)),
|
||||
refreshed_at_relative: quota
|
||||
.observed_at
|
||||
@@ -306,7 +309,7 @@ pub(crate) fn quota_window_view(
|
||||
limit_id,
|
||||
limit_name,
|
||||
role,
|
||||
local_usage_attribution: _,
|
||||
local_usage_attribution,
|
||||
window_seconds,
|
||||
used_percent,
|
||||
reset_at,
|
||||
@@ -318,6 +321,8 @@ pub(crate) fn quota_window_view(
|
||||
.as_ref()
|
||||
.map_or_else(|| label.clone(), |name| format!("{name} · {label}"));
|
||||
AccountQuotaWindowView {
|
||||
account_wide: local_usage_attribution
|
||||
== gateway_admin::model::provider_credentials::QuotaLocalUsageAttribution::AccountWide,
|
||||
source_account_id: upstream.as_ref().and_then(|value| value.account_id.clone()),
|
||||
amount: upstream
|
||||
.as_ref()
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct ListQuery {
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct BatchUpdateAccountsRequest {
|
||||
pub quota_limits: Option<gateway_core::account::AccountQuotaLimits>,
|
||||
pub auto_use_quota_credits: Option<bool>,
|
||||
pub outbound_proxy_id: Option<String>,
|
||||
pub outbound_proxy_url: Option<AccountProxyUpdate>,
|
||||
@@ -118,7 +119,8 @@ impl BatchUpdateAccountsRequest {
|
||||
.map(parse_concurrency_limit)
|
||||
.transpose()?;
|
||||
self.weight.map(parse_account_weight).transpose()?;
|
||||
if self.auto_use_quota_credits.is_none()
|
||||
if self.quota_limits.is_none()
|
||||
&& self.auto_use_quota_credits.is_none()
|
||||
&& self.enabled.is_none()
|
||||
&& self.concurrency_limit.is_none()
|
||||
&& self.weight.is_none()
|
||||
@@ -135,6 +137,7 @@ impl BatchUpdateAccountsRequest {
|
||||
pub(super) fn into_command(self) -> Result<BatchUpdateAccounts, WireValidationError> {
|
||||
self.validate()?;
|
||||
Ok(BatchUpdateAccounts {
|
||||
quota_limits: self.quota_limits,
|
||||
auto_use_quota_credits: self.auto_use_quota_credits,
|
||||
outbound_proxy: proxy_selection(self.outbound_proxy_id, self.outbound_proxy_url)?,
|
||||
account_ids: self.account_ids,
|
||||
@@ -259,6 +262,7 @@ pub struct AccountPageData {
|
||||
pub struct AccountSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
@@ -269,6 +273,7 @@ pub struct AccountSummaryView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountView {
|
||||
pub quota_limits: gateway_core::account::AccountQuotaLimits,
|
||||
pub auto_use_quota_credits: bool,
|
||||
pub upstream: Option<super::AccountUpstreamInfoView>,
|
||||
pub capabilities: AccountCapabilitiesView,
|
||||
@@ -322,6 +327,7 @@ pub struct AccountCapacityView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountCapabilitiesView {
|
||||
pub quota_limits_supported: bool,
|
||||
pub quota_credits: bool,
|
||||
pub quota: bool,
|
||||
pub quota_refresh: bool,
|
||||
@@ -341,6 +347,7 @@ impl From<gateway_admin::model::accounts::ProviderAccountCapabilities> for Accou
|
||||
subscription: value.subscription,
|
||||
avatar: value.avatar,
|
||||
reset_credits: value.reset_credits,
|
||||
quota_limits_supported: value.quota_limits_supported,
|
||||
quota_credits: value.quota_credits,
|
||||
consume_reset_credit: value.consume_reset_credit,
|
||||
}
|
||||
@@ -392,6 +399,7 @@ pub use crate::admin::account_groups::AccountGroupRefView;
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaView {
|
||||
pub paused_windows: Vec<String>,
|
||||
pub refreshed_at: Option<String>,
|
||||
pub refreshed_at_relative: Option<crate::time::RelativeTimeView>,
|
||||
pub refreshed_at_display: String,
|
||||
@@ -419,6 +427,7 @@ pub struct AccountQuotaCreditsView {
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AccountQuotaWindowView {
|
||||
pub account_wide: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_account_id: Option<String>,
|
||||
pub amount: Option<super::AccountQuotaAmountView>,
|
||||
@@ -1100,7 +1109,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
DomainConnectionTestEvent::Started { model } => serde_json::json!({
|
||||
"type": "test_start",
|
||||
"model": model,
|
||||
"text": "正在连接上游 Responses"
|
||||
"text": "正在连接上游"
|
||||
}),
|
||||
DomainConnectionTestEvent::Request {
|
||||
model,
|
||||
@@ -1129,6 +1138,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
DomainConnectionTestEvent::Failed {
|
||||
source,
|
||||
gateway_error_code,
|
||||
proxy_hint,
|
||||
send_state,
|
||||
message,
|
||||
provider_error_code,
|
||||
@@ -1140,6 +1150,7 @@ impl From<(DomainConnectionTestEvent, crate::time::TimePresenter)> for AccountCo
|
||||
"type": "error",
|
||||
"source": source.as_str(),
|
||||
"gatewayErrorCode": gateway_error_code.as_str(),
|
||||
"proxyHint": proxy_hint.map(gateway_core::error::ProxyConnectionHint::as_str),
|
||||
"sendState": send_state.map(gateway_core::upstream::UpstreamSendState::as_str),
|
||||
"error": message,
|
||||
"providerErrorCode": provider_error_code,
|
||||
@@ -1235,6 +1246,7 @@ pub struct AccountResetCreditAutomationRequest {
|
||||
pub struct AccountResetCreditAutomationData {
|
||||
pub enabled: bool,
|
||||
pub lead_minutes: u32,
|
||||
pub weekly_trigger_remaining_percent: Option<u32>,
|
||||
pub next_discovery_at_display: Option<String>,
|
||||
pub next_consume_at_display: Option<String>,
|
||||
pub last_result: Option<String>,
|
||||
@@ -1255,6 +1267,7 @@ impl
|
||||
Self {
|
||||
enabled: status.enabled,
|
||||
lead_minutes: status.lead_minutes,
|
||||
weekly_trigger_remaining_percent: status.weekly_trigger_remaining_percent,
|
||||
next_discovery_at_display: status
|
||||
.next_discovery_at
|
||||
.as_ref()
|
||||
|
||||
@@ -415,14 +415,14 @@ impl From<(ClientKeyRecord, crate::time::TimePresenter)> for ClientKeyView {
|
||||
weekly_used_usd: record.budget.weekly_used_usd.canonical(),
|
||||
daily_resets_at_display: record
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.daily_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: record.budget.daily_resets_at.map(DateTime::from),
|
||||
daily_resets_at: record.budget.daily_resets_at().map(DateTime::from),
|
||||
weekly_resets_at_display: record
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.weekly_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: record.budget.weekly_resets_at.map(DateTime::from),
|
||||
weekly_resets_at: record.budget.weekly_resets_at().map(DateTime::from),
|
||||
created_at_display: time.datetime(&record.created_at),
|
||||
created_at: record.created_at,
|
||||
updated_at_display: time.datetime(&record.updated_at),
|
||||
|
||||
@@ -258,6 +258,9 @@ pub(crate) fn usage_list_record_view(
|
||||
.clone()
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
UsageListRecordView {
|
||||
error_origin: record.error_origin().as_str(),
|
||||
client_status_code: record.client_status_code,
|
||||
upstream_status_code: record.upstream_status_code,
|
||||
error_code: record.error_code,
|
||||
error_message: record.error_message,
|
||||
error_kind: record.error_kind,
|
||||
@@ -362,6 +365,7 @@ pub(crate) fn usage_record_view(
|
||||
.or_else(|| record.requested_model_id.clone());
|
||||
let metadata = provider_metadata_fields(record.provider_metadata_json.as_deref());
|
||||
UsageRecordView {
|
||||
error_origin: record.error_origin().as_str(),
|
||||
upstream_error: record
|
||||
.upstream_error
|
||||
.map(|error| super::wire::UsageUpstreamErrorView {
|
||||
@@ -1038,6 +1042,7 @@ pub(crate) fn dashboard_view(
|
||||
pool_summary: DashboardPoolSummaryView {
|
||||
total: provider_accounts.total,
|
||||
normal: provider_accounts.normal,
|
||||
quota_paused: provider_accounts.quota_paused,
|
||||
quota_exhausted: provider_accounts.quota_exhausted,
|
||||
rate_limited: provider_accounts.rate_limited,
|
||||
disabled: provider_accounts.disabled,
|
||||
|
||||
@@ -86,6 +86,9 @@ pub struct ImageBillingView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageListRecordView {
|
||||
pub error_origin: &'static str,
|
||||
pub client_status_code: Option<u16>,
|
||||
pub upstream_status_code: Option<u16>,
|
||||
pub error_code: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub error_kind: Option<String>,
|
||||
@@ -143,6 +146,7 @@ pub struct UsageUpstreamErrorView {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UsageRecordView {
|
||||
pub error_origin: &'static str,
|
||||
pub upstream_error: Option<UsageUpstreamErrorView>,
|
||||
pub retry_after_ms: Option<u64>,
|
||||
pub error_code: Option<String>,
|
||||
@@ -268,7 +272,7 @@ pub struct UsageAttemptView {
|
||||
pub transport: String,
|
||||
pub send_state: String,
|
||||
pub outcome: String,
|
||||
pub downstream_committed: bool,
|
||||
pub downstream_committed: Option<bool>,
|
||||
pub status_code: Option<u16>,
|
||||
pub provider_error_code: Option<String>,
|
||||
pub failure_class: Option<String>,
|
||||
@@ -456,6 +460,7 @@ pub struct DashboardAccountUsageView {
|
||||
pub struct DashboardPoolSummaryView {
|
||||
pub total: u64,
|
||||
pub normal: u64,
|
||||
pub quota_paused: u64,
|
||||
pub quota_exhausted: u64,
|
||||
pub rate_limited: u64,
|
||||
pub disabled: u64,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! 插件管理页面、资源、授权回调与页面模型请求的 HTTP 边界
|
||||
|
||||
use crate::openai::generation::{
|
||||
GenerationHttpRequest, ResponseAuthorization, execute_prepared_generation,
|
||||
};
|
||||
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
|
||||
use axum::{
|
||||
@@ -35,10 +39,7 @@ use crate::{
|
||||
auth::SessionState,
|
||||
openai::{
|
||||
error::{gateway_error_response, protocol_error_response},
|
||||
responses::{
|
||||
ResponseAuthorization, ResponsesHttpRequest, decode_request_with_body,
|
||||
execute_prepared_responses,
|
||||
},
|
||||
responses::decode_request_with_body,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,10 +141,10 @@ async fn model_responses(
|
||||
};
|
||||
let authorization: Arc<dyn ResponseAuthorization> =
|
||||
Arc::new(PluginModelsAuthorization { state, target });
|
||||
let response = execute_prepared_responses(
|
||||
let response = execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
ResponsesHttpRequest {
|
||||
GenerationHttpRequest {
|
||||
peer_address: parts
|
||||
.extensions
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
@@ -158,6 +159,7 @@ async fn model_responses(
|
||||
middleware_body,
|
||||
},
|
||||
Some(authorization),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(plugin_response(response))
|
||||
|
||||
@@ -61,8 +61,10 @@ pub struct RuntimeSettingsFields {
|
||||
pub audit_retention_days: u64,
|
||||
pub account_error_protection: gateway_core::account::AccountErrorProtection,
|
||||
pub account_warmup_enabled: bool,
|
||||
pub account_warmup_schedule_time: String,
|
||||
pub account_warmup_cron: String,
|
||||
pub account_warmup_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reset_credit_on_low_weekly_quota: bool,
|
||||
pub reset_credit_lead_minutes: u32,
|
||||
pub reset_credit_max_weekly_remaining_percent: Option<u32>,
|
||||
}
|
||||
@@ -773,8 +775,9 @@ impl RuntimeSettingsFields {
|
||||
.map_err(|_| WireValidationError::new("settingsAuditRetentionOverflow"))?,
|
||||
account_error_protection: self.account_error_protection,
|
||||
account_warmup_enabled: self.account_warmup_enabled,
|
||||
account_warmup_schedule_time: self.account_warmup_schedule_time,
|
||||
account_warmup_cron: self.account_warmup_cron,
|
||||
account_warmup_model: self.account_warmup_model,
|
||||
reset_credit_on_low_weekly_quota: self.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_lead_minutes: self.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: self
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
@@ -812,8 +815,9 @@ impl From<gateway_admin::model::settings::RuntimeSettingsValues> for RuntimeSett
|
||||
audit_retention_days: u64::from(settings.audit_retention_days),
|
||||
account_error_protection: settings.account_error_protection,
|
||||
account_warmup_enabled: settings.account_warmup_enabled,
|
||||
account_warmup_schedule_time: settings.account_warmup_schedule_time,
|
||||
account_warmup_cron: settings.account_warmup_cron,
|
||||
account_warmup_model: settings.account_warmup_model,
|
||||
reset_credit_on_low_weekly_quota: settings.reset_credit_on_low_weekly_quota,
|
||||
reset_credit_lead_minutes: settings.reset_credit_lead_minutes,
|
||||
reset_credit_max_weekly_remaining_percent: settings
|
||||
.reset_credit_max_weekly_remaining_percent,
|
||||
@@ -828,7 +832,7 @@ fn settings_validation_error(field: &'static str) -> WireValidationError {
|
||||
"account_warmup_model" => "accountWarmupModel",
|
||||
"reset_credit_lead_minutes" => "resetCreditLeadMinutes",
|
||||
"reset_credit_max_weekly_remaining_percent" => "resetCreditMaxWeeklyRemainingPercent",
|
||||
"account_warmup_schedule_time" => "accountWarmupScheduleTime",
|
||||
"account_warmup_cron" => "accountWarmupCron",
|
||||
"audit_retention_days" => "auditRetentionDays",
|
||||
"key_queue_timeout_seconds" => "keyQueueTimeoutSeconds",
|
||||
"account_queue_timeout_seconds" => "accountQueueTimeoutSeconds",
|
||||
|
||||
@@ -143,6 +143,11 @@ pub const fn gateway_error_contract(
|
||||
"invalid_request_error",
|
||||
"invalid_request",
|
||||
),
|
||||
GatewayErrorKind::PeerRoutingLoop => (
|
||||
StatusCode::LOOP_DETECTED,
|
||||
"invalid_request_error",
|
||||
gateway_protocol::peer::ROUTING_LOOP_CODE,
|
||||
),
|
||||
GatewayErrorKind::Unsupported => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
|
||||
@@ -139,16 +139,16 @@ pub(super) fn overview(value: KeyUsageOverview, time: crate::time::TimePresenter
|
||||
daily_used_usd: key.budget.daily_used_usd.canonical(),
|
||||
daily_resets_at_display: key
|
||||
.budget
|
||||
.daily_resets_at
|
||||
.daily_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
daily_resets_at: key.budget.daily_resets_at.map(DateTime::from),
|
||||
daily_resets_at: key.budget.daily_resets_at().map(DateTime::from),
|
||||
weekly_limit_usd: key.budget.limits.weekly_usd.canonical(),
|
||||
weekly_used_usd: key.budget.weekly_used_usd.canonical(),
|
||||
weekly_resets_at_display: key
|
||||
.budget
|
||||
.weekly_resets_at
|
||||
.weekly_resets_at()
|
||||
.map(|value| time.datetime(&value.into())),
|
||||
weekly_resets_at: key.budget.weekly_resets_at.map(DateTime::from),
|
||||
weekly_resets_at: key.budget.weekly_resets_at().map(DateTime::from),
|
||||
},
|
||||
summary: metrics(
|
||||
&value.overview.requests,
|
||||
|
||||
@@ -137,6 +137,7 @@ pub enum ApiConfigError {
|
||||
|
||||
/// 完成组装的唯一 API router
|
||||
pub struct ApiBundle {
|
||||
peer_node: HeaderValue,
|
||||
router: Router,
|
||||
settings: middleware::SettingsSource,
|
||||
middleware: Option<middleware::PlanSource>,
|
||||
@@ -169,6 +170,7 @@ impl ApiBundle {
|
||||
self.timeout,
|
||||
self.request_id_header.clone(),
|
||||
self.settings.clone(),
|
||||
self.peer_node.clone(),
|
||||
),
|
||||
settings: self.settings.clone(),
|
||||
})
|
||||
@@ -181,12 +183,15 @@ impl ApiBundle {
|
||||
self.timeout,
|
||||
self.request_id_header,
|
||||
self.settings,
|
||||
self.peer_node,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 组装客户端、管理端、健康检查和静态资源路由
|
||||
#[expect(clippy::too_many_arguments)]
|
||||
pub fn initialize(
|
||||
peer_node: uuid::Uuid,
|
||||
mut config: ApiConfig,
|
||||
execution: Arc<dyn ExecutionService>,
|
||||
admin: AdminServices,
|
||||
@@ -250,6 +255,8 @@ pub fn initialize(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
HeaderName::from_static("x-api-key"),
|
||||
HeaderName::from_static("anthropic-version"),
|
||||
HeaderName::from_static("anthropic-beta"),
|
||||
request_id_header.clone(),
|
||||
])
|
||||
.allow_credentials(true),
|
||||
@@ -297,6 +304,13 @@ pub fn initialize(
|
||||
))
|
||||
.with_state(state);
|
||||
Ok(ApiBundle {
|
||||
peer_node: HeaderValue::from_bytes(
|
||||
peer_node
|
||||
.simple()
|
||||
.encode_lower(&mut uuid::Uuid::encode_buffer())
|
||||
.as_bytes(),
|
||||
)
|
||||
.expect("UUID encodes as ASCII"),
|
||||
settings,
|
||||
router,
|
||||
middleware: None,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 路由外层只适配 HTTP 值和正文,不解释插件协议或业务策略
|
||||
|
||||
pub(crate) mod headers;
|
||||
mod peer_route;
|
||||
pub(crate) mod websocket;
|
||||
|
||||
use std::{
|
||||
@@ -110,6 +111,7 @@ pub(crate) fn wrap(
|
||||
timeout: Option<Duration>,
|
||||
request_id_header: HeaderName,
|
||||
settings: SettingsSource,
|
||||
peer_node: HeaderValue,
|
||||
) -> Router {
|
||||
// Axum 的 route layer 在匹配后运行;外层 fallback service 才能让改写后的 URI 重新匹配内层路由
|
||||
Router::new().fallback_service(tower::service_fn(move |mut request: Request<Body>| {
|
||||
@@ -136,7 +138,10 @@ pub(crate) fn wrap(
|
||||
.and_then(|source| source(snapshot.as_deref()))
|
||||
});
|
||||
let request_id_header = request_id_header.clone();
|
||||
async move { Ok::<_, Infallible>(handle(router, request, plan, request_id_header).await) }
|
||||
let peer_node = peer_node.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(handle(router, request, plan, request_id_header, peer_node).await)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -145,13 +150,23 @@ async fn handle(
|
||||
mut request: Request<Body>,
|
||||
plan: Option<FrozenMiddlewarePlan>,
|
||||
request_id_header: HeaderName,
|
||||
peer_node: HeaderValue,
|
||||
) -> Response {
|
||||
let started = Instant::now();
|
||||
let inherited = request.extensions_mut().remove::<contract::Context>();
|
||||
if let Some(plan) = plan.as_ref().filter(|plan| plan.has_websocket()) {
|
||||
request.extensions_mut().insert(plan.clone());
|
||||
}
|
||||
let context = inherited.unwrap_or_else(|| {
|
||||
let context = if let Some(context) = inherited {
|
||||
request
|
||||
.headers_mut()
|
||||
.remove(gateway_protocol::peer::ROUTE_HEADER);
|
||||
context
|
||||
} else {
|
||||
let path = match peer_route::enter(request.headers_mut(), peer_node) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return error.response(request.uri().path()),
|
||||
};
|
||||
let request_id = request
|
||||
.headers()
|
||||
.get(&request_id_header)
|
||||
@@ -164,11 +179,12 @@ async fn handle(
|
||||
request_id,
|
||||
parent_call_id: None,
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope::default()
|
||||
.with_request(crate::request_context::capture(&request)),
|
||||
.with_request(crate::request_context::capture(&request))
|
||||
.with_peer_route(path),
|
||||
cancellation: CancellationToken::new(),
|
||||
plan: plan.clone(),
|
||||
}
|
||||
});
|
||||
};
|
||||
let context = contract::Context {
|
||||
plan: plan.clone(),
|
||||
..context
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! 入口消费互联推理路径,拒绝回到本部署的请求
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse as _, Response},
|
||||
};
|
||||
use gateway_protocol::peer::{ROUTE_HEADER, ROUTING_LOOP_CODE};
|
||||
|
||||
pub(super) enum Error {
|
||||
Invalid,
|
||||
Loop,
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub(super) fn response(self, path: &str) -> Response {
|
||||
let (status, message, code) = match self {
|
||||
Self::Invalid => (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid peer routing path.",
|
||||
"invalid_peer_route",
|
||||
),
|
||||
Self::Loop => (
|
||||
StatusCode::LOOP_DETECTED,
|
||||
"Peer routing loop detected.",
|
||||
ROUTING_LOOP_CODE,
|
||||
),
|
||||
};
|
||||
let (_, mut json) = crate::client_error::openai_error_response(
|
||||
status,
|
||||
message,
|
||||
"invalid_request_error",
|
||||
code,
|
||||
);
|
||||
if matches!(path, "/v1/messages" | "/v1/messages/count_tokens") {
|
||||
json.0["type"] = serde_json::json!("error");
|
||||
}
|
||||
(status, json).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn enter(headers: &mut HeaderMap, node: HeaderValue) -> Result<HeaderValue, Error> {
|
||||
if !headers.contains_key(ROUTE_HEADER) {
|
||||
return Ok(node);
|
||||
}
|
||||
let mut path = Vec::new();
|
||||
for value in headers.get_all(ROUTE_HEADER) {
|
||||
for part in value.to_str().map_err(|_| Error::Invalid)?.split(',') {
|
||||
let id = part.trim();
|
||||
if id.len() != 32
|
||||
|| !id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
{
|
||||
return Err(Error::Invalid);
|
||||
}
|
||||
if id.as_bytes() == node.as_bytes() {
|
||||
return Err(Error::Loop);
|
||||
}
|
||||
path.extend_from_slice(id.as_bytes());
|
||||
path.push(b',');
|
||||
}
|
||||
}
|
||||
// 消费线协议头;内部调用继承执行作用域,上游重试不能重复追加本节点
|
||||
headers.remove(ROUTE_HEADER);
|
||||
path.extend_from_slice(node.as_bytes());
|
||||
HeaderValue::from_maybe_shared(bytes::Bytes::from(path)).map_err(|_| Error::Invalid)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 客户端协议的 Bearer API key 认证
|
||||
//! 数据面客户端 Bearer 与 x-api-key 认证
|
||||
|
||||
use axum::{
|
||||
http::{HeaderMap, header::AUTHORIZATION},
|
||||
@@ -87,8 +87,9 @@ impl ClientApiKeyAuthError {
|
||||
pub(crate) async fn authenticate_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
accepted: ClientKeyHeaders,
|
||||
) -> Result<AuthenticatedClient, ClientAccessError> {
|
||||
let request = client_authentication_request(headers)?;
|
||||
let request = client_authentication_request(headers, accepted)?;
|
||||
let client = service.authenticate(request).await?;
|
||||
if let Some(identified) = identify_codex_client(headers) {
|
||||
client
|
||||
@@ -104,19 +105,38 @@ pub(crate) async fn verify_client(
|
||||
service: &OpenAiService,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AuthenticatedClient, ClientApiKeyAuthError> {
|
||||
let request = client_authentication_request(headers)?;
|
||||
let request = client_authentication_request(headers, ClientKeyHeaders::Bearer)?;
|
||||
service.verify(request).await
|
||||
}
|
||||
|
||||
/// 入口声明其认证头合同,所有形式仍进入同一 Client Key 认证链路
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ClientKeyHeaders {
|
||||
Bearer,
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
fn client_authentication_request(
|
||||
headers: &HeaderMap,
|
||||
accepted: ClientKeyHeaders,
|
||||
) -> Result<ClientAuthenticationRequest, ClientApiKeyAuthError> {
|
||||
let raw = headers
|
||||
.get(AUTHORIZATION)
|
||||
.ok_or(ClientApiKeyAuthError::MissingAuthorization)?
|
||||
.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
|
||||
let request = ClientAuthenticationRequest::new(raw.to_owned())
|
||||
let raw = if let Some(authorization) = headers.get(AUTHORIZATION) {
|
||||
authorization
|
||||
.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
|
||||
.to_owned()
|
||||
} else if matches!(accepted, ClientKeyHeaders::Anthropic)
|
||||
&& let Some(key) = headers.get("x-api-key")
|
||||
{
|
||||
format!(
|
||||
"Bearer {}",
|
||||
key.to_str()
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?
|
||||
)
|
||||
} else {
|
||||
return Err(ClientApiKeyAuthError::MissingAuthorization);
|
||||
};
|
||||
let request = ClientAuthenticationRequest::new(raw)
|
||||
.map_err(|_| ClientApiKeyAuthError::MalformedAuthorization)?;
|
||||
let request = request.with_request_context(
|
||||
crate::middleware::current().and_then(|context| context.extensions.shared_request()),
|
||||
|
||||
@@ -11,12 +11,13 @@ use axum::{
|
||||
|
||||
use crate::ApiState;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{gateway_error_response, protocol_error_response},
|
||||
responses::{
|
||||
DecodedResponsesRequest, HttpResponseAdapter, RequestDecodeError, ResponsesHttpRequest,
|
||||
decode_request_with_headers, decompress_request_body, execute_prepared_response,
|
||||
generation::{
|
||||
DecodedGenerationRequest, GenerationHttpRequest, HttpResponseAdapter,
|
||||
execute_prepared_generation,
|
||||
},
|
||||
responses::{RequestDecodeError, decode_request_with_headers, decompress_request_body},
|
||||
router::CHAT_COMPLETIONS_PATH,
|
||||
};
|
||||
|
||||
@@ -34,7 +35,7 @@ pub(in crate::openai) async fn chat_completions(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers).await {
|
||||
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -68,10 +69,10 @@ pub(in crate::openai) async fn chat_completions(
|
||||
}),
|
||||
stream_error: chat_error_frame,
|
||||
};
|
||||
execute_prepared_response(
|
||||
execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
ResponsesHttpRequest {
|
||||
GenerationHttpRequest {
|
||||
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
ingress_id,
|
||||
headers,
|
||||
@@ -88,7 +89,7 @@ fn decode_request(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum_body_bytes: usize,
|
||||
) -> Result<(DecodedResponsesRequest, Bytes, bool), RequestDecodeError> {
|
||||
) -> Result<(DecodedGenerationRequest, Bytes, bool), RequestDecodeError> {
|
||||
let body = decompress_request_body(body, headers, maximum_body_bytes)?;
|
||||
let converted = convert_request(&body)?;
|
||||
let body =
|
||||
|
||||
@@ -89,7 +89,7 @@ where
|
||||
)
|
||||
.await;
|
||||
let response = match result {
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson, "openai").await,
|
||||
Err(error) => error_response(error),
|
||||
};
|
||||
if !execution_entered.load(Ordering::Relaxed) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! 生成与计数协议共用的解码事实和 HTTP 执行边界
|
||||
|
||||
pub(crate) mod http;
|
||||
mod request;
|
||||
mod response;
|
||||
mod validation;
|
||||
|
||||
pub(crate) use http::{
|
||||
GenerationHttpRequest, HttpResponseAdapter, ResponseAuthorization, execute_prepared_generation,
|
||||
};
|
||||
pub use request::{ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata};
|
||||
|
||||
use axum::http::HeaderMap;
|
||||
use gateway_core::{
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
operation::OperationKind,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GenerationProtocol {
|
||||
Responses,
|
||||
Messages,
|
||||
MessageTokenCount,
|
||||
}
|
||||
|
||||
impl GenerationProtocol {
|
||||
pub(crate) fn from_request(request: &DecodedGenerationRequest) -> Self {
|
||||
if request.operation().kind() == OperationKind::CountTokens {
|
||||
Self::MessageTokenCount
|
||||
} else if request.operation().protocol() == "anthropic" {
|
||||
Self::Messages
|
||||
} else {
|
||||
Self::Responses
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn wire(self) -> &'static str {
|
||||
match self {
|
||||
Self::Responses => "openai",
|
||||
Self::Messages | Self::MessageTokenCount => "anthropic",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn endpoint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Responses => super::router::RESPONSES_PATH,
|
||||
Self::Messages => super::router::MESSAGES_PATH,
|
||||
Self::MessageTokenCount => super::router::MESSAGE_TOKENS_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const fn operation(self) -> OperationKind {
|
||||
match self {
|
||||
Self::Responses | Self::Messages => OperationKind::Generate,
|
||||
Self::MessageTokenCount => OperationKind::CountTokens,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode(
|
||||
self,
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum: usize,
|
||||
) -> Result<DecodedGenerationRequest, GatewayError> {
|
||||
match self {
|
||||
Self::Responses => super::responses::decode_request_with_headers(
|
||||
body, headers, maximum,
|
||||
)
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::InvalidRequest,
|
||||
"Request middleware produced an invalid Responses request.",
|
||||
)
|
||||
}),
|
||||
Self::Messages | Self::MessageTokenCount => {
|
||||
super::messages::decode_request(body, headers, maximum, self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 生成与计数入口共用的已解码操作和交付元数据
|
||||
|
||||
use gateway_core::operation::{Operation, ProviderSessionState};
|
||||
use std::{fmt, net::IpAddr};
|
||||
|
||||
/// 客户端声明的 continuation 意图
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ContinuationIntent {
|
||||
/// 不使用先前响应
|
||||
None,
|
||||
/// 使用当前调用方可见的 OpenAI response ID
|
||||
PreviousResponseId(String),
|
||||
}
|
||||
|
||||
impl ContinuationIntent {
|
||||
/// 返回待 history owner 解析的 response ID
|
||||
#[must_use]
|
||||
pub fn previous_response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::PreviousResponseId(value) => Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContinuationIntent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::None => formatter.write_str("None"),
|
||||
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler、Router 和 history owner 使用的请求元数据
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct GenerationRequestMetadata {
|
||||
pub(in crate::openai) requested_model: String,
|
||||
pub(in crate::openai) stream: bool,
|
||||
pub(in crate::openai) store: bool,
|
||||
pub(in crate::openai) continuation: ContinuationIntent,
|
||||
pub(in crate::openai) client_ip: Option<IpAddr>,
|
||||
pub(in crate::openai) user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl GenerationRequestMetadata {
|
||||
/// 返回客户端原始模型名
|
||||
#[must_use]
|
||||
pub fn requested_model(&self) -> &str {
|
||||
&self.requested_model
|
||||
}
|
||||
|
||||
/// 返回客户端是否请求 SSE
|
||||
#[must_use]
|
||||
pub const fn stream(&self) -> bool {
|
||||
self.stream
|
||||
}
|
||||
|
||||
/// 返回客户端 storage intent
|
||||
#[must_use]
|
||||
pub const fn store(&self) -> bool {
|
||||
self.store
|
||||
}
|
||||
|
||||
/// 返回 continuation intent
|
||||
#[must_use]
|
||||
pub const fn continuation(&self) -> &ContinuationIntent {
|
||||
&self.continuation
|
||||
}
|
||||
|
||||
/// 返回从 HTTP 连接边界解析出的客户端地址
|
||||
#[must_use]
|
||||
pub const fn client_ip(&self) -> Option<IpAddr> {
|
||||
self.client_ip
|
||||
}
|
||||
|
||||
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent
|
||||
#[must_use]
|
||||
pub fn user_agent(&self) -> Option<&str> {
|
||||
self.user_agent.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GenerationRequestMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("GenerationRequestMetadata")
|
||||
.field("requested_model", &self.requested_model)
|
||||
.field("stream", &self.stream)
|
||||
.field("store", &self.store)
|
||||
.field("continuation", &self.continuation)
|
||||
.field("client_ip", &self.client_ip)
|
||||
.field("has_user_agent", &self.user_agent.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次成功解码的生成或计数请求
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DecodedGenerationRequest {
|
||||
pub(in crate::openai) operation: Operation,
|
||||
pub(in crate::openai) metadata: GenerationRequestMetadata,
|
||||
}
|
||||
|
||||
impl DecodedGenerationRequest {
|
||||
pub(crate) fn with_middleware_capabilities(
|
||||
mut self,
|
||||
request: &gateway_core::engine::middleware::MiddlewareRequest,
|
||||
) -> Result<Self, gateway_core::engine::middleware::MiddlewareError> {
|
||||
self.operation = request.apply_capabilities(self.operation)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态
|
||||
#[must_use]
|
||||
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
|
||||
self.operation = self.operation.with_provider_session_state(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回协议无关 operation
|
||||
#[must_use]
|
||||
pub const fn operation(&self) -> &Operation {
|
||||
&self.operation
|
||||
}
|
||||
|
||||
/// 返回 handler 元数据
|
||||
#[must_use]
|
||||
pub const fn metadata(&self) -> &GenerationRequestMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// 拆分为 operation 与元数据
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Operation, GenerationRequestMetadata) {
|
||||
(self.operation, self.metadata)
|
||||
}
|
||||
|
||||
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实
|
||||
#[must_use]
|
||||
pub fn with_client_context(
|
||||
mut self,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
) -> Self {
|
||||
self.metadata.client_ip = client_ip;
|
||||
self.metadata.user_agent = user_agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DecodedGenerationRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DecodedGenerationRequest")
|
||||
.field("operation", &self.operation)
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 原生协议出口保留 wire,只在非流式交付时聚合消息正文
|
||||
|
||||
use axum::body::Bytes;
|
||||
use gateway_core::{error::GatewayError, event::ProviderEvent};
|
||||
use gateway_protocol::{
|
||||
anthropic::MessagesAccumulator,
|
||||
openai::sse::{
|
||||
DONE_SSE_FRAME, encode_sse_event_with_metadata, response_failed_sse_event_with_id,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::GenerationProtocol;
|
||||
use crate::openai::{
|
||||
error::gateway_error_contract,
|
||||
messages,
|
||||
responses::{OpenAiResponsesEncoder, ResponseEncodeError},
|
||||
};
|
||||
|
||||
pub(super) enum GenerationEncoder {
|
||||
Responses(OpenAiResponsesEncoder),
|
||||
Messages {
|
||||
response_id: Option<String>,
|
||||
completed: bool,
|
||||
failed: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl GenerationEncoder {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(OpenAiResponsesEncoder::new()),
|
||||
GenerationProtocol::Messages | GenerationProtocol::MessageTokenCount => {
|
||||
Self::Messages {
|
||||
response_id: None,
|
||||
completed: false,
|
||||
failed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn push_sse(&mut self, event: &ProviderEvent) -> Vec<Bytes> {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.push_sse(event),
|
||||
Self::Messages {
|
||||
response_id,
|
||||
completed,
|
||||
failed,
|
||||
} => {
|
||||
let Some(wire) = event
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == "anthropic")
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(raw) = wire.raw_json_body() {
|
||||
let events = serde_json::from_slice(raw).ok().and_then(|value| {
|
||||
gateway_protocol::anthropic::message_to_stream_events(value).ok()
|
||||
});
|
||||
let Some(events) = events else {
|
||||
*failed = true;
|
||||
return vec![messages::stream_error(
|
||||
&json!({"error":{"message":"The upstream message is invalid."}}),
|
||||
)];
|
||||
};
|
||||
*response_id = events
|
||||
.first()
|
||||
.and_then(|value| value.pointer("/message/id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
*completed = true;
|
||||
return events
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
Bytes::from(encode_sse_event_with_metadata(
|
||||
value["type"].as_str().unwrap_or_default(),
|
||||
&value.to_string(),
|
||||
None,
|
||||
None,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let kind = wire
|
||||
.event_type()
|
||||
.or_else(|| wire.data().get("type").and_then(Value::as_str));
|
||||
match kind {
|
||||
Some("message_start") => {
|
||||
*response_id = wire
|
||||
.data()
|
||||
.pointer("/message/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
Some("message_stop") => *completed = true,
|
||||
Some("error") => *failed = true,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(raw) = wire.raw_sse_frame() {
|
||||
return vec![raw.clone()];
|
||||
}
|
||||
vec![Bytes::from(encode_sse_event_with_metadata(
|
||||
kind.unwrap_or_default(),
|
||||
&wire.data().to_string(),
|
||||
wire.sse_id(),
|
||||
wire.sse_retry(),
|
||||
))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_completed(&self) -> bool {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.is_completed(),
|
||||
Self::Messages { completed, .. } => *completed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_wire_failure(&self) -> bool {
|
||||
match self {
|
||||
Self::Responses(encoder) => encoder.has_wire_failure(),
|
||||
Self::Messages { failed, .. } => *failed,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error_frame(&self, error: &GatewayError) -> Bytes {
|
||||
let (status, error_type, code) = gateway_error_contract(error.kind());
|
||||
match self {
|
||||
Self::Responses(encoder) => Bytes::from(response_failed_sse_event_with_id(
|
||||
encoder.response_id(),
|
||||
error.client_error_type().unwrap_or(error_type),
|
||||
crate::client_error::client_error_code(error.client_error_code().unwrap_or(code)),
|
||||
error.client_message(),
|
||||
)),
|
||||
Self::Messages { .. } => {
|
||||
let value = messages::error_payload(
|
||||
&json!({"error":{"message":error.client_message()}}),
|
||||
status,
|
||||
);
|
||||
messages::stream_error(&value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GenerationProtocol {
|
||||
pub(super) fn terminal_bytes(self) -> Bytes {
|
||||
match self {
|
||||
Self::Responses => Bytes::from_static(DONE_SSE_FRAME.as_bytes()),
|
||||
// Messages 的 message_stop 已在 wire 中交付,空帧只确认宿主交付终结
|
||||
Self::Messages | Self::MessageTokenCount => Bytes::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn collect(self, events: &[ProviderEvent]) -> Result<Vec<u8>, ResponseEncodeError> {
|
||||
if self == Self::Responses {
|
||||
return serde_json::to_vec(&crate::openai::responses::response::collect_response(
|
||||
events,
|
||||
)?)
|
||||
.map_err(|_| ResponseEncodeError::Serialization);
|
||||
}
|
||||
let mut accumulator = MessagesAccumulator::default();
|
||||
let mut response = None;
|
||||
for wire in events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.filter(|wire| wire.protocol() == self.wire())
|
||||
{
|
||||
if let Some(raw) = wire.raw_json_body() {
|
||||
return Ok(raw.to_vec());
|
||||
}
|
||||
let value = wire.data();
|
||||
if self == Self::MessageTokenCount {
|
||||
if value.get("input_tokens").and_then(Value::as_u64).is_some() {
|
||||
response = Some(value.clone());
|
||||
}
|
||||
} else if value.get("type").and_then(Value::as_str) == Some("message") {
|
||||
response = Some(value.clone());
|
||||
} else if wire.has_json_data()
|
||||
&& let Some(message) = accumulator
|
||||
.push(value)
|
||||
.map_err(|_| ResponseEncodeError::InvalidOutputItems)?
|
||||
{
|
||||
response = Some(message);
|
||||
}
|
||||
}
|
||||
serde_json::to_vec(&response.ok_or(ResponseEncodeError::MissingWireTerminal)?)
|
||||
.map_err(|_| ResponseEncodeError::Serialization)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! 出口校验按协议维护边界事实,不在流式路径保存生成正文
|
||||
|
||||
use gateway_core::event::ProviderEvent;
|
||||
use gateway_protocol::openai::sse::parse_sse_events;
|
||||
use serde_json::Value;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::GenerationProtocol;
|
||||
use crate::openai::responses::validation::{
|
||||
ResponseValidationFacts, ResponsesDeliveryValidator, validate_buffered_response,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) enum GenerationValidationFacts {
|
||||
Responses(ResponseValidationFacts),
|
||||
Messages(Arc<Mutex<Option<String>>>),
|
||||
Count,
|
||||
}
|
||||
|
||||
impl GenerationValidationFacts {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(ResponseValidationFacts::default()),
|
||||
GenerationProtocol::Messages => Self::Messages(Arc::default()),
|
||||
GenerationProtocol::MessageTokenCount => Self::Count,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_event(&self, event: &ProviderEvent) {
|
||||
match self {
|
||||
Self::Responses(facts) => facts.observe_event(event),
|
||||
Self::Messages(id) => {
|
||||
if let Some(value) = event
|
||||
.wire_event()
|
||||
.filter(|wire| wire.protocol() == "anthropic")
|
||||
.map(|wire| wire.data())
|
||||
&& let Some(value) = value
|
||||
.pointer("/message/id")
|
||||
.or_else(|| value.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
&& let Ok(mut id) = id.lock()
|
||||
{
|
||||
*id = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
Self::Count => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_buffered(&self, events: &[ProviderEvent], bytes: &[u8]) {
|
||||
if let Self::Responses(facts) = self {
|
||||
facts.observe_buffered(events, bytes);
|
||||
} else {
|
||||
for event in events {
|
||||
self.observe_event(event);
|
||||
}
|
||||
if let Self::Messages(id) = self
|
||||
&& let Ok(value) = serde_json::from_slice::<Value>(bytes)
|
||||
&& let Some(value) = value.get("id").and_then(Value::as_str)
|
||||
&& let Ok(mut id) = id.lock()
|
||||
{
|
||||
*id = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Responses(facts) => facts.response_id(),
|
||||
Self::Messages(id) => id.lock().ok().and_then(|id| id.clone()),
|
||||
Self::Count => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_buffered(&self, bytes: &[u8]) -> Result<(), ()> {
|
||||
match self {
|
||||
Self::Responses(facts) => validate_buffered_response(facts, bytes).map_err(|_| ()),
|
||||
Self::Messages(_) => {
|
||||
let value: Value = serde_json::from_slice(bytes).map_err(|_| ())?;
|
||||
let id = value.get("id").and_then(Value::as_str).ok_or(())?;
|
||||
if value.get("type").and_then(Value::as_str) != Some("message")
|
||||
|| !value.get("content").is_some_and(Value::is_array)
|
||||
|| self.response_id().is_some_and(|expected| expected != id)
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Count => serde_json::from_slice::<Value>(bytes)
|
||||
.ok()
|
||||
.and_then(|value| value.get("input_tokens").and_then(Value::as_u64))
|
||||
.map(|_| ())
|
||||
.ok_or(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) enum GenerationDeliveryValidator {
|
||||
Responses(ResponsesDeliveryValidator),
|
||||
Messages { id: Option<String>, finished: bool },
|
||||
}
|
||||
|
||||
impl GenerationDeliveryValidator {
|
||||
pub(super) fn new(protocol: GenerationProtocol) -> Self {
|
||||
match protocol {
|
||||
GenerationProtocol::Responses => Self::Responses(ResponsesDeliveryValidator::default()),
|
||||
_ => Self::Messages {
|
||||
id: None,
|
||||
finished: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Responses(validator) => validator.response_id(),
|
||||
Self::Messages { id, .. } => id.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_frame(
|
||||
&mut self,
|
||||
bytes: &[u8],
|
||||
transformed: bool,
|
||||
facts: &GenerationValidationFacts,
|
||||
) -> Result<(), ()> {
|
||||
match (self, facts) {
|
||||
(Self::Responses(validator), GenerationValidationFacts::Responses(facts)) => validator
|
||||
.validate_frame(bytes, transformed, facts)
|
||||
.map_err(|_| ()),
|
||||
(Self::Messages { id, finished }, GenerationValidationFacts::Messages(_)) => {
|
||||
if bytes.is_empty() {
|
||||
return if *finished { Ok(()) } else { Err(()) };
|
||||
}
|
||||
for event in
|
||||
parse_sse_events(std::str::from_utf8(bytes).map_err(|_| ())?).map_err(|_| ())?
|
||||
{
|
||||
let value: Value = match serde_json::from_str(&event.data) {
|
||||
Ok(value) => value,
|
||||
Err(_) if !transformed => continue,
|
||||
Err(_) => return Err(()),
|
||||
};
|
||||
let kind = value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if *finished {
|
||||
return Err(());
|
||||
}
|
||||
match kind {
|
||||
"message_start" => {
|
||||
let next = value
|
||||
.pointer("/message/id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or(())?;
|
||||
if id.is_some()
|
||||
|| (transformed
|
||||
&& facts.response_id().is_some_and(|expected| expected != next))
|
||||
{
|
||||
return Err(());
|
||||
}
|
||||
*id = Some(next.to_owned());
|
||||
}
|
||||
"message_stop" => {
|
||||
if id.is_none() {
|
||||
return Err(());
|
||||
}
|
||||
*finished = true;
|
||||
}
|
||||
"error" => *finished = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish_delivery(&self) -> Result<(), ()> {
|
||||
match self {
|
||||
Self::Responses(validator) => validator.finish_delivery().map_err(|_| ()),
|
||||
Self::Messages { finished: true, .. } => Ok(()),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use crate::ApiState;
|
||||
use crate::middleware::headers::encode_headers;
|
||||
use crate::openai::middleware::RequestInput;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
endpoint::provider_endpoint_response,
|
||||
responses::{OpenAiRequestHeaders, request_client_context},
|
||||
router::{IMAGE_EDITS_PATH, IMAGE_GENERATIONS_PATH},
|
||||
@@ -73,7 +73,7 @@ async fn handle_image_request(
|
||||
endpoint: &'static str,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ use super::{
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{engine_error_response, gateway_error_response},
|
||||
middleware::PendingExecution,
|
||||
responses::request_client_context,
|
||||
@@ -42,7 +42,7 @@ pub(crate) async fn live_call(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -116,7 +116,7 @@ pub(crate) async fn hangup(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -22,7 +22,7 @@ use super::LiveErrorShape;
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::runtime_unavailable_response,
|
||||
live::{
|
||||
filter_protocol_headers, live_error_response, offered_subprotocols,
|
||||
@@ -110,7 +110,7 @@ async fn open_sideband(
|
||||
return upgrade_required_response();
|
||||
};
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Messages 与输入计数的 HTTP 入口和原生错误表达
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State, connect_info::ConnectInfo},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header::CONTENT_TYPE},
|
||||
response::Response,
|
||||
};
|
||||
use gateway_core::{
|
||||
error::{GatewayError, GatewayErrorKind},
|
||||
operation::{GenerateRequest, Operation, ProtocolPayload, RawJsonPayload, TokenCountRequest},
|
||||
};
|
||||
use gateway_protocol::{anthropic, openai::sse::encode_sse_event};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::gateway_error_response,
|
||||
generation::{
|
||||
ContinuationIntent, DecodedGenerationRequest, GenerationHttpRequest, GenerationProtocol,
|
||||
GenerationRequestMetadata, execute_prepared_generation,
|
||||
},
|
||||
responses::{OpenAiRequestHeaders, decompress_request_body},
|
||||
};
|
||||
use crate::ApiState;
|
||||
|
||||
pub(super) async fn messages(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
execute(
|
||||
state,
|
||||
connect_info,
|
||||
ingress_id,
|
||||
headers,
|
||||
body,
|
||||
GenerationProtocol::Messages,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn count_tokens(
|
||||
State(state): State<ApiState>,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
execute(
|
||||
state,
|
||||
connect_info,
|
||||
ingress_id,
|
||||
headers,
|
||||
body,
|
||||
GenerationProtocol::MessageTokenCount,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
state: ApiState,
|
||||
connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
|
||||
ingress_id: Option<Extension<tower_http::request_id::RequestId>>,
|
||||
mut headers: HeaderMap,
|
||||
body: Bytes,
|
||||
protocol: GenerationProtocol,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return project_error_response(client_access_error_response(error)).await,
|
||||
};
|
||||
let maximum = client.snapshot().responses_max_decompressed_body_bytes();
|
||||
let body = match decompress_request_body(&body, &headers, maximum) {
|
||||
Ok(body) => Bytes::copy_from_slice(&body),
|
||||
Err(error) => {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_request_error",
|
||||
&error.protocol_body().error.message,
|
||||
);
|
||||
}
|
||||
};
|
||||
headers.remove(axum::http::header::CONTENT_ENCODING);
|
||||
headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
let decoded = match decode_request(&body, &headers, maximum, protocol) {
|
||||
Ok(decoded) => decoded,
|
||||
Err(error) => return project_error_response(gateway_error_response(&error)).await,
|
||||
};
|
||||
let prepared = match service.execution().prepare_execution(client).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return project_error_response(gateway_error_response(&error)).await,
|
||||
};
|
||||
execute_prepared_generation(
|
||||
service,
|
||||
prepared,
|
||||
GenerationHttpRequest {
|
||||
peer_address: connect_info.map(|Extension(ConnectInfo(address))| address),
|
||||
ingress_id,
|
||||
headers,
|
||||
decoded,
|
||||
middleware_body: body,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) fn decode_request(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
maximum: usize,
|
||||
protocol: GenerationProtocol,
|
||||
) -> Result<DecodedGenerationRequest, GatewayError> {
|
||||
let invalid = |message| GatewayError::new(GatewayErrorKind::InvalidRequest, message);
|
||||
let body = decompress_request_body(body, headers, maximum)
|
||||
.map_err(|_| invalid("Request body could not be decoded."))?;
|
||||
let object = serde_json::from_slice::<Value>(&body)
|
||||
.ok()
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.ok_or_else(|| invalid("Request body must be a JSON object."))?;
|
||||
let counted = protocol == GenerationProtocol::MessageTokenCount;
|
||||
let validation = if counted {
|
||||
anthropic::validate_count_request(&object)
|
||||
} else {
|
||||
anthropic::validate_request(&object)
|
||||
};
|
||||
validation.map_err(|error| {
|
||||
invalid(match error {
|
||||
anthropic::TranslationError::InvalidRequest(message) => message,
|
||||
_ => "Messages request is invalid.",
|
||||
})
|
||||
})?;
|
||||
let model = object
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
let stream = !counted
|
||||
&& object
|
||||
.get("stream")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let context = OpenAiRequestHeaders::from_headers(headers).session_context();
|
||||
let operation = if counted {
|
||||
let payload = RawJsonPayload::new(protocol.wire(), Bytes::copy_from_slice(&body))
|
||||
.map_err(|_| invalid("Request protocol is invalid."))?
|
||||
.with_context(context);
|
||||
Operation::CountTokens(TokenCountRequest::from_raw_json(payload))
|
||||
} else {
|
||||
let payload = ProtocolPayload::json_object(protocol.wire(), object)
|
||||
.map_err(|_| invalid("Request protocol is invalid."))?
|
||||
.with_context(context);
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload))
|
||||
};
|
||||
Ok(DecodedGenerationRequest {
|
||||
operation,
|
||||
metadata: GenerationRequestMetadata {
|
||||
requested_model: model,
|
||||
stream,
|
||||
store: false,
|
||||
continuation: ContinuationIntent::None,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn error_type(status: StatusCode) -> &'static str {
|
||||
match status.as_u16() {
|
||||
400 | 405 | 422 => "invalid_request_error",
|
||||
401 => "authentication_error",
|
||||
403 => "permission_error",
|
||||
404 => "not_found_error",
|
||||
413 => "request_too_large",
|
||||
429 => "rate_limit_error",
|
||||
503 | 529 => "overloaded_error",
|
||||
_ => "api_error",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error_payload(value: &Value, status: StatusCode) -> Value {
|
||||
if value.get("type").and_then(Value::as_str) == Some("error") && value.get("error").is_some() {
|
||||
return value.clone();
|
||||
}
|
||||
let error = value
|
||||
.pointer("/response/error")
|
||||
.or_else(|| value.get("error"))
|
||||
.unwrap_or(value);
|
||||
// 转接 Responses 的互联拒绝时保留机器码,下一节点需能识别相同的环路
|
||||
if gateway_protocol::peer::is_routing_loop(
|
||||
Some(status.as_u16()),
|
||||
error.get("code").and_then(Value::as_str),
|
||||
) {
|
||||
return json!({"type": "error", "error": error});
|
||||
}
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("The gateway could not complete the request.");
|
||||
json!({"type":"error", "error":{"type":error_type(status), "message":message}})
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, kind: &str, message: &str) -> Response {
|
||||
let mut response = Response::new(Body::from(
|
||||
json!({"type":"error", "error":{"type":kind,"message":message}}).to_string(),
|
||||
));
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) async fn project_error_response(response: Response) -> Response {
|
||||
if response.status().is_success() {
|
||||
return response;
|
||||
}
|
||||
let (mut parts, body) = response.into_parts();
|
||||
let bytes = axum::body::to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let value = serde_json::from_slice(&bytes)
|
||||
.unwrap_or_else(|_| json!({"error":{"message":String::from_utf8_lossy(&bytes)}}));
|
||||
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
|
||||
parts
|
||||
.headers
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
let projected = error_payload(&value, parts.status);
|
||||
Response::from_parts(parts, Body::from(projected.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn stream_error(value: &Value) -> Bytes {
|
||||
Bytes::from(encode_sse_event(
|
||||
"error",
|
||||
&error_payload(value, StatusCode::INTERNAL_SERVER_ERROR).to_string(),
|
||||
))
|
||||
}
|
||||
@@ -94,9 +94,10 @@ impl ExpectedBody {
|
||||
pub(crate) async fn into_http_response(
|
||||
response: MiddlewareResponse,
|
||||
expected: ExpectedBody,
|
||||
expected_protocol: &str,
|
||||
) -> Response {
|
||||
let (protocol, status, headers, mut body, _) = response.into_parts();
|
||||
if protocol != "openai" {
|
||||
if protocol != expected_protocol {
|
||||
return fail_before_commit(body, MiddlewareError::InvalidState).await;
|
||||
}
|
||||
let head = response_head(status, headers);
|
||||
|
||||
@@ -64,6 +64,7 @@ where
|
||||
Err(error) => return gateway_error_response(&error),
|
||||
};
|
||||
let protocol = input.protocol.clone();
|
||||
let expected_protocol = protocol.clone();
|
||||
let result = invoke_request(
|
||||
execution,
|
||||
prepared,
|
||||
@@ -79,7 +80,9 @@ where
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(response) => into_http_response(response, ExpectedBody::SingleJson).await,
|
||||
Ok(response) => {
|
||||
into_http_response(response, ExpectedBody::SingleJson, &expected_protocol).await
|
||||
}
|
||||
Err(error) => error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ pub mod auth;
|
||||
mod chat_completions;
|
||||
mod endpoint;
|
||||
pub mod error;
|
||||
pub(crate) mod generation;
|
||||
pub mod images;
|
||||
pub(crate) mod live;
|
||||
mod messages;
|
||||
pub(crate) mod middleware;
|
||||
pub mod models;
|
||||
mod peer_link;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 模型目录 HTTP adapter
|
||||
//! 按显式协议头投影模型目录,Codex 目录保留独立合同
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -19,7 +19,7 @@ use crate::{
|
||||
};
|
||||
|
||||
use super::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{model_not_found_response, openai_error_response},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,9 @@ const CODEX_BASE_INSTRUCTIONS: &str = "You are Codex, a coding agent. Follow the
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ModelsQuery {
|
||||
client_version: Option<String>,
|
||||
after_id: Option<String>,
|
||||
before_id: Option<String>,
|
||||
limit: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /v1/models`
|
||||
@@ -38,28 +41,37 @@ pub(crate) async fn models(
|
||||
Query(query): Query<ModelsQuery>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
Err(error) => {
|
||||
return model_protocol_response(client_access_error_response(error), anthropic).await;
|
||||
}
|
||||
};
|
||||
|
||||
middleware::query_response(
|
||||
service.execution(),
|
||||
client,
|
||||
RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers),
|
||||
move |client| models_response(state, client, query),
|
||||
)
|
||||
.await
|
||||
let mut input = RequestInput::query(super::router::MODELS_PATH.to_owned(), None, &headers);
|
||||
if anthropic {
|
||||
input.protocol = "anthropic".to_owned();
|
||||
}
|
||||
let response = middleware::query_response(service.execution(), client, input, move |client| {
|
||||
models_response(state, client, query, anthropic)
|
||||
})
|
||||
.await;
|
||||
model_protocol_response(response, anthropic).await
|
||||
}
|
||||
|
||||
async fn models_response(
|
||||
state: ApiState,
|
||||
client: AuthenticatedClient,
|
||||
query: ModelsQuery,
|
||||
anthropic: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
|
||||
if anthropic {
|
||||
return anthropic_models_response(service.public_models(&client), &query);
|
||||
}
|
||||
if let Some(version) = query
|
||||
.client_version
|
||||
.as_deref()
|
||||
@@ -147,28 +159,37 @@ pub(crate) async fn model_detail(
|
||||
headers: HeaderMap,
|
||||
Path(model_id): Path<String>,
|
||||
) -> Response {
|
||||
let anthropic = headers.contains_key("anthropic-version") || headers.contains_key("x-api-key");
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Anthropic).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
Err(error) => {
|
||||
return model_protocol_response(client_access_error_response(error), anthropic).await;
|
||||
}
|
||||
};
|
||||
middleware::query_response(
|
||||
let mut input = RequestInput::query(
|
||||
format!("{}/{model_id}", super::router::MODELS_PATH),
|
||||
Some(model_id.clone()),
|
||||
&headers,
|
||||
);
|
||||
if anthropic {
|
||||
input.protocol = "anthropic".to_owned();
|
||||
}
|
||||
let response = middleware::query_response(
|
||||
service.execution(),
|
||||
client,
|
||||
RequestInput::query(
|
||||
format!("{}/{model_id}", super::router::MODELS_PATH),
|
||||
Some(model_id.clone()),
|
||||
&headers,
|
||||
),
|
||||
move |client| async move { model_detail_response(&state, &client, model_id) },
|
||||
input,
|
||||
move |client| async move { model_detail_response(&state, &client, model_id, anthropic) },
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
model_protocol_response(response, anthropic).await
|
||||
}
|
||||
|
||||
fn model_detail_response(
|
||||
state: &ApiState,
|
||||
client: &AuthenticatedClient,
|
||||
model_id: String,
|
||||
anthropic: bool,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let Ok(public_model) = PublicModelId::new(model_id) else {
|
||||
@@ -180,11 +201,76 @@ fn model_detail_response(
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(openai_model_json(public_model.as_str())),
|
||||
Json(if anthropic {
|
||||
anthropic_model_json(public_model.as_str())
|
||||
} else {
|
||||
openai_model_json(public_model.as_str())
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn model_protocol_response(response: Response, anthropic: bool) -> Response {
|
||||
if anthropic {
|
||||
super::messages::project_error_response(response).await
|
||||
} else {
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_models_response(models: Vec<String>, query: &ModelsQuery) -> Response {
|
||||
let limit = match query.limit.as_deref().unwrap_or("20").parse::<usize>() {
|
||||
Ok(limit @ 1..=1000) => limit,
|
||||
_ => {
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Model list limit must be between 1 and 1000.",
|
||||
"invalid_request_error",
|
||||
"invalid_limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let cursor = |value: &Option<String>| -> Result<Option<usize>, ()> {
|
||||
value
|
||||
.as_ref()
|
||||
.map(|id| models.iter().position(|model| model == id).ok_or(()))
|
||||
.transpose()
|
||||
};
|
||||
let (Ok(after), Ok(before)) = (cursor(&query.after_id), cursor(&query.before_id)) else {
|
||||
return openai_error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"The model pagination cursor was not found.",
|
||||
"invalid_request_error",
|
||||
"invalid_cursor",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let start = after.map_or(0, |index| index + 1);
|
||||
let end = before.unwrap_or(models.len());
|
||||
let range = if before.is_some() {
|
||||
start.max(end.saturating_sub(limit))..end
|
||||
} else {
|
||||
start..end.min(start.saturating_add(limit))
|
||||
};
|
||||
let data = models
|
||||
.get(range.clone())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|model| anthropic_model_json(model))
|
||||
.collect::<Vec<_>>();
|
||||
let has_more = if before.is_some() {
|
||||
range.start > start
|
||||
} else {
|
||||
range.end < end
|
||||
};
|
||||
Json(json!({"first_id":data.first().and_then(|model| model.get("id")), "last_id":data.last().and_then(|model| model.get("id")), "has_more":has_more, "data":data})).into_response()
|
||||
}
|
||||
|
||||
fn anthropic_model_json(id: &str) -> Value {
|
||||
json!({"id":id, "type":"model", "display_name":id, "created_at":"1970-01-01T00:00:00Z", "capabilities":null, "max_input_tokens":null, "max_tokens":null, "line":null, "lifecycle":"active", "deprecated_at":null, "retires_at":null})
|
||||
}
|
||||
|
||||
fn openai_model_json(id: &str) -> Value {
|
||||
json!({
|
||||
"id": id,
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use gateway_admin::model::peer_link::{PeerLinkExport, PeerModelState as ModelState};
|
||||
use gateway_core::metering::Decimal;
|
||||
use gateway_core::{engine::budget::ClientBudgetWindow, metering::Decimal};
|
||||
use gateway_protocol::peer::{
|
||||
OBJECT, PeerBudgetWindow, PeerComponentStatus, PeerKeyLimits, PeerKeyUsage, PeerLink,
|
||||
PeerModelAvailability, PeerModelCoverage, PeerModelState, PeerPlanCount, PeerPool,
|
||||
PeerPoolStates, PeerQuota, PeerQuotaAccount, PeerQuotaWindow, PeerUsage, SCHEMA_VERSION,
|
||||
};
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
let mut capabilities = vec!["key_usage".to_owned(), "key_limits".to_owned()];
|
||||
@@ -88,19 +87,17 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
budget_window(
|
||||
"daily",
|
||||
"日额度",
|
||||
86400,
|
||||
export.budget.limits.daily_usd,
|
||||
export.budget.daily_used_usd,
|
||||
export.budget.daily_resets_at,
|
||||
export.budget.daily_window,
|
||||
export.budget_access.daily_limit_reached,
|
||||
),
|
||||
budget_window(
|
||||
"weekly",
|
||||
"周额度",
|
||||
604800,
|
||||
export.budget.limits.weekly_usd,
|
||||
export.budget.weekly_used_usd,
|
||||
export.budget.weekly_resets_at,
|
||||
export.budget.weekly_window,
|
||||
export.budget_access.weekly_limit_reached,
|
||||
),
|
||||
],
|
||||
@@ -133,21 +130,24 @@ pub(super) fn response(export: PeerLinkExport) -> PeerLink {
|
||||
fn budget_window(
|
||||
key: &str,
|
||||
label: &str,
|
||||
window_seconds: u64,
|
||||
total: Decimal,
|
||||
used: Decimal,
|
||||
resets_at: Option<SystemTime>,
|
||||
window: Option<ClientBudgetWindow>,
|
||||
limit_reached: bool,
|
||||
) -> PeerBudgetWindow {
|
||||
let limited = total != Decimal::ZERO;
|
||||
let window = window.filter(|w| w.start < w.end);
|
||||
PeerBudgetWindow {
|
||||
key: key.to_owned(),
|
||||
label: Some(label.to_owned()),
|
||||
window_seconds: Some(window_seconds),
|
||||
window_seconds: window.and_then(|w| {
|
||||
let duration = w.end.duration_since(w.start).ok()?;
|
||||
(duration.subsec_nanos() == 0).then_some(duration.as_secs())
|
||||
}),
|
||||
limit_reached: Some(limit_reached),
|
||||
total: limited.then(|| total.canonical()),
|
||||
used: used.canonical(),
|
||||
remaining: limited.then(|| total.saturating_sub(used).canonical()),
|
||||
reset_at: resets_at.map(|time| DateTime::<Utc>::from(time).to_rfc3339()),
|
||||
reset_at: window.map(|w| DateTime::<Utc>::from(w.end).to_rfc3339()),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,17 +18,18 @@ use tokio::sync::oneshot;
|
||||
|
||||
use crate::openai::middleware::{SSE_KEEPALIVE_FRAME, SSE_KEEPALIVE_INTERVAL};
|
||||
|
||||
pub(super) async fn response(
|
||||
pub(in crate::openai) async fn response(
|
||||
response: impl Future<Output = Response> + Send + 'static,
|
||||
admitted: oneshot::Receiver<ResponseCommit>,
|
||||
) -> Response {
|
||||
response_with_encoder(response, admitted, None).await
|
||||
response_with_encoder(response, admitted, None, true).await
|
||||
}
|
||||
|
||||
pub(super) async fn response_with_encoder(
|
||||
pub(in crate::openai) async fn response_with_encoder(
|
||||
response: impl Future<Output = Response> + Send + 'static,
|
||||
admitted: oneshot::Receiver<ResponseCommit>,
|
||||
error_encoder: Option<fn(&Value) -> Bytes>,
|
||||
append_done: bool,
|
||||
) -> Response {
|
||||
let mut response = Box::pin(response);
|
||||
// 鉴权、模型门禁和准入失败仍保留原 HTTP 状态,不提前建立 SSE
|
||||
@@ -48,7 +49,7 @@ pub(super) async fn response_with_encoder(
|
||||
if !commit.record(Some(StatusCode::OK.as_u16())) {
|
||||
return response.await;
|
||||
}
|
||||
let pending = PendingResponse::Waiting(response, error_encoder);
|
||||
let pending = PendingResponse::Waiting(response, error_encoder, append_done);
|
||||
let stream = stream::once(async { Ok::<_, axum::Error>(SSE_KEEPALIVE_FRAME) })
|
||||
.chain(stream::try_unfold(pending, PendingResponse::next));
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
@@ -66,7 +67,11 @@ pub(super) async fn response_with_encoder(
|
||||
}
|
||||
|
||||
enum PendingResponse {
|
||||
Waiting(BoxFuture<'static, Response>, Option<fn(&Value) -> Bytes>),
|
||||
Waiting(
|
||||
BoxFuture<'static, Response>,
|
||||
Option<fn(&Value) -> Bytes>,
|
||||
bool,
|
||||
),
|
||||
Streaming(BodyDataStream),
|
||||
}
|
||||
|
||||
@@ -74,7 +79,7 @@ impl PendingResponse {
|
||||
async fn next(mut self) -> Result<Option<(Bytes, Self)>, axum::Error> {
|
||||
loop {
|
||||
match &mut self {
|
||||
Self::Waiting(response, error_encoder) => {
|
||||
Self::Waiting(response, error_encoder, append_done) => {
|
||||
// 心跳只轮询同一个 future,不取消重建首帧等待或插件调用
|
||||
let response = tokio::select! {
|
||||
biased;
|
||||
@@ -93,7 +98,7 @@ impl PendingResponse {
|
||||
let body = if sse {
|
||||
response.into_body()
|
||||
} else {
|
||||
stream_failure(response, *error_encoder).await
|
||||
stream_failure(response, *error_encoder, *append_done).await
|
||||
};
|
||||
// 正式响应的正文已经带有交付期心跳,后续只转交,不叠加定时器
|
||||
self = Self::Streaming(body.into_data_stream());
|
||||
@@ -110,7 +115,11 @@ impl PendingResponse {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) -> Bytes>) -> Body {
|
||||
async fn stream_failure(
|
||||
response: Response,
|
||||
error_encoder: Option<fn(&Value) -> Bytes>,
|
||||
append_done: bool,
|
||||
) -> Body {
|
||||
let status = response.status();
|
||||
let retry_after = response
|
||||
.headers()
|
||||
@@ -157,6 +166,8 @@ async fn stream_failure(response: Response, error_encoder: Option<fn(&Value) ->
|
||||
Some(encode) => encode(&data).to_vec(),
|
||||
None => encode_sse_event("response.failed", &data.to_string()).into_bytes(),
|
||||
};
|
||||
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
|
||||
if append_done {
|
||||
event.extend_from_slice(DONE_SSE_FRAME.as_bytes());
|
||||
}
|
||||
Body::from(event)
|
||||
}
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
|
||||
mod error;
|
||||
mod http;
|
||||
mod keepalive;
|
||||
mod request;
|
||||
mod response;
|
||||
mod validation;
|
||||
pub(in crate::openai) mod keepalive;
|
||||
pub(in crate::openai) mod request;
|
||||
pub(in crate::openai) mod response;
|
||||
pub(in crate::openai) mod validation;
|
||||
pub mod websocket;
|
||||
|
||||
pub use crate::client_error::{ProtocolError, ProtocolErrorBody};
|
||||
pub use crate::openai::generation::http::{collect_execution_response, stream_execution_response};
|
||||
pub use crate::openai::generation::{
|
||||
ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata,
|
||||
};
|
||||
pub(crate) use crate::request_context::request_client_context;
|
||||
pub use error::{RequestDecodeError, ResponseEncodeError};
|
||||
pub(in crate::openai) use http::{HttpResponseAdapter, execute_prepared_response};
|
||||
pub(crate) use http::{
|
||||
ResponseAuthorization, ResponsesHttpRequest, execute_prepared_responses, responses,
|
||||
};
|
||||
pub use http::{collect_execution_response, stream_execution_response};
|
||||
pub(crate) use http::responses;
|
||||
pub(in crate::openai) use request::decompress_request_body;
|
||||
pub use request::{
|
||||
ContinuationIntent, DecodedResponsesRequest, OpenAiRequestHeaders, ResponsesRequestMetadata,
|
||||
decode_request_with_headers,
|
||||
};
|
||||
pub use request::{OpenAiRequestHeaders, decode_request_with_headers};
|
||||
pub(crate) use request::{decode_request_with_body, passthrough_request_headers};
|
||||
pub use response::OpenAiResponsesEncoder;
|
||||
pub(crate) use websocket::responses_websocket;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
//! OpenAI Responses JSON 到 Core 路由事实与不透明 wire payload 的单一解码边界
|
||||
|
||||
use std::{borrow::Cow, fmt, net::IpAddr};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use bytes::Bytes;
|
||||
use gateway_core::operation::{
|
||||
ClientTurn, GenerateRequest, Operation, ProtocolPayload, ProviderSessionState,
|
||||
};
|
||||
use gateway_core::operation::{ClientTurn, GenerateRequest, Operation, ProtocolPayload};
|
||||
use gateway_protocol::openai::{
|
||||
X_OPENAI_INTERNAL_CODEX_RESPONSES_LITE_HEADER, X_OPENAI_MEMGEN_REQUEST_HEADER,
|
||||
is_transport_managed_request_header,
|
||||
@@ -15,6 +13,9 @@ use gateway_protocol::openai::{
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::error::RequestDecodeError;
|
||||
use crate::openai::generation::{
|
||||
ContinuationIntent, DecodedGenerationRequest, GenerationRequestMetadata,
|
||||
};
|
||||
|
||||
const OPENAI_PROTOCOL: &str = "openai";
|
||||
const OPENAI_SUBAGENT_KEY: &str = "x-openai-subagent";
|
||||
@@ -179,162 +180,6 @@ fn insert_protocol_context(context: &mut Map<String, Value>, field: &str, value:
|
||||
}
|
||||
}
|
||||
|
||||
/// 客户端声明的 continuation 意图
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum ContinuationIntent {
|
||||
/// 不使用先前响应
|
||||
None,
|
||||
/// 使用当前调用方可见的 OpenAI response ID
|
||||
PreviousResponseId(String),
|
||||
}
|
||||
|
||||
impl ContinuationIntent {
|
||||
/// 返回待 history owner 解析的 response ID
|
||||
#[must_use]
|
||||
pub fn previous_response_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
Self::PreviousResponseId(value) => Some(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContinuationIntent {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::None => formatter.write_str("None"),
|
||||
Self::PreviousResponseId(_) => formatter.write_str("PreviousResponseId(<redacted>)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler、Router 和 history owner 使用的请求元数据
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ResponsesRequestMetadata {
|
||||
requested_model: String,
|
||||
stream: bool,
|
||||
store: bool,
|
||||
continuation: ContinuationIntent,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
}
|
||||
|
||||
impl ResponsesRequestMetadata {
|
||||
/// 返回客户端原始模型名
|
||||
#[must_use]
|
||||
pub fn requested_model(&self) -> &str {
|
||||
&self.requested_model
|
||||
}
|
||||
|
||||
/// 返回客户端是否请求 SSE
|
||||
#[must_use]
|
||||
pub const fn stream(&self) -> bool {
|
||||
self.stream
|
||||
}
|
||||
|
||||
/// 返回客户端 storage intent
|
||||
#[must_use]
|
||||
pub const fn store(&self) -> bool {
|
||||
self.store
|
||||
}
|
||||
|
||||
/// 返回 continuation intent
|
||||
#[must_use]
|
||||
pub const fn continuation(&self) -> &ContinuationIntent {
|
||||
&self.continuation
|
||||
}
|
||||
|
||||
/// 返回从 HTTP 连接边界解析出的客户端地址
|
||||
#[must_use]
|
||||
pub const fn client_ip(&self) -> Option<IpAddr> {
|
||||
self.client_ip
|
||||
}
|
||||
|
||||
/// 返回经过 UTF-8 校验和空白归一化的 User-Agent
|
||||
#[must_use]
|
||||
pub fn user_agent(&self) -> Option<&str> {
|
||||
self.user_agent.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ResponsesRequestMetadata {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ResponsesRequestMetadata")
|
||||
.field("requested_model", &self.requested_model)
|
||||
.field("stream", &self.stream)
|
||||
.field("store", &self.store)
|
||||
.field("continuation", &self.continuation)
|
||||
.field("client_ip", &self.client_ip)
|
||||
.field("has_user_agent", &self.user_agent.is_some())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次成功解码的 Responses 请求
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DecodedResponsesRequest {
|
||||
operation: Operation,
|
||||
metadata: ResponsesRequestMetadata,
|
||||
}
|
||||
|
||||
impl DecodedResponsesRequest {
|
||||
pub(crate) fn with_middleware_capabilities(
|
||||
mut self,
|
||||
request: &gateway_core::engine::middleware::MiddlewareRequest,
|
||||
) -> Result<Self, gateway_core::engine::middleware::MiddlewareError> {
|
||||
self.operation = request.apply_capabilities(self.operation)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// 附着当前 WebSocket 连接保存的 Provider 私有上一轮状态
|
||||
#[must_use]
|
||||
pub fn with_provider_session_state(mut self, state: ProviderSessionState) -> Self {
|
||||
self.operation = self.operation.with_provider_session_state(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回协议无关 operation
|
||||
#[must_use]
|
||||
pub const fn operation(&self) -> &Operation {
|
||||
&self.operation
|
||||
}
|
||||
|
||||
/// 返回 handler 元数据
|
||||
#[must_use]
|
||||
pub const fn metadata(&self) -> &ResponsesRequestMetadata {
|
||||
&self.metadata
|
||||
}
|
||||
|
||||
/// 拆分为 operation 与元数据
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (Operation, ResponsesRequestMetadata) {
|
||||
(self.operation, self.metadata)
|
||||
}
|
||||
|
||||
/// 附着只由 HTTP/WebSocket 连接边界提供的诊断事实
|
||||
#[must_use]
|
||||
pub fn with_client_context(
|
||||
mut self,
|
||||
client_ip: Option<IpAddr>,
|
||||
user_agent: Option<String>,
|
||||
) -> Self {
|
||||
self.metadata.client_ip = client_ip;
|
||||
self.metadata.user_agent = user_agent;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DecodedResponsesRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DecodedResponsesRequest")
|
||||
.field("operation", &self.operation)
|
||||
.field("metadata", &self.metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// 使用下游 OpenAI/Codex 请求头解码 `POST /v1/responses`
|
||||
///
|
||||
/// # Errors
|
||||
@@ -344,7 +189,7 @@ pub fn decode_request_with_headers(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
max_decompressed_bytes: usize,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
|
||||
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
|
||||
decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))
|
||||
}
|
||||
@@ -357,7 +202,7 @@ pub(crate) fn decode_request_with_body(
|
||||
body: &[u8],
|
||||
headers: &HeaderMap,
|
||||
max_decompressed_bytes: usize,
|
||||
) -> Result<(DecodedResponsesRequest, Bytes), RequestDecodeError> {
|
||||
) -> Result<(DecodedGenerationRequest, Bytes), RequestDecodeError> {
|
||||
let body = decompress_request_body(body, headers, max_decompressed_bytes)?;
|
||||
let decoded = decode_request_inner(&body, &OpenAiRequestHeaders::from_headers(headers))?;
|
||||
Ok((decoded, Bytes::copy_from_slice(&body)))
|
||||
@@ -449,7 +294,7 @@ fn read_bounded<R: std::io::Read>(
|
||||
pub(super) fn decode_request_inner(
|
||||
body: &[u8],
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
|
||||
let value =
|
||||
serde_json::from_slice::<Value>(body).map_err(|_| RequestDecodeError::MalformedJson)?;
|
||||
let Value::Object(object) = value else {
|
||||
@@ -463,7 +308,7 @@ pub(super) fn decode_request_object(
|
||||
mut object: Map<String, Value>,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
source: RequestDecodeSource,
|
||||
) -> Result<DecodedResponsesRequest, RequestDecodeError> {
|
||||
) -> Result<DecodedGenerationRequest, RequestDecodeError> {
|
||||
// 仅消费已识别的本地 transport 开关;未知同名值保留给未来上游协议
|
||||
let use_websocket = object.get("use_websocket").and_then(Value::as_bool);
|
||||
if use_websocket.is_some() {
|
||||
@@ -523,9 +368,9 @@ pub(super) fn decode_request_object(
|
||||
let operation = Operation::Generate(
|
||||
GenerateRequest::from_protocol_payload(payload).with_client_turn(client_turn),
|
||||
);
|
||||
Ok(DecodedResponsesRequest {
|
||||
Ok(DecodedGenerationRequest {
|
||||
operation,
|
||||
metadata: ResponsesRequestMetadata {
|
||||
metadata: GenerationRequestMetadata {
|
||||
requested_model: model,
|
||||
stream,
|
||||
store,
|
||||
|
||||
@@ -187,7 +187,9 @@ impl OpenAiResponsesEncoder {
|
||||
}
|
||||
|
||||
/// 仅在非流式出口聚合同一响应的完成项,不让流式转发常驻保存输出内容
|
||||
pub(super) fn collect_response(events: &[ProviderEvent]) -> Result<Value, ResponseEncodeError> {
|
||||
pub(in crate::openai) fn collect_response(
|
||||
events: &[ProviderEvent],
|
||||
) -> Result<Value, ResponseEncodeError> {
|
||||
let mut encoder = OpenAiResponsesEncoder::new();
|
||||
let mut response_id = None;
|
||||
let mut items = BTreeMap::new();
|
||||
|
||||
@@ -14,7 +14,7 @@ const MAX_IDENTIFIER_BYTES: usize = 1_024;
|
||||
|
||||
/// 插件输出不能安全投递为 OpenAI Responses 协议
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct ResponseValidationError;
|
||||
pub(in crate::openai) struct ResponseValidationError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TerminalKind {
|
||||
@@ -33,10 +33,10 @@ struct ExpectedResponseFacts {
|
||||
|
||||
/// 仅保存最终协议复核所需的响应 ID 与终态类别,不缓存流正文
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct ResponseValidationFacts(Arc<Mutex<ExpectedResponseFacts>>);
|
||||
pub(in crate::openai) struct ResponseValidationFacts(Arc<Mutex<ExpectedResponseFacts>>);
|
||||
|
||||
impl ResponseValidationFacts {
|
||||
pub(super) fn observe_event(&self, event: &ProviderEvent) {
|
||||
pub(in crate::openai) fn observe_event(&self, event: &ProviderEvent) {
|
||||
let mut facts = self
|
||||
.0
|
||||
.lock()
|
||||
@@ -55,7 +55,7 @@ impl ResponseValidationFacts {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn observe_buffered(&self, events: &[ProviderEvent], original: &[u8]) {
|
||||
pub(in crate::openai) fn observe_buffered(&self, events: &[ProviderEvent], original: &[u8]) {
|
||||
for event in events {
|
||||
self.observe_event(event);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ impl ResponseValidationFacts {
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<String> {
|
||||
pub(in crate::openai) fn response_id(&self) -> Option<String> {
|
||||
self.snapshot().response_id
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ impl ExpectedResponseFacts {
|
||||
}
|
||||
|
||||
/// 只复核中间件确实改写过的完整 JSON;未经改写的原生快路保持原行为
|
||||
pub(super) fn validate_buffered_response(
|
||||
pub(in crate::openai) fn validate_buffered_response(
|
||||
facts: &ResponseValidationFacts,
|
||||
modified: &[u8],
|
||||
) -> Result<(), ResponseValidationError> {
|
||||
@@ -176,7 +176,7 @@ pub(super) fn validate_buffered_response(
|
||||
/// 原始前缀只旁路构建状态;直到插件首次改写或丢弃正文才启用严格拒绝,避免给
|
||||
/// 无策略原生热路增加新的兼容性条件
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct ResponsesDeliveryValidator {
|
||||
pub(in crate::openai) struct ResponsesDeliveryValidator {
|
||||
active: bool,
|
||||
invalid_prefix: bool,
|
||||
state: DeliveryState,
|
||||
@@ -184,7 +184,7 @@ pub(super) struct ResponsesDeliveryValidator {
|
||||
|
||||
impl ResponsesDeliveryValidator {
|
||||
/// 复核实际交付帧;只有宿主标记为已改写时才激活严格模式
|
||||
pub(super) fn validate_frame(
|
||||
pub(in crate::openai) fn validate_frame(
|
||||
&mut self,
|
||||
delivered: &[u8],
|
||||
transformed: bool,
|
||||
@@ -194,7 +194,7 @@ impl ResponsesDeliveryValidator {
|
||||
}
|
||||
|
||||
/// WebSocket JSON message 与 SSE 共用同一响应顺序和关联校验
|
||||
pub(super) fn validate_websocket_frame(
|
||||
pub(in crate::openai) fn validate_websocket_frame(
|
||||
&mut self,
|
||||
delivered: &[u8],
|
||||
transformed: bool,
|
||||
@@ -263,11 +263,11 @@ impl ResponsesDeliveryValidator {
|
||||
self.state.apply(parsed)
|
||||
}
|
||||
|
||||
pub(super) fn response_id(&self) -> Option<&str> {
|
||||
pub(in crate::openai) fn response_id(&self) -> Option<&str> {
|
||||
self.state.response_id.as_deref()
|
||||
}
|
||||
|
||||
pub(super) fn finish_delivery(&self) -> Result<(), ResponseValidationError> {
|
||||
pub(in crate::openai) fn finish_delivery(&self) -> Result<(), ResponseValidationError> {
|
||||
if !self.active || self.state.done {
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -275,12 +275,14 @@ impl ResponsesDeliveryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn completed_response_id(&self) -> Option<&str> {
|
||||
pub(in crate::openai) fn completed_response_id(&self) -> Option<&str> {
|
||||
self.response_id()
|
||||
.filter(|_| self.state.terminal == Some(TerminalKind::Completed))
|
||||
}
|
||||
|
||||
pub(super) fn finish_websocket_delivery(&self) -> Result<(), ResponseValidationError> {
|
||||
pub(in crate::openai) fn finish_websocket_delivery(
|
||||
&self,
|
||||
) -> Result<(), ResponseValidationError> {
|
||||
if !self.active || self.state.terminal.is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::openai::middleware::{PendingExecution, pending_execution_response};
|
||||
use crate::openai::responses::validation::{ResponseValidationFacts, ResponsesDeliveryValidator};
|
||||
|
||||
use super::{
|
||||
super::{DecodedResponsesRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
|
||||
super::{DecodedGenerationRequest, OpenAiResponsesEncoder, ProtocolErrorBody},
|
||||
connection::{
|
||||
ConnectionEvent, FramePhase, ResponsesWebSocketConnection, WriteContext, WriteOutcome,
|
||||
},
|
||||
@@ -45,7 +45,7 @@ pub(super) struct ConnectionReplaySnapshot {
|
||||
}
|
||||
|
||||
impl ConnectionReplaySnapshot {
|
||||
pub(super) fn prepare(&self, request: DecodedResponsesRequest) -> DecodedResponsesRequest {
|
||||
pub(super) fn prepare(&self, request: DecodedGenerationRequest) -> DecodedGenerationRequest {
|
||||
match (
|
||||
request.metadata().continuation().previous_response_id(),
|
||||
self.last_response_id.as_deref(),
|
||||
|
||||
@@ -32,7 +32,7 @@ use crate::{
|
||||
ApiState,
|
||||
openai::middleware::{RequestInput, invoke_request, request_parts},
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::runtime_unavailable_response,
|
||||
service::OpenAiService,
|
||||
},
|
||||
@@ -65,7 +65,7 @@ pub(crate) async fn responses_websocket(
|
||||
websocket: Upgrade,
|
||||
) -> Response {
|
||||
let service = state.openai().clone();
|
||||
let client = match authenticate_client(&service, &headers).await {
|
||||
let client = match authenticate_client(&service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -365,7 +365,7 @@ async fn serve_responses_websocket(socket: Socket, session: ResponsesWebSocketSe
|
||||
.prepare(decoded)
|
||||
.with_middleware_capabilities(&request)?;
|
||||
let started = service_for_terminal
|
||||
.start_prepared_response(
|
||||
.start_prepared_generation(
|
||||
prepared,
|
||||
decoded,
|
||||
ClientTransport::WebSocket,
|
||||
|
||||
@@ -12,7 +12,7 @@ use thiserror::Error;
|
||||
use crate::openai::error::{gateway_error_contract, gateway_error_from_engine};
|
||||
|
||||
use super::super::{
|
||||
DecodedResponsesRequest, ProtocolErrorBody, RequestDecodeError,
|
||||
DecodedGenerationRequest, ProtocolErrorBody, RequestDecodeError,
|
||||
request::{OpenAiRequestHeaders, RequestDecodeSource},
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ use super::super::{
|
||||
pub fn decode_response_create_with_context(
|
||||
payload: &str,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
|
||||
) -> Result<DecodedGenerationRequest, ResponseCreateFrameError> {
|
||||
decode_response_create_inner(payload, request_headers)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn is_response_create(payload: &str) -> bool {
|
||||
fn decode_response_create_inner(
|
||||
payload: &str,
|
||||
request_headers: &OpenAiRequestHeaders,
|
||||
) -> Result<DecodedResponsesRequest, ResponseCreateFrameError> {
|
||||
) -> Result<DecodedGenerationRequest, ResponseCreateFrameError> {
|
||||
let Value::Object(mut body) = serde_json::from_str::<Value>(payload)
|
||||
.map_err(|_| ResponseCreateFrameError::InvalidJson)?
|
||||
else {
|
||||
|
||||
@@ -10,6 +10,7 @@ use super::{
|
||||
chat_completions::chat_completions,
|
||||
images::{image_edits, image_generations},
|
||||
live,
|
||||
messages::{count_tokens, messages},
|
||||
models::{model_detail, models},
|
||||
responses::{responses, responses_websocket},
|
||||
search::standalone_search,
|
||||
@@ -20,6 +21,8 @@ use crate::ApiState;
|
||||
|
||||
// 公开协议路径由 API 层统一定义;Core 使用操作类型,不根据这些路径分派业务
|
||||
pub(super) const RESPONSES_PATH: &str = "/v1/responses";
|
||||
pub(super) const MESSAGES_PATH: &str = "/v1/messages";
|
||||
pub(super) const MESSAGE_TOKENS_PATH: &str = "/v1/messages/count_tokens";
|
||||
pub(super) const CHAT_COMPLETIONS_PATH: &str = "/v1/chat/completions";
|
||||
pub(super) const IMAGE_GENERATIONS_PATH: &str = "/v1/images/generations";
|
||||
pub(super) const IMAGE_EDITS_PATH: &str = "/v1/images/edits";
|
||||
@@ -37,6 +40,8 @@ pub(crate) fn router() -> Router<ApiState> {
|
||||
.route(SEARCH_PATH, post(standalone_search))
|
||||
.route(RESPONSES_PATH, get(responses_websocket).post(responses))
|
||||
.route(CHAT_COMPLETIONS_PATH, post(chat_completions))
|
||||
.route(MESSAGES_PATH, post(messages))
|
||||
.route(MESSAGE_TOKENS_PATH, post(count_tokens))
|
||||
.route(MODELS_PATH, get(models))
|
||||
// 官方 OpenAI 模型详情合同使用 path ID;它不属于 Admin API 约束
|
||||
.route(MODEL_DETAIL_PATH, get(model_detail))
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::ApiState;
|
||||
use crate::middleware::headers::encode_headers;
|
||||
use crate::openai::middleware::RequestInput;
|
||||
use crate::openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
endpoint::provider_endpoint_response,
|
||||
responses::{OpenAiRequestHeaders, request_client_context},
|
||||
router::SEARCH_PATH,
|
||||
@@ -32,7 +32,7 @@ pub(crate) async fn standalone_search(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ use gateway_core::routing::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::auth::ClientApiKeyAuthError;
|
||||
use super::responses::{ContinuationIntent, DecodedResponsesRequest};
|
||||
use super::responses::{ContinuationIntent, DecodedGenerationRequest};
|
||||
|
||||
/// OpenAI HTTP/WS adapter 共享的 Core 与连接生命周期能力
|
||||
#[derive(Clone)]
|
||||
@@ -76,8 +76,14 @@ impl OpenAiService {
|
||||
client: &AuthenticatedClient,
|
||||
client_version: &str,
|
||||
) -> Result<Vec<PublicModelDescriptor>, ProviderCatalogUnavailable> {
|
||||
let context = crate::middleware::current();
|
||||
self.execution
|
||||
.client_model_catalog(client, "codex", client_version)
|
||||
.client_model_catalog(
|
||||
client,
|
||||
"codex",
|
||||
client_version,
|
||||
context.as_ref().and_then(|ctx| ctx.extensions.peer_route()),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -89,14 +95,15 @@ impl OpenAiService {
|
||||
self.execution.contains_public_model(client, model)
|
||||
}
|
||||
|
||||
pub(crate) async fn start_prepared_response(
|
||||
pub(crate) async fn start_prepared_generation(
|
||||
&self,
|
||||
prepared: PreparedRootExecution,
|
||||
request: DecodedResponsesRequest,
|
||||
request: DecodedGenerationRequest,
|
||||
transport: ClientTransport,
|
||||
endpoint: &'static str,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let (operation, metadata) = request.into_parts();
|
||||
let protocol = operation.protocol().to_owned();
|
||||
let public_model = PublicModelId::from_client_wire(metadata.requested_model().to_owned())
|
||||
.map_err(|_| {
|
||||
GatewayError::new(
|
||||
@@ -117,7 +124,7 @@ impl OpenAiService {
|
||||
public_model,
|
||||
operation,
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".to_owned(),
|
||||
protocol,
|
||||
endpoint: endpoint.to_owned(),
|
||||
transport,
|
||||
stream: metadata.stream(),
|
||||
|
||||
@@ -89,8 +89,8 @@ async fn usage_response(
|
||||
};
|
||||
Json(json!({
|
||||
"unit": "USD",
|
||||
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at),
|
||||
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at),
|
||||
"daily": window(budget.limits.daily_usd, budget.daily_used_usd, budget.daily_resets_at()),
|
||||
"weekly": window(budget.limits.weekly_usd, budget.weekly_used_usd, budget.weekly_resets_at()),
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ use gateway_core::{
|
||||
use crate::{
|
||||
ApiState,
|
||||
openai::{
|
||||
auth::{authenticate_client, client_access_error_response},
|
||||
auth::{ClientKeyHeaders, authenticate_client, client_access_error_response},
|
||||
error::{engine_error_response, gateway_error_response, openai_error_response},
|
||||
middleware::PendingExecution,
|
||||
responses::request_client_context,
|
||||
@@ -67,7 +67,7 @@ async fn count_tokens(
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
@@ -132,7 +132,7 @@ async fn provider_http(
|
||||
_ => return invalid_request("Provider HTTP method is unsupported."),
|
||||
};
|
||||
let service = state.openai();
|
||||
let client = match authenticate_client(service, &headers).await {
|
||||
let client = match authenticate_client(service, &headers, ClientKeyHeaders::Bearer).await {
|
||||
Ok(client) => client,
|
||||
Err(error) => return client_access_error_response(error),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ async fn peer_refresh_uses_the_account_quota_endpoint_and_requires_admin() {
|
||||
fixture.auth.insert_session("valid-session");
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -73,6 +74,7 @@ async fn account_model_reads_and_refreshes_preserve_provider_observation_time()
|
||||
let observed_at: DateTime<Utc> = "2026-09-12T08:00:00Z".parse().unwrap();
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -161,6 +163,7 @@ async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() {
|
||||
] {
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -239,6 +242,7 @@ async fn account_cooldown_returns_an_instant_and_server_formatted_recovery() {
|
||||
let now = Utc::now();
|
||||
let until = seconds.map(|seconds| now + Duration::seconds(seconds));
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -323,6 +327,7 @@ async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone()
|
||||
let now = Utc::now();
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -356,6 +361,7 @@ async fn account_quota_refresh_time_uses_server_clock_and_deployment_timezone()
|
||||
),
|
||||
] {
|
||||
*fixture.provider_quota.lock().unwrap() = Some(ProviderQuota {
|
||||
paused_windows: Vec::new(),
|
||||
upstream: None,
|
||||
plan_type: None,
|
||||
observed_at,
|
||||
@@ -406,6 +412,8 @@ fn account_page_item(
|
||||
AccountPageItem {
|
||||
protection_rules: Vec::new(),
|
||||
account: AccountRecord {
|
||||
quota_paused: false,
|
||||
quota_limits: Default::default(),
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
@@ -458,6 +466,7 @@ async fn account_usage_preserves_periods_cache_counts_and_missing_values() {
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -741,6 +750,7 @@ async fn concurrency_details_require_admin_validate_paging_and_report_lease_time
|
||||
fixture.auth.insert_session("valid-session");
|
||||
*fixture.account.lock().unwrap() = Some(account_page_item(
|
||||
gateway_core::account::AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: gateway_core::account::CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
|
||||
@@ -944,6 +944,7 @@ mod actions {
|
||||
DomainConnectionTestEvent::Failed {
|
||||
source: AccountProbeErrorSource::Upstream,
|
||||
gateway_error_code: GatewayErrorKind::RateLimited,
|
||||
proxy_hint: None,
|
||||
send_state: Some(UpstreamSendState::Sent),
|
||||
message: "upstream unavailable".to_owned(),
|
||||
provider_error_code: Some("usage_exhausted".to_owned()),
|
||||
@@ -983,7 +984,7 @@ mod actions {
|
||||
assert_eq!(
|
||||
events,
|
||||
[
|
||||
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游 Responses" }),
|
||||
json!({ "type": "test_start", "model": "grok-4.5", "text": "正在连接上游" }),
|
||||
json!({
|
||||
"type": "request",
|
||||
"payload": {
|
||||
@@ -1005,6 +1006,7 @@ mod actions {
|
||||
"type": "error",
|
||||
"source": "upstream",
|
||||
"gatewayErrorCode": "rate_limited",
|
||||
"proxyHint": null,
|
||||
"sendState": "sent",
|
||||
"error": "upstream unavailable",
|
||||
"providerErrorCode": "usage_exhausted",
|
||||
|
||||
@@ -563,6 +563,7 @@ mod provider {
|
||||
fn account(provider: &str) -> AccountPageItem {
|
||||
let now = Utc::now();
|
||||
let facts = AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -578,6 +579,8 @@ mod provider {
|
||||
total_slots: None,
|
||||
},
|
||||
account: AccountRecord {
|
||||
quota_paused: false,
|
||||
quota_limits: Default::default(),
|
||||
auto_use_quota_credits: false,
|
||||
outbound_proxy_id: None,
|
||||
outbound_proxy_enabled: true,
|
||||
|
||||
@@ -671,9 +671,10 @@ impl SettingsStore for MemorySettingsStore {
|
||||
audit_retention_days: command.values.audit_retention_days,
|
||||
account_error_protection: command.values.account_error_protection,
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
};
|
||||
@@ -890,6 +891,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
|
||||
group_id: group_id(PRIMARY_GROUP_ID),
|
||||
account_id: "acct_group_ready".to_owned(),
|
||||
status: AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: true,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -905,6 +907,7 @@ impl AccountGroupStore for MemoryAccountGroupStore {
|
||||
group_id: group_id(PRIMARY_GROUP_ID),
|
||||
account_id: "acct_group_disabled".to_owned(),
|
||||
status: AccountStatusFacts {
|
||||
quota_paused: false,
|
||||
enabled: false,
|
||||
credential_state: CredentialState::Ready,
|
||||
access_token_expires_at: None,
|
||||
@@ -1016,6 +1019,32 @@ fn mutation(
|
||||
|
||||
#[async_trait]
|
||||
impl ClientKeyStore for MemoryClientKeyStore {
|
||||
async fn client_key_budget_window(
|
||||
&self,
|
||||
_: &gateway_core::policy::ClientApiKeyId,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetWindowPeriod,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<
|
||||
gateway_admin::model::client_keys::ClientKeyBudgetWindow,
|
||||
> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
async fn set_client_key_budget_window(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::SetClientKeyBudgetWindow,
|
||||
_: gateway_admin::model::client_keys::ClientKeyBudgetMutationOrigin,
|
||||
_: &MutationContext,
|
||||
) -> gateway_admin::ports::store::AdminStoreResult<u64> {
|
||||
Err(gateway_admin::ports::store::AdminStoreError::new(
|
||||
gateway_admin::ports::store::AdminStoreErrorKind::Unavailable,
|
||||
"client key",
|
||||
"unused budget window",
|
||||
))
|
||||
}
|
||||
|
||||
async fn update_client_key_budget_limits(
|
||||
&self,
|
||||
_: gateway_admin::model::client_keys::UpdateClientKeyBudgetLimits,
|
||||
@@ -1201,6 +1230,9 @@ impl AccountStore for UnusedStore {
|
||||
summary: gateway_admin::model::accounts::AccountSummary {
|
||||
total: 1,
|
||||
normal: u64::from(status == gateway_core::account::AccountStatus::Normal),
|
||||
quota_paused: u64::from(
|
||||
status == gateway_core::account::AccountStatus::QuotaPaused,
|
||||
),
|
||||
quota_exhausted: u64::from(
|
||||
status == gateway_core::account::AccountStatus::QuotaExhausted,
|
||||
),
|
||||
@@ -1365,6 +1397,7 @@ impl AccountStore for UnusedStore {
|
||||
async fn batch_update_accounts(
|
||||
&self,
|
||||
_: BatchUpdateAccounts,
|
||||
_: gateway_admin::model::accounts::PreparedQuotaPauses,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<AccountsUpdateResult> {
|
||||
Err(unavailable("account batch update"))
|
||||
@@ -1914,9 +1947,10 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
audit_retention_days: 90,
|
||||
account_error_protection: Default::default(),
|
||||
account_warmup_enabled: false,
|
||||
account_warmup_schedule_time: "08:00".to_owned(),
|
||||
account_warmup_cron: "0 8 * * *".to_owned(),
|
||||
account_warmup_model: None,
|
||||
reset_credit_lead_minutes: 10,
|
||||
reset_credit_on_low_weekly_quota: false,
|
||||
reset_credit_max_weekly_remaining_percent: None,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ async fn dashboard_summary_should_include_quota_exhaustion_in_unavailable_headli
|
||||
provider_accounts: AccountPoolMetrics {
|
||||
total: 1002,
|
||||
normal: 943,
|
||||
quota_paused: 0,
|
||||
quota_exhausted: 52,
|
||||
rate_limited: 0,
|
||||
disabled: 3,
|
||||
@@ -425,7 +426,7 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
upstream_send_state: Some("sent".to_owned()),
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Failed,
|
||||
downstream_committed: false,
|
||||
downstream_committed: None,
|
||||
status_code: Some(429),
|
||||
provider_error_code: Some("rate_limit".to_owned()),
|
||||
failure_kind: Some("rate_limited".to_owned()),
|
||||
@@ -462,6 +463,8 @@ async fn usage_detail_should_keep_attempt_snapshot_contract() {
|
||||
.await
|
||||
.expect("usage detail body");
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("usage detail JSON");
|
||||
assert_eq!(value["data"]["errorOrigin"], "upstream");
|
||||
assert!(value["data"]["attempts"][0]["downstreamCommitted"].is_null());
|
||||
assert_eq!(value["data"]["errorKind"], "continuation_recovery_required");
|
||||
assert_eq!(value["data"]["errorCode"], "previous_response_not_found");
|
||||
assert_eq!(
|
||||
@@ -981,6 +984,7 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
.expect("usage records")
|
||||
.push(UsageListRecord {
|
||||
provider_account_upstream_peer: None,
|
||||
has_upstream_error: false,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
error_kind: None,
|
||||
@@ -989,6 +993,7 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
upstream_connection_exit_reason: None,
|
||||
recovered: false,
|
||||
client_status_code: None,
|
||||
upstream_status_code: None,
|
||||
outcome: gateway_admin::model::observability::RequestOutcome::Succeeded,
|
||||
client_api_key_name: Some("Production".to_owned()),
|
||||
id: "request_endpoint".to_owned(),
|
||||
@@ -1078,6 +1083,8 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
let mut failed = records[0].clone();
|
||||
failed.id = "request_failed".to_owned();
|
||||
failed.outcome = gateway_admin::model::observability::RequestOutcome::Failed;
|
||||
failed.client_status_code = Some(503);
|
||||
failed.upstream_status_code = Some(429);
|
||||
failed.error_code = Some("server_error".to_owned());
|
||||
failed.error_message = Some("The upstream service is temporarily unavailable.".to_owned());
|
||||
records.push(failed);
|
||||
@@ -1111,6 +1118,19 @@ async fn usage_route_projects_request_facts_and_billing() {
|
||||
assert!(value["data"]["items"][0]["errorCode"].is_null());
|
||||
assert!(value["data"]["items"][0]["errorMessage"].is_null());
|
||||
assert_eq!(value["data"]["items"][3]["errorCode"], "server_error");
|
||||
assert_eq!(value["data"]["items"][3]["clientStatusCode"], 503);
|
||||
assert_eq!(value["data"]["items"][3]["upstreamStatusCode"], 429);
|
||||
assert_eq!(value["data"]["items"][3]["errorOrigin"], "upstream");
|
||||
assert_eq!(value["data"]["items"][4]["errorOrigin"], "upstream");
|
||||
assert_eq!(value["data"]["items"][0]["errorOrigin"], "unknown");
|
||||
assert_eq!(
|
||||
value["data"]["items"][4].get("upstreamStatusCode"),
|
||||
Some(&serde_json::Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
value["data"]["items"][4].get("clientStatusCode"),
|
||||
Some(&serde_json::Value::Null)
|
||||
);
|
||||
assert_eq!(value["data"]["items"][4]["logicalOutcome"], "incomplete");
|
||||
assert_eq!(
|
||||
value["data"]["items"][4]["errorKind"],
|
||||
|
||||
@@ -70,7 +70,7 @@ fn update_body() -> Value {
|
||||
"auditRetentionDays": 91,
|
||||
"accountErrorProtection": gateway_core::account::AccountErrorProtection::default(),
|
||||
"accountWarmupEnabled": false,
|
||||
"accountWarmupScheduleTime": "08:00",
|
||||
"accountWarmupCron": "0 8 * * *",
|
||||
"accountWarmupModel": null,
|
||||
"resetCreditLeadMinutes": 10,
|
||||
"resetCreditMaxWeeklyRemainingPercent": null
|
||||
@@ -402,6 +402,33 @@ fn settings_request_accepts_unlimited_default_account_concurrency() {
|
||||
request.validate().expect("zero means unlimited");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_validates_cron_and_allows_disabled_empty_schedule() {
|
||||
for (enabled, cron, valid) in [
|
||||
(false, "", true),
|
||||
(true, "", false),
|
||||
(false, "08:00", false),
|
||||
(true, "0 8 * * MON-FRI", true),
|
||||
(true, "60 8 * * *", false),
|
||||
(true, "0 8 * * *\n30 13 * * *", false),
|
||||
] {
|
||||
let mut body = update_body();
|
||||
body["accountWarmupEnabled"] = json!(enabled);
|
||||
body["accountWarmupCron"] = json!(cron);
|
||||
body["accountWarmupModel"] = json!("test-model");
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
|
||||
let result = request.validate();
|
||||
assert_eq!(result.is_ok(), valid, "{enabled}: {cron:?}");
|
||||
if let Err(error) = result {
|
||||
assert_eq!(error.field(), "accountWarmupCron");
|
||||
}
|
||||
}
|
||||
let mut legacy = update_body();
|
||||
legacy.as_object_mut().unwrap().remove("accountWarmupCron");
|
||||
legacy["accountWarmupScheduleTime"] = json!("08:00");
|
||||
assert!(serde_json::from_value::<UpdateRuntimeSettingsRequest>(legacy).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_request_requires_model_when_warmup_is_enabled() {
|
||||
let mut body = update_body();
|
||||
@@ -1511,11 +1538,14 @@ fn reset_credit_weekly_threshold_accepts_unset_and_zero_to_one_hundred() {
|
||||
.reset_credit_max_weekly_remaining_percent
|
||||
.is_none()
|
||||
);
|
||||
assert!(!request.values.reset_credit_on_low_weekly_quota);
|
||||
for value in [json!(null), json!(0), json!(70), json!(100)] {
|
||||
let mut body = update_body();
|
||||
body["resetCreditOnLowWeeklyQuota"] = json!(true);
|
||||
body["resetCreditMaxWeeklyRemainingPercent"] = value.clone();
|
||||
let request: UpdateRuntimeSettingsRequest = serde_json::from_value(body).unwrap();
|
||||
request.validate().unwrap();
|
||||
assert!(request.values.reset_credit_on_low_weekly_quota);
|
||||
assert_eq!(
|
||||
serde_json::to_value(request.values.reset_credit_max_weekly_remaining_percent).unwrap(),
|
||||
value
|
||||
|
||||
@@ -86,7 +86,7 @@ async fn authentication_unknown_routes_and_method_errors_are_observable() {
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/v1/messages?secret=SENSITIVE_QUERY",
|
||||
"/v1/unknown?secret=SENSITIVE_QUERY",
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown_endpoint",
|
||||
),
|
||||
|
||||
@@ -294,6 +294,7 @@ async fn health_router(
|
||||
) -> axum::Router {
|
||||
let admin = crate::admin::AdminTestFixture::new().await;
|
||||
gateway_api::initialize(
|
||||
uuid::Uuid::nil(),
|
||||
gateway_api::ApiConfig {
|
||||
asset_directory: std::env::temp_dir(),
|
||||
cors_allowed_origins: Vec::new(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user