mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-10 16:05:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d30474af | ||
|
|
f1d5b73255 | ||
|
|
18c75132c3 | ||
|
|
a48139b4c2 | ||
|
|
dd529d07ec | ||
|
|
19fbb5da75 | ||
|
|
9296885cce | ||
|
|
af9790ac37 | ||
|
|
c58e280739 | ||
|
|
e33f50ec09 | ||
|
|
1db8b0e319 | ||
|
|
8c8618aa0f | ||
|
|
c43a8872db | ||
|
|
51c5caed0e | ||
|
|
a7f95c3a78 | ||
|
|
421e37896a | ||
|
|
1d1ccda42a | ||
|
|
e38cf31323 | ||
|
|
614c81c375 | ||
|
|
f0484c4e11 | ||
|
|
dffc417b9f | ||
|
|
2d4294c3c9 | ||
|
|
3d9906facf | ||
|
|
da9c2b92b0 | ||
|
|
de5c10c4e4 | ||
|
|
939cb99985 | ||
|
|
2bb8ebc01c | ||
|
|
6a5300a7b5 | ||
|
|
2c5b8b5c1a | ||
|
|
0a87ac7f76 | ||
|
|
3ac9d5f6f8 | ||
|
|
e7e5b659cd | ||
|
|
b225b3732d | ||
|
|
9dca763ea4 | ||
|
|
d26bc36db5 | ||
|
|
96d5ab674a | ||
|
|
84c0db2613 | ||
|
|
d819315d8b | ||
|
|
06e1d8f0ac | ||
|
|
bdc8274b36 | ||
|
|
3f6607209b | ||
|
|
3c05bc8f07 | ||
|
|
9ccd84f1a0 | ||
|
|
a8f183707e | ||
|
|
3f20af61ff | ||
|
|
efaed4c3a6 |
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
container:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CPR_IMAGE: codex-proxy-rs:ci-${{ inputs.ref }}
|
||||
|
||||
@@ -24,7 +24,7 @@ permissions:
|
||||
jobs:
|
||||
backend:
|
||||
if: inputs.run_backend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
CPR_TEST_REDIS_URL: redis://127.0.0.1:6379
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818
|
||||
image: postgres:18-bookworm@sha256:bbf83594798e144bff365a9c191c5d779f181fb3135902064ab8ff11918020fc
|
||||
env:
|
||||
POSTGRES_DB: codex_proxy
|
||||
POSTGRES_USER: codex_proxy
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
redis:
|
||||
image: redis:8-alpine@sha256:9d317178eceac8454a2284a9e6df2466b93c745529947f0cd42a0fa9609d7005
|
||||
image: redis:8-alpine@sha256:0bf6577d7774e592b29d093e3ac9390eb91e13d72c21e506afce736de5e0eeb0
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
components: rustfmt,clippy
|
||||
|
||||
- name: Cache Rust
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
workspaces: backend -> target
|
||||
save-if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
- name: Setup nextest
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
|
||||
with:
|
||||
tool: cargo-nextest@0.9.143
|
||||
fallback: none
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
frontend:
|
||||
if: inputs.run_frontend
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -213,7 +213,7 @@ jobs:
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
workflow-lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -223,6 +223,8 @@ jobs:
|
||||
|
||||
# GitHub 已支持 queue: max,actionlint 尚未支持该字段
|
||||
# https://github.com/rhysd/actionlint/issues/657
|
||||
# GitHub 已提供 ubuntu-26.04,actionlint 尚未收录该标签
|
||||
# https://github.com/actions/runner-images#available-images
|
||||
- name: Lint GitHub Actions workflows
|
||||
run: >-
|
||||
docker run --rm
|
||||
@@ -231,6 +233,7 @@ jobs:
|
||||
rhysd/actionlint:1.7.7@sha256:887a259a5a534f3c4f36cb02dca341673c6089431057242cdc931e9f133147e9
|
||||
-color
|
||||
-ignore '^unexpected key "queue" for "concurrency" section\. expected one of "cancel-in-progress", "group"$'
|
||||
-ignore '^label "ubuntu-26\.04" is unknown\.'
|
||||
.github/workflows/*.yml
|
||||
|
||||
- name: Check workflow script syntax
|
||||
|
||||
@@ -20,7 +20,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
backend: ${{ steps.filter.outputs.backend }}
|
||||
frontend: ${{ steps.filter.outputs.frontend }}
|
||||
container: ${{ steps.filter.outputs.container }}
|
||||
source: ${{ steps.filter.outputs.source }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
id: filter
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置与子模块指针
|
||||
# 目录规则取并集,再统一排除说明文档,保留隐藏配置
|
||||
predicate-quantifier: some-with-excludes
|
||||
filters: |
|
||||
backend:
|
||||
@@ -61,17 +60,6 @@ jobs:
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/workflows/_quality.yml'
|
||||
- '!**/*.md'
|
||||
source:
|
||||
- '.github/scripts/toolchains.rb'
|
||||
- 'backend/rust-toolchain.toml'
|
||||
- 'deploy/Dockerfile'
|
||||
- '.gitmodules'
|
||||
- 'modules/ui'
|
||||
- 'modules/plugins'
|
||||
- 'frontend/**'
|
||||
- 'backend/crates/gateway-plugin/sdk/**'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '!**/*.md'
|
||||
container:
|
||||
- 'backend/**'
|
||||
- 'frontend/**'
|
||||
@@ -101,66 +89,3 @@ jobs:
|
||||
uses: ./.github/workflows/_container.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
source-development:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.source == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout with development modules
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
submodules: recursive
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
package_json_file: frontend/package.json
|
||||
- name: Read build toolchains
|
||||
id: toolchains
|
||||
run: ruby .github/scripts/toolchains.rb >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: ${{ steps.toolchains.outputs.node }}
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
|
||||
with:
|
||||
toolchain: ${{ steps.toolchains.outputs.rust }}
|
||||
- name: Check dependency pins
|
||||
run: |
|
||||
ui_revision="$(git -C modules/ui rev-parse HEAD)"
|
||||
for lockfile in frontend/pnpm-lock.yaml modules/plugins/examples/workbench/frontend/pnpm-lock.yaml; do
|
||||
locked_revision="$(sed -nE 's@.*codeload.github.com/zyycn/codex-proxy-ui/tar.gz/([a-f0-9]{40}).*@\1@p' "$lockfile" | sort -u)"
|
||||
if [ "$locked_revision" != "$ui_revision" ]; then
|
||||
echo "::error file=$lockfile::UI dependency must match modules/ui ($ui_revision)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Install development dependencies
|
||||
run: |
|
||||
pnpm --dir modules/ui install --frozen-lockfile
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend install --frozen-lockfile
|
||||
- name: Check UI source integration
|
||||
run: |
|
||||
pnpm --dir frontend build:source
|
||||
pnpm --dir modules/plugins/examples/workbench/frontend build:source
|
||||
- name: Check local SDK integration
|
||||
env:
|
||||
RUST_MIN_STACK: 16777216
|
||||
CARGO_TARGET_DIR: ${{ github.workspace }}/backend/target/plugin-development
|
||||
run: |
|
||||
sdk_check_dir="$RUNNER_TEMP/plugin-source"
|
||||
mkdir -p "$sdk_check_dir/backend"
|
||||
cp modules/plugins/examples/workbench/backend/Cargo.{toml,lock} "$sdk_check_dir/backend/"
|
||||
cp -R modules/plugins/examples/workbench/backend/{src,tests} "$sdk_check_dir/backend/"
|
||||
cp modules/plugins/examples/workbench/plugin.json "$sdk_check_dir/"
|
||||
cargo test --manifest-path "$sdk_check_dir/backend/Cargo.toml" \
|
||||
--config "patch.\"https://github.com/zyycn/codex-proxy-rs.git\".gateway-plugin-sdk.path=\"$GITHUB_WORKSPACE/backend/crates/gateway-plugin/sdk\""
|
||||
- name: Ensure source checks preserve lockfiles
|
||||
run: |
|
||||
git diff --exit-code -- frontend/package.json frontend/pnpm-lock.yaml backend/Cargo.lock
|
||||
git -C modules/plugins diff --exit-code -- examples/workbench/frontend/package.json examples/workbench/frontend/pnpm-lock.yaml examples/workbench/backend/Cargo.lock
|
||||
|
||||
@@ -21,7 +21,7 @@ env:
|
||||
|
||||
jobs:
|
||||
release-info:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
frontend:
|
||||
needs: release-info
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
CI: 'true'
|
||||
@@ -205,7 +205,7 @@ jobs:
|
||||
|
||||
package-assets:
|
||||
needs: [release-info, frontend, build-docker-binary, build-binary]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -312,7 +312,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
queue: max
|
||||
# 产物准备可与测试重叠,版本标签和 GitHub Release 必须等全部质量与镜像验证通过
|
||||
needs: [release-info, quality, package-assets, build-docker-image]
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -409,7 +409,7 @@ jobs:
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -18,7 +18,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
|
||||
|
||||
@@ -19,7 +19,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
backend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Install cargo-audit
|
||||
uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29
|
||||
uses: taiki-e/install-action@1e29709c0ec63babbe8f266397a465f29a825f91 # v2.87.31
|
||||
with:
|
||||
tool: cargo-audit@0.22.2
|
||||
fallback: none
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
cargo-audit audit --file backend/Cargo.lock
|
||||
|
||||
frontend-security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
+1
-1
@@ -228,7 +228,7 @@ git diff --check
|
||||
|
||||
## 自动检查与合并条件
|
||||
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端、容器和源码联调任务,工作流语法检查独立执行
|
||||
[CI](.github/workflows/ci.yml) 按变更范围调用质量检查,Markdown 不触发后端、前端和容器任务,工作流语法检查独立执行
|
||||
|
||||
后端 CI 使用 nextest 执行 `main` 集成测试,默认 Redis 密码模式执行完整测试集,ACL 用户模式复用同一批构建产物执行 Store 的 Redis 测试;本地验证命令见 [架构文档](docs/architecture.md#验证命令)
|
||||
|
||||
|
||||
Generated
+96
-106
@@ -147,9 +147,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-compression"
|
||||
version = "0.4.50"
|
||||
version = "0.4.51"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
|
||||
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
|
||||
dependencies = [
|
||||
"compression-codecs",
|
||||
"compression-core",
|
||||
@@ -224,9 +224,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-credential-types"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
|
||||
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -260,9 +260,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-runtime"
|
||||
version = "1.10.0"
|
||||
version = "1.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
|
||||
checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
@@ -286,9 +286,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.152.0"
|
||||
version = "1.155.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
|
||||
checksum = "5a542f0e9261b9ce56708d591a62062079fa3c58a362ffbf49a2e48a4f6ced16"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -322,9 +322,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sigv4"
|
||||
version = "1.6.0"
|
||||
version = "1.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
|
||||
checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-eventstream",
|
||||
@@ -344,9 +344,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-async"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
|
||||
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
@@ -376,9 +376,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.4"
|
||||
version = "0.61.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609"
|
||||
checksum = "14c8a643d0e114cae39e45415e3225793a6396698b7779a0c9e3ede731d8fc71"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -387,9 +387,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http"
|
||||
version = "0.64.1"
|
||||
version = "0.64.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
|
||||
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
|
||||
dependencies = [
|
||||
"aws-smithy-eventstream",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -403,15 +403,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
|
||||
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -433,9 +432,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-json"
|
||||
version = "0.63.1"
|
||||
version = "0.65.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18"
|
||||
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -453,9 +452,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
|
||||
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -470,16 +469,15 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.19.0"
|
||||
version = "1.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
|
||||
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -505,9 +503,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-schema"
|
||||
version = "0.2.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
|
||||
checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-types",
|
||||
@@ -516,9 +514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.8.1"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
|
||||
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -530,7 +528,6 @@ dependencies = [
|
||||
"itoa",
|
||||
"num-integer",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"ryu",
|
||||
"serde",
|
||||
"time",
|
||||
@@ -540,9 +537,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-xml"
|
||||
version = "0.62.1"
|
||||
version = "0.64.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf"
|
||||
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
|
||||
dependencies = [
|
||||
"aws-smithy-runtime-api",
|
||||
"aws-smithy-schema",
|
||||
@@ -552,9 +549,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-types"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
|
||||
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
|
||||
dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-smithy-async",
|
||||
@@ -945,9 +942,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.45"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
|
||||
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
|
||||
dependencies = [
|
||||
"compression-core",
|
||||
"flate2",
|
||||
@@ -956,9 +953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "compression-core"
|
||||
version = "0.4.33"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
|
||||
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
|
||||
|
||||
[[package]]
|
||||
name = "config"
|
||||
@@ -1084,9 +1081,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
version = "1.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
|
||||
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
@@ -1166,14 +1163,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
|
||||
dependencies = [
|
||||
"cssparser-macros",
|
||||
"dtoa-short",
|
||||
"itoa",
|
||||
"phf 0.13.1",
|
||||
"phf 0.14.0",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -2125,12 +2122,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2483,9 +2481,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.58.6"
|
||||
version = "0.60.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
|
||||
checksum = "7e7305938cac5ee144352ceb71583ffc6cfeac7d20fcb20ef865a4d5c314e499"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
@@ -2511,23 +2509,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.58.6"
|
||||
version = "0.60.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
|
||||
checksum = "6f8a848331614144c03e815fb931f238c9e342bd7ab501b29e35d647e7cd5688"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.58.6"
|
||||
version = "0.60.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
|
||||
checksum = "5a5a947ef798eeacacb43edf29b1d0ab64bdf5e2c9fbcaad10ee8663d922b544"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"memchr",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
@@ -2642,9 +2641,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.39.0"
|
||||
version = "0.40.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
@@ -3101,23 +3100,23 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_codegen"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3132,12 +3131,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3155,12 +3154,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -3186,9 +3185,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
@@ -3199,12 +3198,6 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.11.0"
|
||||
@@ -3636,9 +3629,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.58.6"
|
||||
version = "0.60.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
|
||||
checksum = "922268812b6f9e20b4ab0f1efe8e07036958eed7346ceddb31c777b5531fd11f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
@@ -3875,9 +3868,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "scraper"
|
||||
version = "0.27.0"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||
checksum = "6844b5f49a5579f12ba4f89d9f7eec6d71889e4aa0bc7181821040ce66f08fff"
|
||||
dependencies = [
|
||||
"cssparser",
|
||||
"ego-tree",
|
||||
@@ -3885,7 +3878,6 @@ dependencies = [
|
||||
"html5ever",
|
||||
"precomputed-hash",
|
||||
"selectors",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3950,17 +3942,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.38.0"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||
checksum = "8322aebea94a6cc288ff884e8a49bdbceef60206c400c6c46d22e62fc4fd91a6"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cssparser",
|
||||
"derive_more",
|
||||
"log",
|
||||
"new_debug_unreachable",
|
||||
"phf 0.13.1",
|
||||
"phf_codegen",
|
||||
"precomputed-hash",
|
||||
"rustc-hash",
|
||||
"servo_arc",
|
||||
@@ -4067,9 +4057,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "servo_arc"
|
||||
version = "0.4.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||
checksum = "b4eceb519ec34d8290aaf6e46f435102f2bfa63f22b05a144fb16ab266d3b198"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
@@ -4401,24 +4391,24 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
||||
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_shared 0.14.0",
|
||||
"precomputed-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.6.1"
|
||||
version = "0.11.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
|
||||
dependencies = [
|
||||
"phf_generator 0.13.1",
|
||||
"phf_shared 0.13.1",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
@@ -5214,11 +5204,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.6"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
|
||||
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
|
||||
dependencies = [
|
||||
"phf 0.13.1",
|
||||
"phf 0.14.0",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
@@ -5651,9 +5641,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.0.4"
|
||||
version = "9.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a954114b47a54df49f83d2722814e86b23d4bb1b54a1b90fb7d92b41619ddc22"
|
||||
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -5677,27 +5667,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.16+zstd.1.5.7"
|
||||
version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
|
||||
@@ -103,6 +103,10 @@ async fn execute(
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
|
||||
);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
|
||||
@@ -38,6 +38,10 @@ pub async fn run() -> Result<(), BootstrapError> {
|
||||
let management = ManagementPorts::new(&store, providers.admin.clone(), core.snapshot_control());
|
||||
or_shutdown!(plugin_runtime, management.bind(&plugin_runtime));
|
||||
let mut core = or_shutdown!(plugin_runtime, core.activate().await);
|
||||
or_shutdown!(
|
||||
plugin_runtime,
|
||||
plugin_runtime.bind_client_identity_port(&core.client_identity_port())
|
||||
);
|
||||
let nested_models = core.nested_model_execution_port();
|
||||
let affinity_lookup = core.affinity_lookup_port();
|
||||
or_shutdown!(
|
||||
|
||||
@@ -370,10 +370,28 @@ fn config_loader_should_reject_zero_server_port() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_loader_should_validate_openai_residency() {
|
||||
fn config_loader_should_accept_optional_openai_routing_strings() {
|
||||
for value in [
|
||||
serde_json::Value::Null,
|
||||
serde_json::json!("us"),
|
||||
serde_json::json!("custom-value"),
|
||||
serde_json::json!(""),
|
||||
] {
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["residency"] = value.clone();
|
||||
document["openai"]["account_routing_override"] = value;
|
||||
parse_config(&document.to_string()).expect("optional routing strings");
|
||||
}
|
||||
let mut document = valid_config_document();
|
||||
document["openai"]["residency"] = serde_json::json!("invalid");
|
||||
assert_rejected(document.to_string());
|
||||
document["openai"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("residency");
|
||||
document["openai"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("account_routing_override");
|
||||
parse_config(&document.to_string()).expect("omitted routing strings");
|
||||
}
|
||||
|
||||
fn assert_rejected(config: String) {
|
||||
|
||||
@@ -740,7 +740,6 @@ pub struct UsageListRecord {
|
||||
pub provider_account_authentication_kind: Option<String>,
|
||||
pub upstream_model_id: Option<String>,
|
||||
pub upstream_transport: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
@@ -802,7 +801,6 @@ pub struct UsageRecord {
|
||||
pub upstream_transport: Option<String>,
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;管理领域保持不透明
|
||||
pub provider_metadata_json: Option<String>,
|
||||
|
||||
@@ -84,6 +84,7 @@ pub struct PluginInstanceMutation {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PluginInstanceRuntimeStatus {
|
||||
Disabled,
|
||||
Unknown,
|
||||
AwaitingPublication,
|
||||
Preparing,
|
||||
Running,
|
||||
@@ -108,6 +109,21 @@ pub struct PluginInstanceRuntime {
|
||||
pub actual_artifact_sha256: Option<String>,
|
||||
pub failure: Option<PluginInstanceRuntimeFailure>,
|
||||
pub draining_revisions: Vec<u64>,
|
||||
pub load: Option<PluginInstanceRuntimeLoad>,
|
||||
}
|
||||
|
||||
/// 当前发布会话的资源占用采样,计数可以重叠且不构成原子快照
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstanceRuntimeLoad {
|
||||
/// 总在途名额,包含排队、取消确认和仍由响应流持有的调用
|
||||
pub active_calls: usize,
|
||||
pub executing_calls: usize,
|
||||
pub queued_calls: usize,
|
||||
pub resuming_calls: usize,
|
||||
/// 包含已返回但尚未由插件消费的回调结果
|
||||
pub pending_callbacks: usize,
|
||||
/// 已交付首结果但尚未收到 End 的流
|
||||
pub active_streams: usize,
|
||||
}
|
||||
|
||||
/// 可继续运行的旧接口提示,与无法启用的兼容性错误分开
|
||||
@@ -136,6 +152,8 @@ pub struct PluginInstanceView {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct ConfigurePluginInstance {
|
||||
/// 只随本次启动请求传递,不写入实例配置
|
||||
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
|
||||
/// 创建草稿固定一个 UUID,重试复用同一配置,不能作为编辑已有配置的入口
|
||||
pub creation_id: Option<String>,
|
||||
/// 编辑时检查用户看到的配置版本,避免覆盖另一窗口已经保存的内容
|
||||
@@ -159,6 +177,16 @@ pub struct PluginInstanceReplacement {
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// 管理员确认的宿主、实例配置与目标包;不构成永久兼容豁免
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PluginCompatibilityConfirmation {
|
||||
pub host_fingerprint: String,
|
||||
pub instance_id: String,
|
||||
pub instance_revision: u64,
|
||||
pub artifact_sha256: String,
|
||||
}
|
||||
|
||||
/// 恢复已安装旧版本的配置快照,并重新通过目标版本校验
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
@@ -196,6 +224,9 @@ pub struct PluginVersionConfiguration {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginVersionPlan {
|
||||
pub compatibility_warning: Option<String>,
|
||||
pub compatibility_confirmation: Option<PluginCompatibilityConfirmation>,
|
||||
pub load_error: Option<String>,
|
||||
pub instance_revision: u64,
|
||||
pub artifact_sha256: String,
|
||||
pub configuration: serde_json::Value,
|
||||
|
||||
@@ -62,6 +62,48 @@ impl PluginHostCompatibility {
|
||||
.iter()
|
||||
.any(|entry| entry.capability == capability && entry.versions.contains(&version))
|
||||
}
|
||||
|
||||
/// 启动和升级预检共用比较规则,只检查插件声明的能力
|
||||
#[must_use]
|
||||
pub fn incompatibilities(
|
||||
&self,
|
||||
requirements: &PluginCompatibilityRequirements,
|
||||
host: &semver::Version,
|
||||
) -> Vec<String> {
|
||||
let mut reasons = Vec::new();
|
||||
if !self
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
{
|
||||
reasons.push(format!(
|
||||
"清单 v{} 不在宿主支持范围内",
|
||||
requirements.manifest_schema_version
|
||||
));
|
||||
}
|
||||
if !self
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
{
|
||||
reasons.push(format!(
|
||||
"RPC v{} 不在宿主支持范围内",
|
||||
requirements.protocol_version
|
||||
));
|
||||
}
|
||||
if !semver::VersionReq::parse(&requirements.host_version)
|
||||
.is_ok_and(|range| range.matches(host))
|
||||
{
|
||||
reasons.push(format!(
|
||||
"插件声明的宿主范围为 {},当前为 {host}",
|
||||
requirements.host_version
|
||||
));
|
||||
}
|
||||
for (capability, version) in &requirements.capabilities {
|
||||
if !self.supports_capability(capability, *version) {
|
||||
reasons.push(format!("{capability} v{version} 不在宿主支持范围内"));
|
||||
}
|
||||
}
|
||||
reasons
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_versions(versions: &[u32]) -> bool {
|
||||
|
||||
@@ -46,6 +46,15 @@ pub trait PluginPreparation: PluginRuntimeDiagnostics + PluginStateLifecycle {
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
) -> Result<ExtensionSetReference, AdminError>;
|
||||
|
||||
/// 确认由 Admin 校验配置版本,Runtime 复核宿主并仅放行目标实例
|
||||
async fn prepare_confirmed(
|
||||
&self,
|
||||
_snapshot: PluginInstanceSnapshot,
|
||||
_confirmation: crate::model::plugins::instances::PluginCompatibilityConfirmation,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
Err(AdminError::invalid("插件运行时不支持确认启动"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 运行诊断独立于发布准备;没有进程事实时显式返回 None
|
||||
@@ -175,6 +184,10 @@ pub trait PluginDistribution: Send + Sync {
|
||||
/// Runtime 只解释插件包格式;安装事务与来源选择归 Admin
|
||||
#[async_trait]
|
||||
pub trait PluginPackageInspector: Send + Sync {
|
||||
/// 当前宿主版本、兼容声明与平台的稳定身份,不含敏感信息
|
||||
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
|
||||
Err(AdminError::internal("宿主兼容声明不可用"))
|
||||
}
|
||||
/// 完整校验包并返回版本范围诊断;校验失败与可加载但未经宿主承诺的提醒分开
|
||||
async fn compatibility_warning(
|
||||
&self,
|
||||
|
||||
@@ -161,53 +161,24 @@ impl PluginSystemUpdatePreflight {
|
||||
.await,
|
||||
) {
|
||||
(_, Err(error)) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件包或协议与目标版本不兼容".to_owned())
|
||||
Some(error.message().to_owned())
|
||||
}
|
||||
(_, Err(error)) => return Err(map_inspection_error(error)),
|
||||
(None, _) => Some("无法确认插件与目标版本兼容".to_owned()),
|
||||
(Some(host), Ok(requirements)) => {
|
||||
if !semver::VersionReq::parse(&requirements.host_version)
|
||||
.is_ok_and(|versions| versions.matches(target))
|
||||
{
|
||||
Some(format!("要求网关版本 {}", requirements.host_version))
|
||||
} else if !host
|
||||
.manifest_schema_versions
|
||||
.contains(&requirements.manifest_schema_version)
|
||||
|| !host
|
||||
.protocol_versions
|
||||
.contains(&requirements.protocol_version)
|
||||
|| requirements
|
||||
.capabilities
|
||||
.iter()
|
||||
.any(|(capability, version)| {
|
||||
!host.supports_capability(capability, *version)
|
||||
})
|
||||
{
|
||||
Some("插件协议或能力与目标版本不兼容".to_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let reasons = host.incompatibilities(&requirements, target);
|
||||
(!reasons.is_empty()).then(|| reasons.join(","))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match self
|
||||
.inspector
|
||||
.inspect(
|
||||
artifact.archive.clone(),
|
||||
Some(instance.artifact_sha256.clone()),
|
||||
)
|
||||
.compatibility_warning(artifact.archive, instance.artifact_sha256.clone())
|
||||
.await
|
||||
{
|
||||
Ok(_) => self
|
||||
.inspector
|
||||
.compatibility_warning(
|
||||
artifact.archive,
|
||||
instance.artifact_sha256.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_inspection_error)?,
|
||||
Ok(warning) => warning,
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => {
|
||||
Some("插件与当前宿主不兼容".to_owned())
|
||||
Some(error.message().to_owned())
|
||||
}
|
||||
Err(error) => return Err(map_inspection_error(error)),
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ impl PluginsService {
|
||||
.configuration_ready(probe, &metadata)
|
||||
.await?;
|
||||
|
||||
let status = self.package_status(&metadata.sha256).await?;
|
||||
let compatible = status.warning.is_none() && status.load_error.is_none();
|
||||
// 其它管理员写入或同插件的并发安装可能推进全局 revision;稳定 creation ID
|
||||
// 配合每轮重新读取,既不会复制默认实例,也不会覆盖先完成的另一个版本
|
||||
for _ in 0..3 {
|
||||
@@ -207,12 +209,13 @@ impl PluginsService {
|
||||
}
|
||||
|
||||
let input = ConfigurePluginInstance {
|
||||
compatibility_confirmation: None,
|
||||
replace_instances: Vec::new(),
|
||||
creation_id: Some(creation_id.clone()),
|
||||
expected_revision: None,
|
||||
name: metadata.display_name.clone(),
|
||||
artifact_sha256: metadata.sha256.clone(),
|
||||
enabled: !configuration_required,
|
||||
enabled: !configuration_required && compatible,
|
||||
configuration: configuration.clone(),
|
||||
secrets: None,
|
||||
bindings: bindings.clone(),
|
||||
|
||||
@@ -1,773 +0,0 @@
|
||||
//! 插件实例的配置、停用、删除与私有状态迁移编排
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use gateway_core::{
|
||||
policy::ClientApiKeyId,
|
||||
routing::{AccountGroupId, extensions::ExtensionSetReference},
|
||||
};
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use super::PluginsService;
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind, MutationContext, Revision,
|
||||
plugins::instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceReplacement, PluginInstanceRuntime, PluginInstanceRuntimeStatus,
|
||||
PluginInstanceSnapshot, PluginInstanceView,
|
||||
},
|
||||
plugins::state::{PluginStateCommit, PluginStateConfiguration},
|
||||
},
|
||||
use_case::{map_store_error, publish_committed},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct PackageStatus {
|
||||
warning: Option<String>,
|
||||
load_error: Option<String>,
|
||||
}
|
||||
|
||||
impl PluginsService {
|
||||
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
|
||||
async fn package_status(&self, digest: &str) -> Result<PackageStatus, AdminError> {
|
||||
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
|
||||
// 只缓存有限数量的确定结果,暂时性错误仍可重试
|
||||
let mut cache = self.compatibility.lock().await;
|
||||
if let Some(warning) = cache.get(digest) {
|
||||
return Ok(warning.clone());
|
||||
}
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let warning = match self
|
||||
.inspector
|
||||
.compatibility_warning(artifact.archive, digest.to_owned())
|
||||
.await
|
||||
{
|
||||
Ok(warning) => PackageStatus {
|
||||
warning,
|
||||
load_error: None,
|
||||
},
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
|
||||
warning: None,
|
||||
load_error: Some(error.message().to_owned()),
|
||||
},
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if cache.len() < 128 {
|
||||
cache.insert(digest.to_owned(), warning.clone());
|
||||
}
|
||||
Ok(warning)
|
||||
}
|
||||
|
||||
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let published = self.published.snapshot_for_diagnostics();
|
||||
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
|
||||
let published_ready = published
|
||||
.as_ref()
|
||||
.is_some_and(|snapshot| snapshot.extensions().is_none_or(|set| set.can_serve()));
|
||||
let mut diagnostics = self
|
||||
.preparation
|
||||
.runtime_diagnostics(
|
||||
&snapshot,
|
||||
published_revision,
|
||||
published
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.extensions()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let target_revision = snapshot.config_revision.get();
|
||||
let artifacts = self
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut views = Vec::with_capacity(snapshot.instances.len());
|
||||
for instance in snapshot.instances {
|
||||
let metadata = artifacts
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
let package_status = self.package_status(&instance.artifact_sha256).await?;
|
||||
let configuration_required = package_status.load_error.is_none()
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
let runtime = diagnostics.remove(&instance.id).unwrap_or_else(|| {
|
||||
let running = instance.enabled
|
||||
&& published_ready
|
||||
&& published_revision == Some(target_revision);
|
||||
PluginInstanceRuntime {
|
||||
status: if !instance.enabled {
|
||||
PluginInstanceRuntimeStatus::Disabled
|
||||
} else if running {
|
||||
PluginInstanceRuntimeStatus::Running
|
||||
} else {
|
||||
PluginInstanceRuntimeStatus::AwaitingPublication
|
||||
},
|
||||
actual_revision: running.then_some(instance.revision.get()),
|
||||
actual_artifact_sha256: running.then(|| instance.artifact_sha256.clone()),
|
||||
failure: None,
|
||||
draining_revisions: Vec::new(),
|
||||
}
|
||||
});
|
||||
views.push(PluginInstanceView {
|
||||
api_deprecations: if package_status.load_error.is_none() {
|
||||
self.inspector.api_deprecations(metadata)?
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
configuration_required,
|
||||
compatibility_warning: package_status.warning,
|
||||
load_error: package_status.load_error,
|
||||
running: runtime.status == PluginInstanceRuntimeStatus::Running,
|
||||
published_revision,
|
||||
runtime,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
|
||||
pub async fn configure_instance(
|
||||
&self,
|
||||
id: Option<&str>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
if id.is_some() && input.creation_id.is_some() {
|
||||
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
|
||||
}
|
||||
if id.is_none() && input.expected_revision.is_some() {
|
||||
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
|
||||
}
|
||||
if let Some(creation_id) = &input.creation_id {
|
||||
let parsed = uuid::Uuid::parse_str(creation_id)
|
||||
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
|
||||
if parsed.to_string() != *creation_id {
|
||||
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
|
||||
}
|
||||
}
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let existing = match id {
|
||||
Some(id) => Some(
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
|
||||
),
|
||||
None => input.creation_id.as_ref().and_then(|creation_id| {
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == *creation_id)
|
||||
.cloned()
|
||||
}),
|
||||
};
|
||||
if let Some(existing) = &existing {
|
||||
if id.is_none() && !same_creation_request(existing, &input) {
|
||||
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
|
||||
}
|
||||
if input
|
||||
.expected_revision
|
||||
.is_some_and(|revision| revision != existing.revision.get())
|
||||
{
|
||||
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
|
||||
}
|
||||
}
|
||||
self.configure_from_snapshot(snapshot, existing, input, context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn configure_from_snapshot(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
existing: Option<PluginInstance>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let expected_revision = snapshot.config_revision;
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
let artifacts = self
|
||||
.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let artifact = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
if artifact.accepted_at.is_none() {
|
||||
return Err(AdminError::invalid("请先安装并接受该版本声明的权限"));
|
||||
}
|
||||
if input.enabled
|
||||
&& let Some(warning) = self
|
||||
.package_status(&input.artifact_sha256)
|
||||
.await?
|
||||
.load_error
|
||||
{
|
||||
return Err(AdminError::invalid(format!(
|
||||
"{warning},无法启动,请安装兼容版本"
|
||||
)));
|
||||
}
|
||||
let same_plugin = |digest: &str| {
|
||||
artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == digest
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
})
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
|
||||
{
|
||||
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
|
||||
}
|
||||
if input.enabled
|
||||
&& snapshot.instances.iter().any(|other| {
|
||||
other.enabled
|
||||
&& existing
|
||||
.as_ref()
|
||||
.is_none_or(|current| current.id != other.id)
|
||||
&& same_plugin(&other.artifact_sha256)
|
||||
&& !input
|
||||
.replace_instances
|
||||
.iter()
|
||||
.any(|replacement| replacement.id == other.id)
|
||||
})
|
||||
{
|
||||
return Err(AdminError::conflict(
|
||||
"此插件已有启用配置,请先切换或停用旧配置",
|
||||
));
|
||||
}
|
||||
let saved = if let Some(previous) = &existing
|
||||
&& previous.artifact_sha256 != input.artifact_sha256
|
||||
&& input.secrets.is_none()
|
||||
{
|
||||
self.store
|
||||
.load_version_configuration(&previous.id, &input.artifact_sha256)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let instance = PluginInstance {
|
||||
id: existing.as_ref().map_or_else(
|
||||
|| {
|
||||
input
|
||||
.creation_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
|
||||
},
|
||||
|instance| instance.id.clone(),
|
||||
),
|
||||
name: input.name.trim().to_owned(),
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: input.enabled,
|
||||
trusted_process: true,
|
||||
configuration: input.configuration,
|
||||
secrets: input
|
||||
.secrets
|
||||
.or_else(|| saved.map(|saved| saved.secrets))
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
}),
|
||||
bindings: input.bindings,
|
||||
revision: candidate_revision,
|
||||
};
|
||||
validate_input(&instance)?;
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), &artifact.metadata)
|
||||
.await?
|
||||
{
|
||||
return Err(AdminError::invalid(
|
||||
"目标版本仍缺少必填设置,请补充后再切换",
|
||||
));
|
||||
}
|
||||
|
||||
let replacements = input.replace_instances;
|
||||
if !replacements.is_empty() {
|
||||
if !instance.enabled || replacements.len() > 256 {
|
||||
return Err(AdminError::invalid(
|
||||
"仅启用配置时可以切换其他配置,单次最多 256 项",
|
||||
));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for replacement in &replacements {
|
||||
if replacement.id == instance.id || !ids.insert(&replacement.id) {
|
||||
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
|
||||
}
|
||||
let previous = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|item| item.id == replacement.id)
|
||||
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
|
||||
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
|
||||
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
|
||||
}
|
||||
if !artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == previous.artifact_sha256
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
}) {
|
||||
return Err(AdminError::invalid("只能切换同一插件的配置"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = self.preparation.validate(instance.clone()).await?;
|
||||
if let Some(existing) = &existing
|
||||
&& self.state.transition_required(&existing.id, &state).await?
|
||||
{
|
||||
return self
|
||||
.configure_instance_with_state_transition(
|
||||
snapshot,
|
||||
existing.clone(),
|
||||
instance,
|
||||
state,
|
||||
&replacements,
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
snapshot.instances.retain(|item| item.id != instance.id);
|
||||
snapshot.instances.push(instance.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
apply_replacements(&mut snapshot, &replacements);
|
||||
let prepared = self.preparation.prepare(snapshot.clone()).await?;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
instance,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: state,
|
||||
transition_id: None,
|
||||
},
|
||||
&replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
self.publish_instance(snapshot, prepared, &result, &replacements)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn configure_instance_with_state_transition(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
previous: PluginInstance,
|
||||
mut target: PluginInstance,
|
||||
target_state: PluginStateConfiguration,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let previous_state = self.preparation.validate(previous.clone()).await?;
|
||||
let original_revision = snapshot.config_revision;
|
||||
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
|
||||
let mut migration_instance = target.clone();
|
||||
migration_instance.enabled = true;
|
||||
snapshot.config_revision = migration_instance.revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|instance| instance.id != migration_instance.id);
|
||||
snapshot.instances.push(migration_instance);
|
||||
apply_replacements(&mut snapshot, replacements);
|
||||
let migration_prepared = self.preparation.prepare(snapshot).await?;
|
||||
|
||||
let mut disabled_revision = None;
|
||||
let disabled = if previous.enabled {
|
||||
let mut disabled = previous.clone();
|
||||
disabled.enabled = false;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
disabled,
|
||||
original_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
self.preparation
|
||||
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
|
||||
.await;
|
||||
disabled_revision = Some(mutation.config_revision);
|
||||
mutation.instance
|
||||
} else {
|
||||
previous.clone()
|
||||
};
|
||||
let expected_revision = disabled_revision.unwrap_or(original_revision);
|
||||
let transition = match self
|
||||
.state
|
||||
.begin_transition(
|
||||
&disabled.id,
|
||||
disabled.revision,
|
||||
&target.artifact_sha256,
|
||||
target_state.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(transition) => transition,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
None,
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
|
||||
let committed = async {
|
||||
self.preparation
|
||||
.migrate_state(&migration_prepared, transition.clone())
|
||||
.await?;
|
||||
let mut current = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if current.config_revision != expected_revision {
|
||||
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
|
||||
}
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
target.revision = candidate_revision;
|
||||
current.instances.retain(|item| item.id != target.id);
|
||||
current.instances.push(target.clone());
|
||||
current.config_revision = candidate_revision;
|
||||
apply_replacements(&mut current, replacements);
|
||||
let prepared = self.preparation.prepare(current.clone()).await?;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
target,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: target_state,
|
||||
transition_id: Some(transition.id.clone()),
|
||||
},
|
||||
replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
Ok((current, prepared, result))
|
||||
}
|
||||
.await;
|
||||
let (current, prepared, result) = match committed {
|
||||
Ok(committed) => committed,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
self.publish_instance(current, prepared, &result, replacements)
|
||||
.await?;
|
||||
drop(migration_prepared);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn recover_failed_state_transition(
|
||||
&self,
|
||||
transition_id: Option<&str>,
|
||||
previous: &PluginInstance,
|
||||
previous_state: &PluginStateConfiguration,
|
||||
disabled_revision: Option<Revision>,
|
||||
context: &MutationContext,
|
||||
original_error: AdminError,
|
||||
) -> AdminError {
|
||||
if let Some(transition_id) = transition_id {
|
||||
self.state.abort_transition(transition_id).await;
|
||||
}
|
||||
let Some(disabled_revision) = disabled_revision else {
|
||||
return original_error;
|
||||
};
|
||||
let restored = async {
|
||||
let mut snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if snapshot.config_revision != disabled_revision {
|
||||
return Err(AdminError::conflict(
|
||||
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
|
||||
));
|
||||
}
|
||||
let candidate_revision = next_revision(disabled_revision)?;
|
||||
let mut restore = previous.clone();
|
||||
restore.enabled = true;
|
||||
restore.revision = candidate_revision;
|
||||
snapshot.instances.retain(|item| item.id != restore.id);
|
||||
snapshot.instances.push(restore.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
let prepared = self.preparation.prepare(snapshot.clone()).await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
restore,
|
||||
disabled_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
self.publish_instance(snapshot, prepared, &mutation, &[])
|
||||
.await?;
|
||||
Ok::<(), AdminError>(())
|
||||
}
|
||||
.await;
|
||||
match restored {
|
||||
Ok(()) => original_error,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error_kind = ?error.kind(),
|
||||
"plugin state migration failed and the previous instance could not be restored"
|
||||
);
|
||||
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
|
||||
async fn publish_instance(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
prepared: ExtensionSetReference,
|
||||
mutation: &PluginInstanceMutation,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
) -> Result<(), AdminError> {
|
||||
let prepared = if mutation.config_revision == snapshot.config_revision
|
||||
&& mutation.instance.revision == snapshot.config_revision
|
||||
{
|
||||
prepared
|
||||
} else {
|
||||
snapshot.config_revision = mutation.config_revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|item| item.id != mutation.instance.id);
|
||||
snapshot.instances.push(mutation.instance.clone());
|
||||
apply_replacements(&mut snapshot, replacements);
|
||||
self.preparation.prepare(snapshot).await?
|
||||
};
|
||||
self.preparation
|
||||
.activate_state(&prepared, &mutation.instance)
|
||||
.await?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
drop(prepared);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let instance = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
if instance.enabled {
|
||||
return Err(AdminError::conflict("请先停用插件实例"));
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.delete_instance(id, snapshot.config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
|
||||
pub async fn disable_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let PluginInstanceSnapshot {
|
||||
config_revision,
|
||||
instances,
|
||||
} = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let mut instance = instances
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
instance.enabled = false;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance(instance, config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_replacements(
|
||||
snapshot: &mut PluginInstanceSnapshot,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
) {
|
||||
for instance in &mut snapshot.instances {
|
||||
if replacements.iter().any(|item| item.id == instance.id) {
|
||||
instance.enabled = false;
|
||||
instance.revision = snapshot.config_revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_creation_request(existing: &PluginInstance, input: &ConfigurePluginInstance) -> bool {
|
||||
let same_secrets = input
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map_or(existing.secrets.is_empty(), |secrets| {
|
||||
secrets.len() == existing.secrets.len()
|
||||
&& secrets.iter().all(|(key, value)| {
|
||||
existing
|
||||
.secrets
|
||||
.get(key)
|
||||
.is_some_and(|current| current.expose_secret() == value.expose_secret())
|
||||
})
|
||||
});
|
||||
existing.name == input.name.trim()
|
||||
&& existing.artifact_sha256 == input.artifact_sha256
|
||||
&& existing.enabled == input.enabled
|
||||
&& existing.configuration == input.configuration
|
||||
&& existing.bindings == input.bindings
|
||||
&& same_secrets
|
||||
}
|
||||
|
||||
fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
|
||||
if instance.name.is_empty()
|
||||
|| instance.name.len() > 128
|
||||
|| instance.name.chars().any(char::is_control)
|
||||
|| instance.artifact_sha256.len() != 64
|
||||
|| !instance
|
||||
.artifact_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
|| !instance.configuration.is_object()
|
||||
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|
||||
|| instance.secrets.len() > 64
|
||||
|| instance.bindings.len() > 64
|
||||
{
|
||||
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
|
||||
}
|
||||
use secrecy::ExposeSecret as _;
|
||||
if instance
|
||||
.secrets
|
||||
.iter()
|
||||
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|
||||
|| instance
|
||||
.secrets
|
||||
.values()
|
||||
.map(|value| value.expose_secret().len())
|
||||
.sum::<usize>()
|
||||
> 48 * 1024
|
||||
{
|
||||
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
|
||||
}
|
||||
if instance.bindings.iter().any(|binding| {
|
||||
binding.contribution.is_empty()
|
||||
|| binding.contribution.len() > 128
|
||||
|| !binding
|
||||
.contribution
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|
||||
|| binding.client_key_ids.len() > 256
|
||||
|| binding.account_group_ids.len() > 256
|
||||
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
|
||||
!= binding.client_key_ids.len()
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.account_group_ids.len()
|
||||
|| binding
|
||||
.client_key_ids
|
||||
.iter()
|
||||
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.any(|id| AccountGroupId::new(id.clone()).is_err())
|
||||
|| binding.identity_bindings.len() > 256
|
||||
|| binding
|
||||
.identity_bindings
|
||||
.iter()
|
||||
.map(|identity| &identity.principal)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.identity_bindings.len()
|
||||
|| binding.identity_bindings.iter().any(|identity| {
|
||||
identity.principal.trim() != identity.principal
|
||||
|| identity.principal.is_empty()
|
||||
|| identity.principal.len() > 256
|
||||
|| identity.principal.chars().any(char::is_control)
|
||||
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
|
||||
})
|
||||
}) {
|
||||
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
|
||||
Revision::new(
|
||||
revision
|
||||
.get()
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("配置版本不合法"))
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! 插件配置的输入约束、创建幂等比较与候选快照变换
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use gateway_core::{policy::ClientApiKeyId, routing::AccountGroupId};
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use crate::model::{
|
||||
AdminError, Revision,
|
||||
plugins::instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceReplacement, PluginInstanceSnapshot,
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) fn apply_replacements(
|
||||
snapshot: &mut PluginInstanceSnapshot,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
) {
|
||||
for instance in &mut snapshot.instances {
|
||||
if replacements.iter().any(|item| item.id == instance.id) {
|
||||
instance.enabled = false;
|
||||
instance.revision = snapshot.config_revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn same_creation_request(
|
||||
existing: &PluginInstance,
|
||||
input: &ConfigurePluginInstance,
|
||||
) -> bool {
|
||||
let same_secrets = input
|
||||
.secrets
|
||||
.as_ref()
|
||||
.map_or(existing.secrets.is_empty(), |secrets| {
|
||||
secrets.len() == existing.secrets.len()
|
||||
&& secrets.iter().all(|(key, value)| {
|
||||
existing
|
||||
.secrets
|
||||
.get(key)
|
||||
.is_some_and(|current| current.expose_secret() == value.expose_secret())
|
||||
})
|
||||
});
|
||||
existing.name == input.name.trim()
|
||||
&& existing.artifact_sha256 == input.artifact_sha256
|
||||
&& existing.enabled == input.enabled
|
||||
&& existing.configuration == input.configuration
|
||||
&& existing.bindings == input.bindings
|
||||
&& same_secrets
|
||||
}
|
||||
|
||||
pub(super) fn validate_input(instance: &PluginInstance) -> Result<(), AdminError> {
|
||||
if instance.name.is_empty()
|
||||
|| instance.name.len() > 128
|
||||
|| instance.name.chars().any(char::is_control)
|
||||
|| instance.artifact_sha256.len() != 64
|
||||
|| !instance
|
||||
.artifact_sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
|| !instance.configuration.is_object()
|
||||
|| serde_json::to_vec(&instance.configuration).map_or(true, |bytes| bytes.len() > 48 * 1024)
|
||||
|| instance.secrets.len() > 64
|
||||
|| instance.bindings.len() > 64
|
||||
{
|
||||
return Err(AdminError::invalid("插件实例配置不合法或超过大小限制"));
|
||||
}
|
||||
use secrecy::ExposeSecret as _;
|
||||
if instance
|
||||
.secrets
|
||||
.iter()
|
||||
.any(|(key, value)| key.len() > 64 || value.expose_secret().len() > 8192)
|
||||
|| instance
|
||||
.secrets
|
||||
.values()
|
||||
.map(|value| value.expose_secret().len())
|
||||
.sum::<usize>()
|
||||
> 48 * 1024
|
||||
{
|
||||
return Err(AdminError::invalid("插件敏感配置超过大小限制"));
|
||||
}
|
||||
if instance.bindings.iter().any(|binding| {
|
||||
binding.contribution.is_empty()
|
||||
|| binding.contribution.len() > 128
|
||||
|| !binding
|
||||
.contribution
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'))
|
||||
|| binding.client_key_ids.len() > 256
|
||||
|| binding.account_group_ids.len() > 256
|
||||
|| binding.client_key_ids.iter().collect::<BTreeSet<_>>().len()
|
||||
!= binding.client_key_ids.len()
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.account_group_ids.len()
|
||||
|| binding
|
||||
.client_key_ids
|
||||
.iter()
|
||||
.any(|id| ClientApiKeyId::new(id.clone()).is_err())
|
||||
|| binding
|
||||
.account_group_ids
|
||||
.iter()
|
||||
.any(|id| AccountGroupId::new(id.clone()).is_err())
|
||||
|| binding.identity_bindings.len() > 256
|
||||
|| binding
|
||||
.identity_bindings
|
||||
.iter()
|
||||
.map(|identity| &identity.principal)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.len()
|
||||
!= binding.identity_bindings.len()
|
||||
|| binding.identity_bindings.iter().any(|identity| {
|
||||
identity.principal.trim() != identity.principal
|
||||
|| identity.principal.is_empty()
|
||||
|| identity.principal.len() > 256
|
||||
|| identity.principal.chars().any(char::is_control)
|
||||
|| ClientApiKeyId::new(identity.client_key_id.clone()).is_err()
|
||||
})
|
||||
}) {
|
||||
return Err(AdminError::invalid("插件绑定的范围或入口认证身份不合法"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn next_revision(revision: Revision) -> Result<Revision, AdminError> {
|
||||
Revision::new(
|
||||
revision
|
||||
.get()
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| AdminError::internal("配置版本已耗尽"))?,
|
||||
)
|
||||
.map_err(|_| AdminError::internal("配置版本不合法"))
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! 插件私有状态迁移的排空、暂存提交与失败恢复编排
|
||||
|
||||
use super::{
|
||||
PluginsService,
|
||||
configuration::{apply_replacements, next_revision},
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
instances::{
|
||||
PluginCompatibilityConfirmation, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceReplacement, PluginInstanceSnapshot,
|
||||
},
|
||||
state::{PluginStateCommit, PluginStateConfiguration},
|
||||
},
|
||||
},
|
||||
use_case::{map_store_error, publish_committed},
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub(super) async fn configure_instance_with_state_transition(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
mut target: PluginInstance,
|
||||
target_state: PluginStateConfiguration,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
confirmation: Option<&PluginCompatibilityConfirmation>,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let previous = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == target.id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
let previous_state = self.preparation.validate(previous.clone()).await?;
|
||||
let original_revision = snapshot.config_revision;
|
||||
// 即使最终保持停用,迁移也必须由目标制品的受控候选执行
|
||||
let mut migration_instance = target.clone();
|
||||
migration_instance.enabled = true;
|
||||
snapshot.config_revision = migration_instance.revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|instance| instance.id != migration_instance.id);
|
||||
snapshot.instances.push(migration_instance);
|
||||
apply_replacements(&mut snapshot, replacements);
|
||||
let migration_prepared = self
|
||||
.prepare_instance_candidate(snapshot, confirmation)
|
||||
.await?;
|
||||
|
||||
let mut disabled_revision = None;
|
||||
let disabled = if previous.enabled {
|
||||
let mut disabled = previous.clone();
|
||||
disabled.enabled = false;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
disabled,
|
||||
original_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
self.preparation
|
||||
.quiesce_instance(&previous.id, &previous.artifact_sha256, previous.revision)
|
||||
.await;
|
||||
disabled_revision = Some(mutation.config_revision);
|
||||
mutation.instance
|
||||
} else {
|
||||
previous.clone()
|
||||
};
|
||||
let expected_revision = disabled_revision.unwrap_or(original_revision);
|
||||
let transition = match self
|
||||
.state
|
||||
.begin_transition(
|
||||
&disabled.id,
|
||||
disabled.revision,
|
||||
&target.artifact_sha256,
|
||||
target_state.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(transition) => transition,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
None,
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
// 迁移开始后的所有提交前失败都走同一恢复出口;提交成功后不再回滚旧配置
|
||||
let committed = async {
|
||||
self.preparation
|
||||
.migrate_state(&migration_prepared, transition.clone())
|
||||
.await?;
|
||||
let mut current = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if current.config_revision != expected_revision {
|
||||
return Err(AdminError::conflict("迁移期间插件配置已发生变化"));
|
||||
}
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
target.revision = candidate_revision;
|
||||
current.instances.retain(|item| item.id != target.id);
|
||||
current.instances.push(target.clone());
|
||||
current.config_revision = candidate_revision;
|
||||
apply_replacements(&mut current, replacements);
|
||||
let prepared = self
|
||||
.prepare_instance_candidate(current.clone(), confirmation)
|
||||
.await?;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
target,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: target_state,
|
||||
transition_id: Some(transition.id.clone()),
|
||||
},
|
||||
replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
Ok((current, prepared, result))
|
||||
}
|
||||
.await;
|
||||
let (current, prepared, result) = match committed {
|
||||
Ok(committed) => committed,
|
||||
Err(error) => {
|
||||
return Err(self
|
||||
.recover_failed_state_transition(
|
||||
Some(&transition.id),
|
||||
&previous,
|
||||
&previous_state,
|
||||
disabled_revision,
|
||||
context,
|
||||
error,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
};
|
||||
self.publish_instance(current, prepared, &result, replacements, confirmation)
|
||||
.await?;
|
||||
drop(migration_prepared);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn recover_failed_state_transition(
|
||||
&self,
|
||||
transition_id: Option<&str>,
|
||||
previous: &PluginInstance,
|
||||
previous_state: &PluginStateConfiguration,
|
||||
disabled_revision: Option<Revision>,
|
||||
context: &MutationContext,
|
||||
original_error: AdminError,
|
||||
) -> AdminError {
|
||||
if let Some(transition_id) = transition_id {
|
||||
self.state.abort_transition(transition_id).await;
|
||||
}
|
||||
let Some(disabled_revision) = disabled_revision else {
|
||||
return original_error;
|
||||
};
|
||||
let restored = async {
|
||||
let mut snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
if snapshot.config_revision != disabled_revision {
|
||||
return Err(AdminError::conflict(
|
||||
"状态迁移失败;实例已保持停用,且配置已被其他操作修改",
|
||||
));
|
||||
}
|
||||
let candidate_revision = next_revision(disabled_revision)?;
|
||||
let mut restore = previous.clone();
|
||||
restore.enabled = true;
|
||||
restore.revision = candidate_revision;
|
||||
snapshot.instances.retain(|item| item.id != restore.id);
|
||||
snapshot.instances.push(restore.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
let prepared = self.preparation.prepare(snapshot.clone()).await?;
|
||||
let mutation = self
|
||||
.store
|
||||
.save_instance_with_state(
|
||||
restore,
|
||||
disabled_revision,
|
||||
PluginStateCommit {
|
||||
configuration: previous_state.clone(),
|
||||
transition_id: None,
|
||||
},
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
self.publish_instance(snapshot, prepared, &mutation, &[], None)
|
||||
.await?;
|
||||
Ok::<(), AdminError>(())
|
||||
}
|
||||
.await;
|
||||
match restored {
|
||||
Ok(()) => original_error,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error_kind = ?error.kind(),
|
||||
"plugin state migration failed and the previous instance could not be restored"
|
||||
);
|
||||
AdminError::conflict("状态迁移失败;为避免覆盖并发配置,插件实例已保持停用")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! 插件实例用例的职责边界与静态检查结果
|
||||
|
||||
mod configuration;
|
||||
mod migration;
|
||||
mod mutations;
|
||||
mod publication;
|
||||
mod queries;
|
||||
|
||||
use super::PluginsService;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct PackageStatus {
|
||||
pub(super) warning: Option<String>,
|
||||
pub(super) load_error: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! 插件实例的配置提交、发布、停用与删除用例
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{
|
||||
PluginsService,
|
||||
configuration::{apply_replacements, next_revision, same_creation_request, validate_input},
|
||||
};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, MutationContext, Revision,
|
||||
plugins::{
|
||||
instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginInstanceSnapshot,
|
||||
},
|
||||
state::PluginStateCommit,
|
||||
},
|
||||
},
|
||||
use_case::{map_store_error, publish_committed},
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub async fn configure_instance(
|
||||
&self,
|
||||
id: Option<&str>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
if id.is_some() && input.creation_id.is_some() {
|
||||
return Err(AdminError::invalid("编辑配置不能使用创建标识"));
|
||||
}
|
||||
if id.is_none() && input.expected_revision.is_some() {
|
||||
return Err(AdminError::invalid("创建配置不能指定已有配置版本"));
|
||||
}
|
||||
if let Some(creation_id) = &input.creation_id {
|
||||
let parsed = uuid::Uuid::parse_str(creation_id)
|
||||
.map_err(|_| AdminError::invalid("配置创建标识必须是 UUID"))?;
|
||||
if parsed.to_string() != *creation_id {
|
||||
return Err(AdminError::invalid("配置创建标识必须使用标准 UUID 格式"));
|
||||
}
|
||||
}
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let existing = match id {
|
||||
Some(id) => Some(
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?,
|
||||
),
|
||||
None => input.creation_id.as_ref().and_then(|creation_id| {
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == *creation_id)
|
||||
.cloned()
|
||||
}),
|
||||
};
|
||||
if let Some(existing) = &existing {
|
||||
if id.is_none() && !same_creation_request(existing, &input) {
|
||||
return Err(AdminError::conflict("此配置已创建,请加载最新配置后编辑"));
|
||||
}
|
||||
if input
|
||||
.expected_revision
|
||||
.is_some_and(|revision| revision != existing.revision.get())
|
||||
{
|
||||
return Err(AdminError::conflict("插件配置已变更,请加载最新配置后重试"));
|
||||
}
|
||||
}
|
||||
self.configure_from_snapshot(snapshot, existing, input, context)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(in crate::use_case::plugins) async fn configure_from_snapshot(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
existing: Option<PluginInstance>,
|
||||
input: ConfigurePluginInstance,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let expected_revision = snapshot.config_revision;
|
||||
let candidate_revision = next_revision(expected_revision)?;
|
||||
let artifacts = self
|
||||
.store
|
||||
.list_artifacts()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let artifact = artifacts
|
||||
.iter()
|
||||
.find(|artifact| artifact.metadata.sha256 == input.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
if artifact.accepted_at.is_none() {
|
||||
return Err(AdminError::invalid("请先安装并确认完整信任该版本"));
|
||||
}
|
||||
let confirmation = input.compatibility_confirmation.as_ref();
|
||||
if let Some(confirmed) = confirmation
|
||||
&& (!input.enabled
|
||||
|| existing.as_ref().is_none_or(|instance| {
|
||||
instance.id != confirmed.instance_id
|
||||
|| instance.revision.get() != confirmed.instance_revision
|
||||
})
|
||||
|| input.artifact_sha256 != confirmed.artifact_sha256
|
||||
|| self.inspector.compatibility_host_fingerprint()? != confirmed.host_fingerprint)
|
||||
{
|
||||
return Err(AdminError::conflict(
|
||||
"宿主、插件包或配置已变化,请重新确认启动风险",
|
||||
));
|
||||
}
|
||||
if input.enabled {
|
||||
let status = self.package_status(&input.artifact_sha256).await?;
|
||||
if let Some(reason) = status.load_error {
|
||||
return Err(AdminError::invalid(format!(
|
||||
"{reason},无法启动,请安装兼容版本"
|
||||
)));
|
||||
}
|
||||
if let Some(reason) = status.warning
|
||||
&& confirmation.is_none()
|
||||
{
|
||||
return Err(AdminError::conflict(format!(
|
||||
"插件版本不兼容:{reason};请先确认启动风险"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let same_plugin = |digest: &str| {
|
||||
artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == digest
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
})
|
||||
};
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| !same_plugin(&previous.artifact_sha256))
|
||||
{
|
||||
return Err(AdminError::invalid("不能将已有配置切换为另一个插件"));
|
||||
}
|
||||
if input.enabled
|
||||
&& snapshot.instances.iter().any(|other| {
|
||||
other.enabled
|
||||
&& existing
|
||||
.as_ref()
|
||||
.is_none_or(|current| current.id != other.id)
|
||||
&& same_plugin(&other.artifact_sha256)
|
||||
&& !input
|
||||
.replace_instances
|
||||
.iter()
|
||||
.any(|replacement| replacement.id == other.id)
|
||||
})
|
||||
{
|
||||
return Err(AdminError::conflict(
|
||||
"此插件已有启用配置,请先切换或停用旧配置",
|
||||
));
|
||||
}
|
||||
let saved = if let Some(previous) = &existing
|
||||
&& previous.artifact_sha256 != input.artifact_sha256
|
||||
&& input.secrets.is_none()
|
||||
{
|
||||
self.store
|
||||
.load_version_configuration(&previous.id, &input.artifact_sha256)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let instance = PluginInstance {
|
||||
id: existing.as_ref().map_or_else(
|
||||
|| {
|
||||
input
|
||||
.creation_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::now_v7().to_string())
|
||||
},
|
||||
|instance| instance.id.clone(),
|
||||
),
|
||||
name: input.name.trim().to_owned(),
|
||||
artifact_sha256: input.artifact_sha256,
|
||||
enabled: input.enabled,
|
||||
trusted_process: true,
|
||||
configuration: input.configuration,
|
||||
secrets: input
|
||||
.secrets
|
||||
.or_else(|| saved.map(|saved| saved.secrets))
|
||||
.unwrap_or_else(|| {
|
||||
existing
|
||||
.as_ref()
|
||||
.map_or_else(Default::default, |instance| instance.secrets.clone())
|
||||
}),
|
||||
bindings: input.bindings,
|
||||
revision: candidate_revision,
|
||||
};
|
||||
validate_input(&instance)?;
|
||||
if existing
|
||||
.as_ref()
|
||||
.is_some_and(|previous| previous.artifact_sha256 != instance.artifact_sha256)
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), &artifact.metadata)
|
||||
.await?
|
||||
{
|
||||
return Err(AdminError::invalid(
|
||||
"目标版本仍缺少必填设置,请补充后再切换",
|
||||
));
|
||||
}
|
||||
|
||||
let replacements = input.replace_instances;
|
||||
if !replacements.is_empty() {
|
||||
if !instance.enabled || replacements.len() > 256 {
|
||||
return Err(AdminError::invalid(
|
||||
"仅启用配置时可以切换其他配置,单次最多 256 项",
|
||||
));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for replacement in &replacements {
|
||||
if replacement.id == instance.id || !ids.insert(&replacement.id) {
|
||||
return Err(AdminError::invalid("停用列表不能重复或包含当前配置"));
|
||||
}
|
||||
let previous = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|item| item.id == replacement.id)
|
||||
.ok_or_else(|| AdminError::conflict("待停用配置已变更,请重新确认"))?;
|
||||
if !previous.enabled || previous.revision.get() != replacement.expected_revision {
|
||||
return Err(AdminError::conflict("待停用配置已变更,请重新确认"));
|
||||
}
|
||||
if !artifacts.iter().any(|item| {
|
||||
item.metadata.sha256 == previous.artifact_sha256
|
||||
&& item.metadata.plugin_id == artifact.metadata.plugin_id
|
||||
}) {
|
||||
return Err(AdminError::invalid("只能切换同一插件的配置"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let state = self.preparation.validate(instance.clone()).await?;
|
||||
if let Some(existing) = &existing
|
||||
&& self.state.transition_required(&existing.id, &state).await?
|
||||
{
|
||||
return self
|
||||
.configure_instance_with_state_transition(
|
||||
snapshot,
|
||||
instance,
|
||||
state,
|
||||
&replacements,
|
||||
confirmation,
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
snapshot.instances.retain(|item| item.id != instance.id);
|
||||
snapshot.instances.push(instance.clone());
|
||||
snapshot.config_revision = candidate_revision;
|
||||
apply_replacements(&mut snapshot, &replacements);
|
||||
let prepared = self
|
||||
.prepare_instance_candidate(snapshot.clone(), confirmation)
|
||||
.await?;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance_replacing(
|
||||
instance,
|
||||
expected_revision,
|
||||
PluginStateCommit {
|
||||
configuration: state,
|
||||
transition_id: None,
|
||||
},
|
||||
&replacements,
|
||||
context,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
self.publish_instance(snapshot, prepared, &result, &replacements, confirmation)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn delete_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<Revision, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let instance = snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
if instance.enabled {
|
||||
return Err(AdminError::conflict("请先停用插件实例"));
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.delete_instance(id, snapshot.config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), revision).await?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// 紧急管理修复不要求损坏插件成功准备;提交后沿用现有发布与暂停合同
|
||||
pub async fn disable_instance(
|
||||
&self,
|
||||
id: &str,
|
||||
context: &MutationContext,
|
||||
) -> Result<PluginInstanceMutation, AdminError> {
|
||||
let PluginInstanceSnapshot {
|
||||
config_revision,
|
||||
instances,
|
||||
} = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let mut instance = instances
|
||||
.into_iter()
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
instance.enabled = false;
|
||||
let result = self
|
||||
.store
|
||||
.save_instance(instance, config_revision, context)
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
publish_committed(self.snapshots.as_ref(), result.config_revision).await?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! 配置提交与迁移恢复共用的状态激活和发布收尾
|
||||
|
||||
use gateway_core::routing::extensions::ExtensionSetReference;
|
||||
|
||||
use super::{PluginsService, configuration::apply_replacements};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError,
|
||||
plugins::instances::{
|
||||
PluginCompatibilityConfirmation, PluginInstanceMutation, PluginInstanceReplacement,
|
||||
PluginInstanceSnapshot,
|
||||
},
|
||||
},
|
||||
use_case::publish_committed,
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
pub(super) async fn prepare_instance_candidate(
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
confirmation: Option<&PluginCompatibilityConfirmation>,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
match confirmation {
|
||||
Some(confirmation) => {
|
||||
self.preparation
|
||||
.prepare_confirmed(snapshot, confirmation.clone())
|
||||
.await
|
||||
}
|
||||
None => self.preparation.prepare(snapshot).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// 以事务返回的版本激活并发布,候选强引用必须覆盖整个提交后阶段
|
||||
pub(super) async fn publish_instance(
|
||||
&self,
|
||||
mut snapshot: PluginInstanceSnapshot,
|
||||
prepared: ExtensionSetReference,
|
||||
mutation: &PluginInstanceMutation,
|
||||
replacements: &[PluginInstanceReplacement],
|
||||
confirmation: Option<&PluginCompatibilityConfirmation>,
|
||||
) -> Result<(), AdminError> {
|
||||
let prepared = if mutation.config_revision == snapshot.config_revision
|
||||
&& mutation.instance.revision == snapshot.config_revision
|
||||
{
|
||||
prepared
|
||||
} else {
|
||||
snapshot.config_revision = mutation.config_revision;
|
||||
snapshot
|
||||
.instances
|
||||
.retain(|item| item.id != mutation.instance.id);
|
||||
snapshot.instances.push(mutation.instance.clone());
|
||||
apply_replacements(&mut snapshot, replacements);
|
||||
self.prepare_instance_candidate(snapshot, confirmation)
|
||||
.await?
|
||||
};
|
||||
self.preparation
|
||||
.activate_state(&prepared, &mutation.instance)
|
||||
.await?;
|
||||
publish_committed(self.snapshots.as_ref(), mutation.config_revision).await?;
|
||||
drop(prepared);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 插件实例列表与静态包检查的只读投影,不从发布事实推断进程状态
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{PackageStatus, PluginsService};
|
||||
use crate::{
|
||||
model::{
|
||||
AdminError, AdminErrorKind,
|
||||
plugins::instances::{
|
||||
PluginInstanceRuntime, PluginInstanceRuntimeStatus, PluginInstanceView,
|
||||
},
|
||||
},
|
||||
use_case::map_store_error,
|
||||
};
|
||||
|
||||
impl PluginsService {
|
||||
/// 只校验固定包体,不启动插件;停用实例也需区分兼容性提醒与加载错误
|
||||
pub(in crate::use_case::plugins) async fn package_status(
|
||||
&self,
|
||||
digest: &str,
|
||||
) -> Result<PackageStatus, AdminError> {
|
||||
// 运行宿主和摘要固定,缓存纯静态结论,避免管理页轮询反复解包
|
||||
// 只缓存有限数量的确定结果,暂时性错误仍可重试
|
||||
if let Some(status) = self.compatibility.lock().await.get(digest).cloned() {
|
||||
return Ok(status);
|
||||
}
|
||||
let artifact = self
|
||||
.store
|
||||
.load_artifact(digest)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "plugin"))?;
|
||||
let status = match self
|
||||
.inspector
|
||||
.compatibility_warning(artifact.archive, digest.to_owned())
|
||||
.await
|
||||
{
|
||||
Ok(warning) => PackageStatus {
|
||||
warning,
|
||||
load_error: None,
|
||||
},
|
||||
Err(error) if error.kind() == AdminErrorKind::Invalid => PackageStatus {
|
||||
warning: None,
|
||||
load_error: Some(error.message().to_owned()),
|
||||
},
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let mut cache = self.compatibility.lock().await;
|
||||
if cache.len() < 128 {
|
||||
cache.insert(digest.to_owned(), status.clone());
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
pub async fn instances(&self) -> Result<Vec<PluginInstanceView>, AdminError> {
|
||||
let snapshot = self
|
||||
.store
|
||||
.load_instances()
|
||||
.await
|
||||
.map_err(|e| map_store_error(e, "plugin"))?;
|
||||
let published = self.published.snapshot_for_diagnostics();
|
||||
let published_revision = published.as_ref().map(|snapshot| snapshot.revision().get());
|
||||
let mut diagnostics = self
|
||||
.preparation
|
||||
.runtime_diagnostics(
|
||||
&snapshot,
|
||||
published_revision,
|
||||
published
|
||||
.as_ref()
|
||||
.and_then(|snapshot| snapshot.extensions()),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let artifacts = self
|
||||
.list()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|artifact| (artifact.metadata.sha256.clone(), artifact.metadata))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut views = Vec::with_capacity(snapshot.instances.len());
|
||||
for instance in snapshot.instances {
|
||||
let metadata = artifacts
|
||||
.get(&instance.artifact_sha256)
|
||||
.ok_or_else(|| AdminError::not_found("插件制品不存在"))?;
|
||||
let package_status = self.package_status(&instance.artifact_sha256).await?;
|
||||
let configuration_required = package_status.load_error.is_none()
|
||||
&& !self
|
||||
.preparation
|
||||
.configuration_ready(instance.clone(), metadata)
|
||||
.await?;
|
||||
let runtime =
|
||||
diagnostics
|
||||
.remove(&instance.id)
|
||||
.unwrap_or_else(|| PluginInstanceRuntime {
|
||||
status: if !instance.enabled {
|
||||
PluginInstanceRuntimeStatus::Disabled
|
||||
} else {
|
||||
PluginInstanceRuntimeStatus::Unknown
|
||||
},
|
||||
actual_revision: None,
|
||||
actual_artifact_sha256: None,
|
||||
failure: None,
|
||||
draining_revisions: Vec::new(),
|
||||
load: None,
|
||||
});
|
||||
views.push(PluginInstanceView {
|
||||
api_deprecations: if package_status.load_error.is_none() {
|
||||
self.inspector.api_deprecations(metadata)?
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
configuration_required,
|
||||
compatibility_warning: package_status.warning,
|
||||
load_error: package_status.load_error,
|
||||
running: runtime.status == PluginInstanceRuntimeStatus::Running,
|
||||
published_revision,
|
||||
runtime,
|
||||
instance,
|
||||
});
|
||||
}
|
||||
Ok(views)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ use crate::{
|
||||
plugins::{
|
||||
PluginArtifactMetadata,
|
||||
instances::{
|
||||
ConfigurePluginInstance, PluginInstance, PluginInstanceMutation,
|
||||
PluginRollbackPlan, PluginRollbackTarget, PluginVersionConfiguration,
|
||||
PluginVersionPlan, RollbackPluginInstance,
|
||||
ConfigurePluginInstance, PluginCompatibilityConfirmation, PluginInstance,
|
||||
PluginInstanceMutation, PluginRollbackPlan, PluginRollbackTarget,
|
||||
PluginVersionConfiguration, PluginVersionPlan, RollbackPluginInstance,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -61,6 +61,7 @@ impl PluginsService {
|
||||
AdminError::invalid("该版本没有可恢复的配置,请通过版本切换检查当前设置")
|
||||
})?;
|
||||
let configuration = ConfigurePluginInstance {
|
||||
compatibility_confirmation: None,
|
||||
creation_id: None,
|
||||
expected_revision: None,
|
||||
replace_instances: Vec::new(),
|
||||
@@ -142,7 +143,21 @@ impl PluginsService {
|
||||
.find(|instance| instance.id == id)
|
||||
.ok_or_else(|| AdminError::not_found("插件实例不存在"))?;
|
||||
let (configuration, restored) = self.version_configuration(instance, digest).await?;
|
||||
let status = self.package_status(digest).await?;
|
||||
let compatibility_confirmation = if status.warning.is_some() {
|
||||
Some(PluginCompatibilityConfirmation {
|
||||
host_fingerprint: self.inspector.compatibility_host_fingerprint()?,
|
||||
instance_id: instance.id.clone(),
|
||||
instance_revision: instance.revision.get(),
|
||||
artifact_sha256: digest.to_owned(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(PluginVersionPlan {
|
||||
compatibility_warning: status.warning,
|
||||
compatibility_confirmation,
|
||||
load_error: status.load_error,
|
||||
instance_revision: instance.revision.get(),
|
||||
artifact_sha256: digest.to_owned(),
|
||||
configuration: configuration.configuration,
|
||||
@@ -179,6 +194,7 @@ impl PluginsService {
|
||||
.version_configuration(&existing, &input.artifact_sha256)
|
||||
.await?;
|
||||
let request = ConfigurePluginInstance {
|
||||
compatibility_confirmation: None,
|
||||
creation_id: None,
|
||||
expected_revision: Some(existing.revision.get()),
|
||||
replace_instances: Vec::new(),
|
||||
|
||||
@@ -1361,7 +1361,6 @@ fn total_record(
|
||||
provider_account_authentication_kind: None,
|
||||
upstream_model_id: Some("gpt-5.5".to_owned()),
|
||||
upstream_transport: None,
|
||||
upstream_response_model: None,
|
||||
service_tier: None,
|
||||
input_tokens: Some(800),
|
||||
output_tokens: Some(200),
|
||||
|
||||
@@ -39,6 +39,7 @@ struct FixtureData {
|
||||
artifact: InstalledPluginArtifact,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
saves: usize,
|
||||
compatibility_warning: Option<String>,
|
||||
publications: Vec<u64>,
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ impl Fixture {
|
||||
instances: existing.into_iter().collect(),
|
||||
},
|
||||
saves: 0,
|
||||
compatibility_warning: None,
|
||||
publications: Vec::new(),
|
||||
}),
|
||||
})
|
||||
@@ -89,6 +91,14 @@ impl SnapshotControl for Fixture {
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for Fixture {
|
||||
async fn compatibility_warning(
|
||||
&self,
|
||||
_: Arc<[u8]>,
|
||||
_: String,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
Ok(self.data.lock().unwrap().compatibility_warning.clone())
|
||||
}
|
||||
|
||||
async fn inspect(
|
||||
&self,
|
||||
archive: Arc<[u8]>,
|
||||
@@ -488,3 +498,20 @@ fn context() -> MutationContext {
|
||||
fn store_error(kind: AdminStoreErrorKind) -> AdminStoreError {
|
||||
AdminStoreError::new(kind, "plugin", "install fixture")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installing_an_incompatible_package_keeps_the_default_instance_stopped() {
|
||||
let fixture = Fixture::new(metadata(true), None);
|
||||
fixture.data.lock().unwrap().compatibility_warning = Some("宿主范围不匹配".into());
|
||||
let result = fixture
|
||||
.service()
|
||||
.accept_artifact(DIGEST, &context())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.configuration_required);
|
||||
let data = fixture.data.lock().unwrap();
|
||||
assert_eq!(data.snapshot.instances.len(), 1);
|
||||
assert!(!data.snapshot.instances[0].enabled);
|
||||
assert!(!data.snapshot.instances[0].bindings.is_empty());
|
||||
assert!(data.artifact.accepted_at.is_some());
|
||||
}
|
||||
|
||||
+103
-44
@@ -49,6 +49,8 @@ use serde_json::json;
|
||||
|
||||
use super::TestPluginPorts;
|
||||
|
||||
mod queries;
|
||||
|
||||
const OLD_ARTIFACT: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const NEW_ARTIFACT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
||||
|
||||
@@ -61,6 +63,10 @@ enum MigrationBehavior {
|
||||
|
||||
#[async_trait]
|
||||
impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture {
|
||||
fn compatibility_host_fingerprint(&self) -> Result<String, AdminError> {
|
||||
Ok("test-host".into())
|
||||
}
|
||||
|
||||
fn api_deprecations(
|
||||
&self,
|
||||
metadata: &PluginArtifactMetadata,
|
||||
@@ -88,6 +94,11 @@ impl gateway_admin::ports::plugins::PluginPackageInspector for LifecycleFixture
|
||||
archive: Arc<[u8]>,
|
||||
digest: String,
|
||||
) -> Result<Option<String>, AdminError> {
|
||||
let gate = self.inspection_gate.lock().unwrap().clone();
|
||||
if let Some(gate) = gate.filter(|gate| gate.digest == digest) {
|
||||
gate.started.notify_one();
|
||||
gate.release.notified().await;
|
||||
}
|
||||
self.inspect(archive, Some(digest)).await?;
|
||||
Ok(self.data.lock().unwrap().compatibility_warning.clone())
|
||||
}
|
||||
@@ -143,11 +154,19 @@ struct LifecycleFixture {
|
||||
behavior: MigrationBehavior,
|
||||
data: Mutex<FixtureData>,
|
||||
next_prepared: AtomicU64,
|
||||
inspection_gate: Mutex<Option<Arc<InspectionGate>>>,
|
||||
}
|
||||
|
||||
struct InspectionGate {
|
||||
digest: String,
|
||||
started: tokio::sync::Notify,
|
||||
release: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
impl LifecycleFixture {
|
||||
fn new(behavior: MigrationBehavior) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
inspection_gate: Mutex::new(None),
|
||||
behavior,
|
||||
data: Mutex::new(FixtureData {
|
||||
snapshot: PluginInstanceSnapshot {
|
||||
@@ -267,6 +286,22 @@ impl ExtensionSetLease for Lease {
|
||||
|
||||
#[async_trait]
|
||||
impl PluginPreparation for LifecycleFixture {
|
||||
async fn prepare_confirmed(
|
||||
&self,
|
||||
snapshot: PluginInstanceSnapshot,
|
||||
confirmation: gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation,
|
||||
) -> Result<ExtensionSetReference, AdminError> {
|
||||
assert_eq!(confirmation.host_fingerprint, "test-host");
|
||||
assert!(
|
||||
snapshot
|
||||
.instances
|
||||
.iter()
|
||||
.any(|instance| instance.id == confirmation.instance_id
|
||||
&& instance.artifact_sha256 == confirmation.artifact_sha256)
|
||||
);
|
||||
self.prepare(snapshot).await
|
||||
}
|
||||
|
||||
async fn configuration_ready(
|
||||
&self,
|
||||
instance: PluginInstance,
|
||||
@@ -716,6 +751,7 @@ fn rollback_input() -> RollbackPluginInstance {
|
||||
|
||||
fn input() -> ConfigurePluginInstance {
|
||||
ConfigurePluginInstance {
|
||||
compatibility_confirmation: None,
|
||||
replace_instances: Vec::new(),
|
||||
creation_id: None,
|
||||
expected_revision: None,
|
||||
@@ -970,49 +1006,6 @@ fn state_unavailable<T>() -> PluginStateStoreResult<T> {
|
||||
))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let expected = PluginInstanceRuntime {
|
||||
status: PluginInstanceRuntimeStatus::PreparationFailed,
|
||||
actual_revision: Some(9),
|
||||
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
|
||||
failure: Some(PluginInstanceRuntimeFailure {
|
||||
code: "unavailable".into(),
|
||||
message: "插件候选准备失败".into(),
|
||||
}),
|
||||
draining_revisions: vec![8],
|
||||
};
|
||||
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
|
||||
"00000000-0000-7000-8000-000000000001".into(),
|
||||
expected.clone(),
|
||||
)]));
|
||||
|
||||
let views = service(fixture, Arc::new(Published::default()))
|
||||
.instances()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(views.len(), 1);
|
||||
assert!(!views[0].running);
|
||||
assert_eq!(views[0].runtime, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_falls_back_without_inventing_runtime_failures() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let views = service(fixture, Arc::new(Published::default()))
|
||||
.instances()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(
|
||||
views[0].runtime.status,
|
||||
PluginInstanceRuntimeStatus::AwaitingPublication
|
||||
);
|
||||
assert!(!views[0].running);
|
||||
assert!(views[0].runtime.failure.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_state_upgrade_quiesces_migrates_and_promotes_the_target() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
@@ -1644,7 +1637,7 @@ async fn transient_compatibility_check_failure_can_be_retried() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compatibility_warning_allows_enabling_and_persists_after_success() {
|
||||
async fn incompatible_start_requires_confirmation_and_preserves_the_warning() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
@@ -1657,6 +1650,21 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() {
|
||||
assert!(view.load_error.is_none());
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
let error = service
|
||||
.configure_instance(Some(&view.instance.id), request, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.kind(), AdminErrorKind::Conflict);
|
||||
assert!(fixture.data.lock().unwrap().saves.is_empty());
|
||||
assert!(fixture.data.lock().unwrap().prepared_snapshots.is_empty());
|
||||
let plan = service
|
||||
.version_plan(&view.instance.id, OLD_ARTIFACT)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
request.compatibility_confirmation = plan.compatibility_confirmation;
|
||||
let result = service
|
||||
.configure_instance(Some(&view.instance.id), request, &context())
|
||||
.await
|
||||
@@ -1667,3 +1675,54 @@ async fn compatibility_warning_allows_enabling_and_persists_after_success() {
|
||||
assert!(view.compatibility_warning.is_some());
|
||||
assert!(view.load_error.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn incompatible_start_rejects_stale_confirmations_and_unloadable_packages() {
|
||||
for mismatch in ["host", "instance", "revision", "artifact", "unloadable"] {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
{
|
||||
let mut data = fixture.data.lock().unwrap();
|
||||
data.snapshot.instances[0].enabled = false;
|
||||
data.compatibility_warning = Some("middleware v2 未声明兼容".into());
|
||||
if mismatch == "unloadable" {
|
||||
data.inspection_error = Some(AdminErrorKind::Invalid);
|
||||
}
|
||||
}
|
||||
let service = service(fixture.clone(), Arc::new(Published::default()));
|
||||
let previous = fixture.data.lock().unwrap().snapshot.instances[0].clone();
|
||||
let mut confirmation =
|
||||
gateway_admin::model::plugins::instances::PluginCompatibilityConfirmation {
|
||||
host_fingerprint: "test-host".into(),
|
||||
instance_id: previous.id.clone(),
|
||||
instance_revision: previous.revision.get(),
|
||||
artifact_sha256: OLD_ARTIFACT.into(),
|
||||
};
|
||||
match mismatch {
|
||||
"host" => confirmation.host_fingerprint = "another-host".into(),
|
||||
"instance" => confirmation.instance_id = "another-instance".into(),
|
||||
"revision" => confirmation.instance_revision += 1,
|
||||
"artifact" => confirmation.artifact_sha256 = NEW_ARTIFACT.into(),
|
||||
_ => {}
|
||||
}
|
||||
let mut request = input();
|
||||
request.artifact_sha256 = OLD_ARTIFACT.into();
|
||||
request.compatibility_confirmation = Some(confirmation);
|
||||
let error = service
|
||||
.configure_instance(Some(&previous.id), request, &context())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
error.kind(),
|
||||
if mismatch == "unloadable" {
|
||||
AdminErrorKind::Invalid
|
||||
} else {
|
||||
AdminErrorKind::Conflict
|
||||
}
|
||||
);
|
||||
let data = fixture.data.lock().unwrap();
|
||||
assert!(data.saves.is_empty(), "{mismatch}");
|
||||
assert!(data.prepared_snapshots.is_empty(), "{mismatch}");
|
||||
assert!(!data.snapshot.instances[0].enabled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
//! 实例运行诊断与包检查缓存的查询回归测试
|
||||
|
||||
use super::*;
|
||||
use gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad;
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_uses_runtime_diagnostics_and_keeps_running_compatibility_derived() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let expected = PluginInstanceRuntime {
|
||||
status: PluginInstanceRuntimeStatus::PreparationFailed,
|
||||
actual_revision: Some(9),
|
||||
actual_artifact_sha256: Some(OLD_ARTIFACT.into()),
|
||||
failure: Some(PluginInstanceRuntimeFailure {
|
||||
code: "unavailable".into(),
|
||||
message: "插件候选准备失败".into(),
|
||||
}),
|
||||
draining_revisions: vec![8],
|
||||
load: Some(PluginInstanceRuntimeLoad {
|
||||
active_calls: 3,
|
||||
executing_calls: 1,
|
||||
queued_calls: 1,
|
||||
resuming_calls: 1,
|
||||
pending_callbacks: 2,
|
||||
active_streams: 0,
|
||||
}),
|
||||
};
|
||||
fixture.data.lock().unwrap().diagnostics = Some(BTreeMap::from([(
|
||||
"00000000-0000-7000-8000-000000000001".into(),
|
||||
expected.clone(),
|
||||
)]));
|
||||
|
||||
let views = service(fixture, Arc::new(Published::default()))
|
||||
.instances()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(views.len(), 1);
|
||||
assert!(!views[0].running);
|
||||
assert_eq!(views[0].runtime, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_list_falls_back_without_inventing_runtime_failures() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let views = service(fixture, Arc::new(Published::default()))
|
||||
.instances()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(
|
||||
views[0].runtime.status,
|
||||
PluginInstanceRuntimeStatus::Unknown
|
||||
);
|
||||
assert!(!views[0].running);
|
||||
assert!(views[0].runtime.failure.is_none());
|
||||
assert!(views[0].runtime.actual_revision.is_none());
|
||||
assert!(views[0].runtime.load.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_instance_without_runtime_diagnostics_has_a_known_disabled_status() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
fixture.data.lock().unwrap().snapshot.instances[0].enabled = false;
|
||||
let views = service(fixture, Arc::new(Published::default()))
|
||||
.instances()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
views[0].runtime.status,
|
||||
PluginInstanceRuntimeStatus::Disabled
|
||||
);
|
||||
assert!(!views[0].running);
|
||||
assert!(views[0].runtime.failure.is_none());
|
||||
assert!(views[0].runtime.load.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_publication_without_process_diagnostics_does_not_imply_running() {
|
||||
for diagnostics in [None, Some(BTreeMap::new())] {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
fixture.data.lock().unwrap().diagnostics = diagnostics;
|
||||
let revision = fixture.snapshot().config_revision.get();
|
||||
let published = RuntimeSnapshotHandle::new(
|
||||
gateway_core::routing::RuntimeSnapshot::new(
|
||||
ConfigRevision::new(revision).unwrap(),
|
||||
gateway_core::settings::SettingsValues::new(
|
||||
1,
|
||||
"round_robin",
|
||||
BTreeMap::new(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let service = PluginsService::new(
|
||||
fixture.clone(),
|
||||
fixture.clone(),
|
||||
PluginDistributionPorts::new(Arc::new(TestPluginPorts), Arc::new(TestPluginPorts)),
|
||||
Arc::new(Published::default()),
|
||||
fixture.clone(),
|
||||
published,
|
||||
fixture,
|
||||
);
|
||||
let view = service.instances().await.unwrap().remove(0);
|
||||
assert_eq!(view.published_revision, Some(revision));
|
||||
assert_eq!(view.runtime.status, PluginInstanceRuntimeStatus::Unknown);
|
||||
assert!(!view.running);
|
||||
assert!(view.runtime.actual_revision.is_none());
|
||||
assert!(view.runtime.actual_artifact_sha256.is_none());
|
||||
assert!(view.runtime.failure.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_package_query_is_not_blocked_by_another_package_inspection() {
|
||||
let fixture = LifecycleFixture::new(MigrationBehavior::Succeed);
|
||||
let service = Arc::new(service(fixture.clone(), Arc::new(Published::default())));
|
||||
service.instances().await.unwrap();
|
||||
let gate = Arc::new(InspectionGate {
|
||||
digest: NEW_ARTIFACT.into(),
|
||||
started: tokio::sync::Notify::new(),
|
||||
release: tokio::sync::Notify::new(),
|
||||
});
|
||||
*fixture.inspection_gate.lock().unwrap() = Some(gate.clone());
|
||||
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = NEW_ARTIFACT.into();
|
||||
let inspecting = {
|
||||
let service = service.clone();
|
||||
tokio::spawn(async move { service.instances().await })
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), gate.started.notified())
|
||||
.await
|
||||
.expect("another package inspection started");
|
||||
|
||||
fixture.data.lock().unwrap().snapshot.instances[0].artifact_sha256 = OLD_ARTIFACT.into();
|
||||
let cached =
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), service.instances()).await;
|
||||
gate.release.notify_one();
|
||||
inspecting.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
cached
|
||||
.expect("cached query must not wait for inspection")
|
||||
.unwrap()[0]
|
||||
.instance
|
||||
.artifact_sha256,
|
||||
OLD_ARTIFACT
|
||||
);
|
||||
assert_eq!(fixture.data.lock().unwrap().archive_reads, 2);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ tower-http = { version = "0.7.0", features = ["compression-gzip", "cors", "fs",
|
||||
tracing = "0.1.44"
|
||||
url = "2.5.8"
|
||||
uuid = { version = "1.23.3", features = ["v7"] }
|
||||
zstd = "=0.13.3"
|
||||
zstd = "=0.14.3"
|
||||
zip = { version = "9.0.4", default-features = false, features = ["deflate-flate2"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -28,13 +28,12 @@ pub(super) fn build(document: &Value) -> io::Result<Vec<u8>> {
|
||||
let detail = &evidence["detail"]["request"];
|
||||
writeln!(
|
||||
archive,
|
||||
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={},上游报告={}\n传输:{} → {}\n时延:{}",
|
||||
"\n结果:{};状态码:{};尝试数:{}\n模型:请求={},发送={}\n传输:{} → {}\n时延:{}",
|
||||
detail["logicalOutcome"],
|
||||
detail["statusCode"],
|
||||
detail["attemptCount"],
|
||||
detail["requestedModel"],
|
||||
detail["upstreamModel"],
|
||||
detail["upstreamResponseModel"],
|
||||
detail["clientTransport"],
|
||||
detail["upstreamTransport"],
|
||||
detail["latencyDetails"]
|
||||
|
||||
@@ -279,7 +279,6 @@ pub(crate) fn usage_list_record_view(
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
upstream_model: record.upstream_model_id,
|
||||
upstream_response_model: record.upstream_response_model,
|
||||
service_tier: record.service_tier,
|
||||
client_transport: record.client_transport,
|
||||
upstream_transport: record.upstream_transport,
|
||||
@@ -377,7 +376,6 @@ pub(crate) fn usage_record_view(
|
||||
model,
|
||||
requested_model: record.requested_model_id,
|
||||
upstream_model: record.upstream_model_id,
|
||||
upstream_response_model: record.upstream_response_model,
|
||||
service_tier: record.service_tier,
|
||||
status_code,
|
||||
client_transport: record.client_transport,
|
||||
|
||||
@@ -103,7 +103,6 @@ pub struct UsageListRecordView {
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub client_transport: String,
|
||||
pub upstream_transport: Option<String>,
|
||||
@@ -142,7 +141,6 @@ pub struct UsageRecordView {
|
||||
pub model: Option<String>,
|
||||
pub requested_model: Option<String>,
|
||||
pub upstream_model: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub status_code: Option<i64>,
|
||||
pub client_transport: String,
|
||||
|
||||
@@ -9,8 +9,8 @@ use axum::{
|
||||
};
|
||||
use gateway_admin::model::plugins::instances::{
|
||||
ConfigurePluginInstance, PluginCapabilityBinding, PluginInstance, PluginInstanceRuntime,
|
||||
PluginInstanceRuntimeFailure, PluginInstanceRuntimeStatus, PluginInstanceView,
|
||||
RollbackPluginInstance,
|
||||
PluginInstanceRuntimeFailure, PluginInstanceRuntimeLoad, PluginInstanceRuntimeStatus,
|
||||
PluginInstanceView, RollbackPluginInstance,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -70,6 +70,7 @@ struct InstanceView {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum InstanceRuntimeStatus {
|
||||
Disabled,
|
||||
Unknown,
|
||||
AwaitingPublication,
|
||||
Preparing,
|
||||
Running,
|
||||
@@ -83,6 +84,7 @@ impl From<PluginInstanceRuntimeStatus> for InstanceRuntimeStatus {
|
||||
fn from(value: PluginInstanceRuntimeStatus) -> Self {
|
||||
match value {
|
||||
PluginInstanceRuntimeStatus::Disabled => Self::Disabled,
|
||||
PluginInstanceRuntimeStatus::Unknown => Self::Unknown,
|
||||
PluginInstanceRuntimeStatus::AwaitingPublication => Self::AwaitingPublication,
|
||||
PluginInstanceRuntimeStatus::Preparing => Self::Preparing,
|
||||
PluginInstanceRuntimeStatus::Running => Self::Running,
|
||||
@@ -118,6 +120,31 @@ struct InstanceRuntimeView {
|
||||
actual_artifact_sha256: Option<String>,
|
||||
failure: Option<InstanceRuntimeFailure>,
|
||||
draining_revisions: Vec<u64>,
|
||||
load: Option<InstanceRuntimeLoad>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InstanceRuntimeLoad {
|
||||
active_calls: usize,
|
||||
executing_calls: usize,
|
||||
queued_calls: usize,
|
||||
resuming_calls: usize,
|
||||
pending_callbacks: usize,
|
||||
active_streams: usize,
|
||||
}
|
||||
|
||||
impl From<PluginInstanceRuntimeLoad> for InstanceRuntimeLoad {
|
||||
fn from(value: PluginInstanceRuntimeLoad) -> Self {
|
||||
Self {
|
||||
active_calls: value.active_calls,
|
||||
executing_calls: value.executing_calls,
|
||||
queued_calls: value.queued_calls,
|
||||
resuming_calls: value.resuming_calls,
|
||||
pending_callbacks: value.pending_callbacks,
|
||||
active_streams: value.active_streams,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginInstanceRuntime> for InstanceRuntimeView {
|
||||
@@ -128,6 +155,7 @@ impl From<PluginInstanceRuntime> for InstanceRuntimeView {
|
||||
actual_artifact_sha256: value.actual_artifact_sha256,
|
||||
failure: value.failure.map(Into::into),
|
||||
draining_revisions: value.draining_revisions,
|
||||
load: value.load.map(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,7 +764,6 @@ pub(super) fn usage_record_with_account(
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
http_version: Some("h2".to_owned()),
|
||||
websocket_pool: None,
|
||||
upstream_response_model: None,
|
||||
service_tier: None,
|
||||
provider_metadata_json: None,
|
||||
attempt_count: 1,
|
||||
@@ -909,7 +908,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
provider_account_authentication_kind: Some("oauth".to_owned()),
|
||||
upstream_model_id: Some("grok-4.5".to_owned()),
|
||||
upstream_transport: Some("http_sse".to_owned()),
|
||||
upstream_response_model: Some("grok-4.6-build".to_owned()),
|
||||
service_tier: Some("default".to_owned()),
|
||||
input_tokens: Some(1),
|
||||
output_tokens: Some(1),
|
||||
@@ -1059,7 +1057,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
"imageOutputTokens": value["data"]["items"][0]["tokenDetails"]["imageOutputTokens"],
|
||||
"requestedModel": value["data"]["items"][0]["requestedModel"],
|
||||
"upstreamModel": value["data"]["items"][0]["upstreamModel"],
|
||||
"upstreamResponseModel": value["data"]["items"][0]["upstreamResponseModel"],
|
||||
"reasoningEffort": value["data"]["items"][0]["reasoningEffort"],
|
||||
"reasoningPreset": value["data"]["items"][0]["reasoningPreset"],
|
||||
"subagentKind": value["data"]["items"][0]["subagentKind"],
|
||||
@@ -1083,7 +1080,6 @@ async fn usage_route_should_expose_table_facts_without_detail_payload() {
|
||||
"imageOutputTokens": 9,
|
||||
"requestedModel": "grok-4.5",
|
||||
"upstreamModel": "grok-4.5",
|
||||
"upstreamResponseModel": "grok-4.6-build",
|
||||
"reasoningEffort": "max",
|
||||
"reasoningPreset": "ultra",
|
||||
"subagentKind": "worker",
|
||||
|
||||
@@ -322,6 +322,17 @@ async fn instance_list_serializes_safe_runtime_diagnostics() {
|
||||
assert_eq!(instance["running"], false);
|
||||
assert_eq!(instance["runtime"]["status"], "preparation_failed");
|
||||
assert_eq!(instance["runtime"]["actualRevision"], 6);
|
||||
assert_eq!(
|
||||
instance["runtime"]["load"],
|
||||
serde_json::json!({
|
||||
"activeCalls": 3,
|
||||
"executingCalls": 1,
|
||||
"queuedCalls": 1,
|
||||
"resumingCalls": 1,
|
||||
"pendingCallbacks": 2,
|
||||
"activeStreams": 0,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
instance["runtime"]["drainingRevisions"],
|
||||
serde_json::json!([5])
|
||||
@@ -665,6 +676,14 @@ impl gateway_admin::ports::plugins::PluginRuntimeDiagnostics for TestPluginPorts
|
||||
message: "插件候选准备失败".into(),
|
||||
}),
|
||||
draining_revisions: vec![5],
|
||||
load: Some(gateway_admin::model::plugins::instances::PluginInstanceRuntimeLoad {
|
||||
active_calls: 3,
|
||||
executing_calls: 1,
|
||||
queued_calls: 1,
|
||||
resuming_calls: 1,
|
||||
pending_callbacks: 2,
|
||||
active_streams: 0,
|
||||
}),
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -110,7 +110,6 @@ fn usage_record() -> UsageListRecord {
|
||||
provider_account_authentication_kind: Some("private-sentinel".to_owned()),
|
||||
upstream_model_id: Some("private-sentinel".to_owned()),
|
||||
upstream_transport: Some("websocket".to_owned()),
|
||||
upstream_response_model: Some("private-sentinel".to_owned()),
|
||||
service_tier: Some("private-sentinel".to_owned()),
|
||||
input_tokens: Some(1000),
|
||||
output_tokens: Some(100),
|
||||
|
||||
@@ -2164,109 +2164,132 @@ async fn streaming_upstream_wire_failure_should_not_be_rewritten_as_a_gateway_er
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_upstream_error_event_should_be_translated_to_response_failed_for_sse() {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error_data = json!({
|
||||
"type": "error",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_is_overloaded",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
|
||||
let error_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("error".to_owned()),
|
||||
error_data,
|
||||
projected_error_frame,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream error wire"),
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
started_wire,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
error_wire,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
let events = parse_sse_events(&body).expect("translated SSE should parse");
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event.as_deref() == Some("response.failed"))
|
||||
.expect("translated response.failed event");
|
||||
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
|
||||
|
||||
assert_eq!(
|
||||
for (error_type, code, client_code, kind, client_status) in [
|
||||
(
|
||||
failed,
|
||||
body.matches("event: response.failed").count(),
|
||||
body.contains("event: error\n"),
|
||||
body.ends_with("data: [DONE]\n\n"),
|
||||
"service_unavailable_error",
|
||||
"server_is_overloaded",
|
||||
"server_error",
|
||||
ProviderErrorKind::UpstreamCapacityUnavailable,
|
||||
503,
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": "service_unavailable_error",
|
||||
"code": "server_error",
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
"rate_limit_error",
|
||||
"turn_rate_limit_exceeded",
|
||||
"turn_rate_limit_exceeded",
|
||||
ProviderErrorKind::RateLimited,
|
||||
429,
|
||||
),
|
||||
] {
|
||||
let trace = Arc::new(Trace::default());
|
||||
let error_data = json!({
|
||||
"type": "error",
|
||||
"status": 429,
|
||||
"headers": {"Retry-After": "24"},
|
||||
"error": {
|
||||
"type": error_type,
|
||||
"code": code,
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
});
|
||||
let projected_error_frame = Bytes::from(encode_sse_event("error", &error_data.to_string()));
|
||||
let error_wire = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json_with_raw_sse_metadata(
|
||||
"openai",
|
||||
Some("error".to_owned()),
|
||||
error_data,
|
||||
projected_error_frame,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("valid upstream error wire"),
|
||||
);
|
||||
let started_wire = ProviderEvent::canonical_with_wire(
|
||||
vec![started()],
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.created".to_owned()),
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "in_progress",
|
||||
"future_response_field": {"keep": true}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("valid upstream started wire"),
|
||||
);
|
||||
let session = FakeSession::streaming(
|
||||
Arc::clone(&trace),
|
||||
vec![
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
started_wire,
|
||||
CommitRequirement::CommitBeforeDelivery,
|
||||
)),
|
||||
NextStep::Event(CoordinatedEvent::single(
|
||||
error_wire,
|
||||
CommitRequirement::AlreadyCommitted,
|
||||
)),
|
||||
NextStep::Error(EngineError::Provider(ProviderError::new(
|
||||
kind,
|
||||
UpstreamSendState::Sent,
|
||||
))),
|
||||
],
|
||||
);
|
||||
|
||||
let response = stream_execution_response(Box::new(session), None).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("read SSE body");
|
||||
let body = String::from_utf8(body.to_vec()).expect("SSE is UTF-8");
|
||||
let events = parse_sse_events(&body).expect("translated SSE should parse");
|
||||
let failed = events
|
||||
.iter()
|
||||
.find(|event| event.event.as_deref() == Some("response.failed"))
|
||||
.expect("translated response.failed event");
|
||||
let failed: Value = serde_json::from_str(&failed.data).expect("response.failed JSON");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
failed,
|
||||
body.matches("event: response.failed").count(),
|
||||
body.contains("event: error\n"),
|
||||
body.ends_with("data: [DONE]\n\n"),
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"status": client_status,
|
||||
"headers": {"Retry-After": "24"},
|
||||
"response": {
|
||||
"id": "resp_test",
|
||||
"model": "public-model",
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"type": error_type,
|
||||
"code": client_code,
|
||||
"message": "Our servers are currently overloaded. Please try again later.",
|
||||
"param": null,
|
||||
"future_error_field": {"keep": true},
|
||||
"headers": {"retry-after": "24"}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"future_response_field": {"keep": true}
|
||||
},
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
}),
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
);
|
||||
"sequence_number": 2,
|
||||
"future_event_field": {"keep": true}
|
||||
}),
|
||||
1,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -948,8 +948,8 @@ fn transparent_encoder_should_translate_error_wire_to_response_failed_for_websoc
|
||||
json!({
|
||||
"type": "error",
|
||||
"status": 429,
|
||||
"headers": {"x-request-id": "req_raw"},
|
||||
"error": {"type": "custom_type", "code": "custom_code", "message": "raw marker"},
|
||||
"headers": {"x-request-id": "req_raw", "Retry-After": "24"},
|
||||
"error": {"type": "rate_limit_error", "code": "turn_rate_limit_exceeded", "message": "raw marker"},
|
||||
"future": {"keep": true}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -9,15 +9,38 @@ use std::{
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::{
|
||||
error::GatewayError,
|
||||
policy::{ClientApiKeyId, PlaintextClientApiKey},
|
||||
routing::extensions::{ExtensionSetId, ExtensionSetReference},
|
||||
routing::{
|
||||
extensions::{ExtensionSetId, ExtensionSetReference},
|
||||
request_settings::RequestSettings,
|
||||
},
|
||||
};
|
||||
|
||||
use super::execution::AuthenticatedClient;
|
||||
|
||||
/// 身份解析与认证只返回冻结权限,不提供模型执行入口
|
||||
pub trait ClientIdentityPort: Send + Sync {
|
||||
/// 只解析身份与冻结权限,不建立模型执行图或记录使用事实
|
||||
fn resolve_client(
|
||||
&self,
|
||||
client_key_id: ClientApiKeyId,
|
||||
settings: Option<RequestSettings>,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
|
||||
|
||||
/// 复用入口认证计划及 principal 映射,不进行模型准入
|
||||
fn verify_client(
|
||||
&self,
|
||||
request: ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>>;
|
||||
}
|
||||
|
||||
pub const MAXIMUM_AUTHORIZATION_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// API 从数据面 Authorization 头构造的有界认证信封
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
#[derive(Clone)]
|
||||
pub struct ClientAuthenticationRequest {
|
||||
invocation: Option<Arc<super::extensions::InvocationOrigin>>,
|
||||
request_context: Option<Arc<super::extensions::RequestContext>>,
|
||||
settings: Option<crate::routing::request_settings::RequestSettings>,
|
||||
authorization: Arc<str>,
|
||||
@@ -25,6 +48,18 @@ pub struct ClientAuthenticationRequest {
|
||||
}
|
||||
|
||||
impl ClientAuthenticationRequest {
|
||||
/// 子认证继承受信调用的来源、取消和副作用归属,不据此跳过认证器
|
||||
#[must_use]
|
||||
pub fn with_invocation(mut self, origin: super::extensions::InvocationOrigin) -> Self {
|
||||
self.invocation = Some(Arc::new(origin));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn invocation(&self) -> Option<&super::extensions::InvocationOrigin> {
|
||||
self.invocation.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_request_context(
|
||||
mut self,
|
||||
@@ -35,12 +70,18 @@ impl ClientAuthenticationRequest {
|
||||
}
|
||||
#[must_use]
|
||||
pub fn request_context(&self) -> Option<&super::extensions::RequestContext> {
|
||||
self.request_context.as_deref()
|
||||
self.invocation
|
||||
.as_ref()
|
||||
.and_then(|origin| origin.extensions.request())
|
||||
.or(self.request_context.as_deref())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn shared_request_context(&self) -> Option<Arc<super::extensions::RequestContext>> {
|
||||
self.request_context.clone()
|
||||
self.invocation
|
||||
.as_ref()
|
||||
.and_then(|origin| origin.extensions.shared_request())
|
||||
.or_else(|| self.request_context.clone())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -79,6 +120,7 @@ impl ClientAuthenticationRequest {
|
||||
.filter(|value| !value.is_empty())
|
||||
.and_then(|value| PlaintextClientApiKey::new(value.to_owned()).ok());
|
||||
Ok(Self {
|
||||
invocation: None,
|
||||
settings: None,
|
||||
request_context: None,
|
||||
authorization: authorization.into(),
|
||||
|
||||
@@ -1110,7 +1110,7 @@ where
|
||||
pinned_account.clone(),
|
||||
self.account_state_owner.clone(),
|
||||
)
|
||||
.with_account_scope(Arc::clone(self.plan.account_scope())),
|
||||
.with_account_scope(Arc::clone(candidate.account_scope())),
|
||||
}
|
||||
.with_credential_recovery_attempted(pinned_account.as_ref().is_some_and(|account| {
|
||||
self.credential_recovery_attempted_accounts
|
||||
@@ -2302,12 +2302,6 @@ where
|
||||
http_version,
|
||||
websocket_pool,
|
||||
service_tier,
|
||||
upstream_response_model: self
|
||||
.current
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::upstream_response_model)
|
||||
.map(str::to_owned),
|
||||
provider_metadata_json,
|
||||
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
|
||||
error: None,
|
||||
@@ -2490,12 +2484,6 @@ where
|
||||
http_version,
|
||||
websocket_pool,
|
||||
service_tier,
|
||||
upstream_response_model: self
|
||||
.current
|
||||
.as_ref()
|
||||
.and_then(|current| current.response_observation.as_ref())
|
||||
.and_then(ProviderResponseObservation::upstream_response_model)
|
||||
.map(str::to_owned),
|
||||
provider_metadata_json,
|
||||
diagnostic_trace_json: self.trace.snapshot().map(|value| value.to_string()),
|
||||
error: Some(finalization.error),
|
||||
|
||||
@@ -12,7 +12,6 @@ pub use contract::{
|
||||
StartedExecution,
|
||||
};
|
||||
mod scope;
|
||||
pub use scope::BoundModelExecutionContext;
|
||||
mod service;
|
||||
pub use failure::gateway_error_from_engine;
|
||||
pub use service::DefaultExecutionService;
|
||||
|
||||
@@ -15,13 +15,10 @@ use crate::{
|
||||
lifecycle::{CancellationToken, Deadline},
|
||||
operation::Operation,
|
||||
policy::ClientApiKeyId,
|
||||
routing::{
|
||||
FrozenAccountScope, PublicModelId, UpstreamModelId, request_settings::RequestSettings,
|
||||
},
|
||||
routing::{FrozenAccountScope, PublicModelId, UpstreamModelId},
|
||||
};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fmt,
|
||||
sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
@@ -188,57 +185,6 @@ pub(super) struct ActiveRequestLease {
|
||||
pub(super) authority: Arc<ActiveRequestAuthority>,
|
||||
}
|
||||
|
||||
/// 管理页或 CLI 一次调用持有的显式模型执行身份
|
||||
///
|
||||
/// 结构不暴露 Key 明文;Runtime 只能把它原样交回 Core 发起模型请求
|
||||
#[derive(Clone)]
|
||||
pub struct BoundModelExecutionContext {
|
||||
pub(super) authority: Arc<BoundModelExecutionAuthority>,
|
||||
}
|
||||
|
||||
impl BoundModelExecutionContext {
|
||||
/// 与实际执行共用绑定时的快照,不能回退到后续发布的配置
|
||||
#[must_use]
|
||||
pub fn request_body_limit(&self) -> usize {
|
||||
self.authority
|
||||
.client
|
||||
.snapshot
|
||||
.responses_max_decompressed_body_bytes()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn request_settings(&self) -> Option<&RequestSettings> {
|
||||
self.authority.client.request_settings()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct BoundModelExecutionAuthority {
|
||||
pub(super) client: AuthenticatedClient,
|
||||
pub(super) account_scope: Arc<FrozenAccountScope>,
|
||||
pub(super) cancellation: CancellationToken,
|
||||
pub(super) extension_scope: ExtensionCallScope,
|
||||
pub(super) initiating_plugin_instance_id: String,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BoundModelExecutionContext {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionContext")
|
||||
.field("key_id", &self.authority.client.policy.key_id())
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.authority.initiating_plugin_instance_id,
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BoundModelExecutionAuthority {
|
||||
fn drop(&mut self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveRequestLease {
|
||||
pub(super) fn release(self) {
|
||||
drop(self);
|
||||
|
||||
@@ -12,9 +12,8 @@ use super::contract::{
|
||||
use super::probe_store::{ProbeObservation, TransientExecutionStore};
|
||||
use super::scope::{
|
||||
ActiveRequestAuthority, ActiveRequestLease, ActiveRequestRegistry, AuthorizedExecution,
|
||||
BoundExecutionFacts, BoundModelExecutionAuthority, BoundModelExecutionContext,
|
||||
ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts, NestedExecutionGraph,
|
||||
PendingStartExecution, PreparedExecutionStart,
|
||||
BoundExecutionFacts, ExecutionStartGuard, ExecutionTarget, NestedExecutionFacts,
|
||||
NestedExecutionGraph, PendingStartExecution, PreparedExecutionStart,
|
||||
};
|
||||
use super::{COORDINATION_TIMEOUT, DIAGNOSTIC_TIMEOUT};
|
||||
use super::{scope::ExecutionAdmission, session::DefaultExecutionSession};
|
||||
@@ -28,7 +27,7 @@ use crate::{
|
||||
ClientAdmissionRequest,
|
||||
},
|
||||
authentication::{
|
||||
ClientAuthenticationRequest, FrontendAuthenticationDecision,
|
||||
ClientAuthenticationRequest, ClientIdentityPort, FrontendAuthenticationDecision,
|
||||
FrontendAuthenticationExtensionIndex,
|
||||
},
|
||||
budget::{ClientBudgetCharge, ClientBudgetPort},
|
||||
@@ -41,8 +40,8 @@ use crate::{
|
||||
middleware::FrozenMiddlewarePlan,
|
||||
nested::{
|
||||
AffinityLookupPort, AffinityLookupRequest, AffinityLookupResult,
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, ExecutionEffects,
|
||||
NestedModelExecutionPort, NestedModelExecutionRequest,
|
||||
BoundModelExecutionRequest, ExecutionEffects, NestedModelExecutionPort,
|
||||
NestedModelExecutionRequest,
|
||||
},
|
||||
observation::{
|
||||
FrozenRequestObservationContext, RequestObservationDispatch, RequestObservationScope,
|
||||
@@ -1081,72 +1080,54 @@ impl DefaultExecutionService {
|
||||
})
|
||||
}
|
||||
|
||||
async fn bind_bound_model_inner(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> Result<BoundModelExecutionContext, GatewayError> {
|
||||
let client = self
|
||||
.client_for_key_id(&binding.client_key_id, binding.settings)
|
||||
.await?;
|
||||
if binding.extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution depth exceeded",
|
||||
));
|
||||
}
|
||||
let extension_scope = binding
|
||||
.extension_scope
|
||||
.extending(binding.initiating_plugin_instance_id.clone())
|
||||
.ok_or_else(|| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::Internal,
|
||||
"bound plugin execution scope is invalid",
|
||||
)
|
||||
})?;
|
||||
let account_scope = Arc::clone(client.policy.account_scope());
|
||||
Ok(BoundModelExecutionContext {
|
||||
authority: Arc::new(BoundModelExecutionAuthority {
|
||||
client,
|
||||
account_scope,
|
||||
cancellation: binding.cancellation,
|
||||
extension_scope,
|
||||
initiating_plugin_instance_id: binding.initiating_plugin_instance_id,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async fn start_bound_model_inner(
|
||||
&self,
|
||||
request: BoundModelExecutionRequest,
|
||||
) -> Result<StartedExecution, GatewayError> {
|
||||
let BoundModelExecutionRequest {
|
||||
context,
|
||||
client,
|
||||
initiating_plugin_instance_id,
|
||||
cancellation,
|
||||
extension_scope,
|
||||
public_model,
|
||||
operation,
|
||||
mut metadata,
|
||||
provider,
|
||||
account,
|
||||
} = request;
|
||||
let authority = &context.authority;
|
||||
if authority.cancellation.is_cancelled() {
|
||||
if cancellation.is_cancelled() {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::Cancelled,
|
||||
"bound plugin invocation was cancelled",
|
||||
));
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
let deadline_at = Deadline::from_timeout(now, authority.client.execution_timeout())
|
||||
if extension_scope.len() >= ExtensionCallScope::MAXIMUM_DEPTH {
|
||||
return Err(GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution depth exceeded",
|
||||
));
|
||||
}
|
||||
let extension_scope = extension_scope
|
||||
.extending(initiating_plugin_instance_id.clone())
|
||||
.ok_or_else(|| {
|
||||
GatewayError::new(
|
||||
GatewayErrorKind::PolicyDenied,
|
||||
"plugin model execution scope is recursive",
|
||||
)
|
||||
})?;
|
||||
let now = SystemTime::now();
|
||||
let deadline_at =
|
||||
Deadline::from_timeout(now, client.execution_timeout()).ok_or_else(|| {
|
||||
GatewayError::new(GatewayErrorKind::Internal, "system clock is invalid")
|
||||
})?;
|
||||
let (account_scope, required_provider) = restrict_model_execution_scope(
|
||||
authority.account_scope.as_ref(),
|
||||
client.policy.account_scope().as_ref(),
|
||||
provider,
|
||||
account.as_ref(),
|
||||
None,
|
||||
)?;
|
||||
// 连接承载多个独立执行;递归预算和副作用只在本次执行的子调用图内共享
|
||||
// 父连接的取消仍通过 authority 传播,不能因新建执行丢失扩展调用链
|
||||
// 父调用的取消继续向子执行传播,不能因新建执行丢失扩展调用链
|
||||
let graph = Arc::new(NestedExecutionGraph::new(Arc::new(
|
||||
ExecutionEffects::default(),
|
||||
)));
|
||||
@@ -1154,12 +1135,12 @@ impl DefaultExecutionService {
|
||||
let execution_effects_baseline = graph.effects.epoch();
|
||||
metadata.endpoint = "host.model".to_owned();
|
||||
metadata.transport = ClientTransport::InternalPlugin;
|
||||
metadata.inherit_request_origin(&authority.extension_scope);
|
||||
metadata.inherit_request_origin(&extension_scope);
|
||||
self.client_api_key_usage
|
||||
.record_used(authority.client.policy.key_id());
|
||||
.record_used(client.policy.key_id());
|
||||
self.start_authorized(
|
||||
PendingStartExecution {
|
||||
client: authority.client.clone(),
|
||||
client,
|
||||
target: ExecutionTarget::Model(public_model),
|
||||
operation,
|
||||
metadata,
|
||||
@@ -1168,13 +1149,13 @@ impl DefaultExecutionService {
|
||||
account_scope,
|
||||
response_control: None,
|
||||
deadline_at,
|
||||
cancellation: authority.cancellation.child_token(),
|
||||
extension_scope: authority.extension_scope.clone(),
|
||||
cancellation: cancellation.child_token(),
|
||||
extension_scope,
|
||||
required_provider,
|
||||
required_account: account,
|
||||
nested: None,
|
||||
bound: Some(BoundExecutionFacts {
|
||||
initiating_plugin_instance_id: authority.initiating_plugin_instance_id.clone(),
|
||||
initiating_plugin_instance_id,
|
||||
}),
|
||||
graph: Some(graph),
|
||||
execution_effects_baseline,
|
||||
@@ -1570,14 +1551,7 @@ impl ExecutionService for DefaultExecutionService {
|
||||
.query_client_model_catalog(scope, protocol, client_version)
|
||||
.await?
|
||||
else {
|
||||
for profile in client.snapshot.public_model_profiles_for_provider(kind) {
|
||||
if !client.snapshot.catalog_model_allowed_for_scope(
|
||||
kind,
|
||||
profile.model(),
|
||||
scope,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for profile in client.snapshot.profiles_for_provider_scope(kind, scope) {
|
||||
if seen.insert(profile.model().clone()) {
|
||||
result.push(PublicModelDescriptor::Adapted(profile));
|
||||
}
|
||||
@@ -1629,6 +1603,12 @@ impl ExecutionService for DefaultExecutionService {
|
||||
result.push(source.content.for_public_model(model));
|
||||
}
|
||||
}
|
||||
// 原生完整对象优先;只补入当前授权账号实际提供的独立适配画像
|
||||
for profile in client.snapshot.contributed_profiles_for_scope(kind, scope) {
|
||||
if seen.insert(profile.model().clone()) {
|
||||
result.push(PublicModelDescriptor::Adapted(profile));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
@@ -1724,23 +1704,57 @@ impl ExecutionService for DefaultExecutionService {
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientIdentityPort for DefaultExecutionService {
|
||||
fn resolve_client(
|
||||
&self,
|
||||
client_key_id: ClientApiKeyId,
|
||||
settings: Option<RequestSettings>,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
|
||||
Box::pin(async move { self.client_for_key_id(&client_key_id, settings).await })
|
||||
}
|
||||
|
||||
fn verify_client(
|
||||
&self,
|
||||
request: ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'_, Result<AuthenticatedClient, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let cancellation = request
|
||||
.invocation()
|
||||
.map(|origin| origin.cancellation.clone());
|
||||
let verification = self.verify_request(request).fuse();
|
||||
pin_mut!(verification);
|
||||
match cancellation {
|
||||
Some(cancellation) => {
|
||||
let cancelled = cancellation.cancelled().fuse();
|
||||
pin_mut!(cancelled);
|
||||
select_biased! {
|
||||
() = cancelled => Err(GatewayError::new(GatewayErrorKind::Cancelled, "client verification was cancelled")),
|
||||
result = verification => result.map_err(authentication_gateway_error),
|
||||
}
|
||||
}
|
||||
None => verification.await.map_err(authentication_gateway_error),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl NestedModelExecutionPort for DefaultExecutionService {
|
||||
fn models(
|
||||
&self,
|
||||
context: BoundModelExecutionContext,
|
||||
client: AuthenticatedClient,
|
||||
cancellation: CancellationToken,
|
||||
protocol: String,
|
||||
client_version: String,
|
||||
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>> {
|
||||
Box::pin(async move {
|
||||
let authority = &context.authority;
|
||||
let cancellation = authority.cancellation.cancelled().fuse();
|
||||
let cancelled = cancellation.cancelled().fuse();
|
||||
let timeout = Delay::new(DIAGNOSTIC_TIMEOUT).fuse();
|
||||
let catalog = self
|
||||
.client_model_catalog(&authority.client, &protocol, &client_version)
|
||||
.client_model_catalog(&client, &protocol, &client_version)
|
||||
.fuse();
|
||||
pin_mut!(cancellation, timeout, catalog);
|
||||
pin_mut!(cancelled, timeout, catalog);
|
||||
let models = select_biased! {
|
||||
() = cancellation => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
|
||||
() = cancelled => return Err(GatewayError::new(GatewayErrorKind::Cancelled, "model catalog call was cancelled")),
|
||||
() = timeout => return Err(GatewayError::new(GatewayErrorKind::Timeout, "model catalog deadline elapsed")),
|
||||
result = catalog => result.map_err(|_| GatewayError::new(GatewayErrorKind::Internal, "model catalog is unavailable"))?,
|
||||
};
|
||||
@@ -1754,13 +1768,6 @@ impl NestedModelExecutionPort for DefaultExecutionService {
|
||||
})
|
||||
}
|
||||
|
||||
fn bind(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>> {
|
||||
Box::pin(async move { self.bind_bound_model_inner(binding).await })
|
||||
}
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
request: NestedModelExecutionRequest,
|
||||
|
||||
@@ -62,6 +62,17 @@ impl ExtensionCallScope {
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主签发的父调用来源;身份裁决、执行链和实际资源仍由各自 owner 持有
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InvocationOrigin {
|
||||
pub request_id: String,
|
||||
pub call_id: String,
|
||||
pub cancellation: crate::lifecycle::CancellationToken,
|
||||
pub extensions: ExtensionCallScope,
|
||||
pub plan: Option<super::middleware::FrozenMiddlewarePlan>,
|
||||
pub effects: Option<Arc<super::nested::ExecutionEffects>>,
|
||||
}
|
||||
|
||||
/// 同一代次的具体执行计划;不拥有发布状态,运行资源由对应发布集合保活
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ExecutionExtensionPlans {
|
||||
|
||||
@@ -104,7 +104,6 @@ impl fmt::Debug for MiddlewareHeader {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct MiddlewareRequest {
|
||||
settings: Option<RequestSettings>,
|
||||
provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
protocol: String,
|
||||
headers: Vec<MiddlewareHeader>,
|
||||
body: Bytes,
|
||||
@@ -126,7 +125,6 @@ impl MiddlewareRequest {
|
||||
let protocol = protocol.into();
|
||||
Self {
|
||||
settings: None,
|
||||
provider_parameters: None,
|
||||
original_protocol: protocol.clone(),
|
||||
original_body: body.clone(),
|
||||
capabilities: None,
|
||||
@@ -147,27 +145,6 @@ impl MiddlewareRequest {
|
||||
self.settings.as_ref()
|
||||
}
|
||||
|
||||
/// Provider 提供的本次构造参数;Core 不解释其中的业务字段
|
||||
#[must_use]
|
||||
pub fn provider_parameters(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
|
||||
self.provider_parameters.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_provider_parameters(
|
||||
mut self,
|
||||
parameters: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Self {
|
||||
self.provider_parameters = Some(parameters);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn take_provider_parameters(
|
||||
&mut self,
|
||||
) -> Option<serde_json::Map<String, serde_json::Value>> {
|
||||
self.provider_parameters.take()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn protocol(&self) -> &str {
|
||||
&self.protocol
|
||||
|
||||
@@ -167,7 +167,7 @@ impl AccountAttemptContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 附着普通请求认证时冻结的账号范围
|
||||
/// 附着本次尝试的冻结账号范围,包含路由候选的能力限制
|
||||
#[must_use]
|
||||
pub fn with_account_scope(
|
||||
mut self,
|
||||
@@ -755,7 +755,6 @@ impl AttemptContext {
|
||||
provider: ProviderKind,
|
||||
model: Option<String>,
|
||||
account_id: ProviderAccountId,
|
||||
parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
terminal: provider::ProviderMiddlewareTerminal,
|
||||
) -> Result<provider::ProviderStream, ProviderError> {
|
||||
// 入口已校验原请求,此处复查中间件最终提交的 operation,每次重试均生效
|
||||
@@ -763,7 +762,7 @@ impl AttemptContext {
|
||||
.account_scope()
|
||||
.is_some_and(|scope| !scope.allows_image_generation())
|
||||
{
|
||||
Box::new(move |operation, headers, parameters| {
|
||||
Box::new(move |operation, headers| {
|
||||
Box::pin(async move {
|
||||
image_generation::check_image_generation(false, &operation).map_err(|_| {
|
||||
ProviderError::new(
|
||||
@@ -772,7 +771,7 @@ impl AttemptContext {
|
||||
)
|
||||
.with_retry_prohibited()
|
||||
})?;
|
||||
terminal(operation, headers, parameters).await
|
||||
terminal(operation, headers).await
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -790,7 +789,6 @@ impl AttemptContext {
|
||||
context,
|
||||
operation,
|
||||
self.request.client_transport,
|
||||
parameters,
|
||||
terminal,
|
||||
)
|
||||
.await
|
||||
@@ -897,7 +895,7 @@ impl AttemptContext {
|
||||
self.account.is_diagnostic_required_account()
|
||||
}
|
||||
|
||||
/// 普通请求认证时冻结的账号范围;管理端诊断为 `None`
|
||||
/// 当前路由候选的冻结账号范围;管理端诊断为 `None`
|
||||
#[must_use]
|
||||
pub const fn account_scope(&self) -> Option<&Arc<crate::account::scope::FrozenAccountScope>> {
|
||||
self.account.account_scope()
|
||||
@@ -1072,7 +1070,6 @@ pub struct ModelRequestFinalization {
|
||||
pub http_version: Option<String>,
|
||||
pub websocket_pool: Option<String>,
|
||||
pub service_tier: Option<String>,
|
||||
pub upstream_response_model: Option<String>,
|
||||
/// Provider 已筛选的专有观测 JSON;Core 不解释字段
|
||||
pub provider_metadata_json: Option<String>,
|
||||
/// 请求全程的有界诊断快照,跨 Provider 与重试保留
|
||||
|
||||
@@ -13,13 +13,13 @@ use futures::future::BoxFuture;
|
||||
|
||||
use crate::{
|
||||
account::ProviderAccountId, error::GatewayError, identity::ProviderKind,
|
||||
lifecycle::CancellationToken, operation::Operation, policy::ClientApiKeyId,
|
||||
provider_ports::ProviderSessionAffinityKey, routing::PublicModelId,
|
||||
lifecycle::CancellationToken, operation::Operation, provider_ports::ProviderSessionAffinityKey,
|
||||
routing::PublicModelId,
|
||||
};
|
||||
|
||||
use super::{
|
||||
ModelRequestId,
|
||||
execution::{BoundModelExecutionContext, ExecutionRequestMetadata, StartedExecution},
|
||||
execution::{AuthenticatedClient, ExecutionRequestMetadata, StartedExecution},
|
||||
};
|
||||
|
||||
/// 同一请求图内不可逆外部副作用的单调水位
|
||||
@@ -59,37 +59,14 @@ pub struct NestedModelExecutionRequest {
|
||||
pub parent_account: Option<ProviderAccountId>,
|
||||
}
|
||||
|
||||
/// 插件为当前调用或连接显式绑定的模型执行身份
|
||||
///
|
||||
/// Key ID 由插件选择,发起实例由宿主确定
|
||||
/// Core 在绑定时冻结当前
|
||||
/// Key 策略;每个实际模型请求仍独立经过准入、预算、账本与计费
|
||||
pub struct BoundModelExecutionBinding {
|
||||
pub settings: Option<crate::routing::request_settings::RequestSettings>,
|
||||
pub client_key_id: ClientApiKeyId,
|
||||
pub initiating_plugin_instance_id: String,
|
||||
/// 身份随父调用或连接回收;每个模型请求使用独立的执行期限
|
||||
pub cancellation: CancellationToken,
|
||||
/// 宿主保留的扩展调用链,异步观察不能通过新建模型请求清空防递归事实
|
||||
pub extension_scope: super::extensions::ExtensionCallScope,
|
||||
}
|
||||
|
||||
impl fmt::Debug for BoundModelExecutionBinding {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionBinding")
|
||||
.field("client_key_id", &self.client_key_id)
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.initiating_plugin_instance_id,
|
||||
)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// 显式身份作用域内的一次模型请求
|
||||
///
|
||||
/// 身份与权限可先用于查询;执行链只在真正启动模型请求时建立
|
||||
pub struct BoundModelExecutionRequest {
|
||||
pub context: BoundModelExecutionContext,
|
||||
pub client: AuthenticatedClient,
|
||||
pub initiating_plugin_instance_id: String,
|
||||
pub cancellation: CancellationToken,
|
||||
pub extension_scope: super::extensions::ExtensionCallScope,
|
||||
pub public_model: PublicModelId,
|
||||
pub operation: Operation,
|
||||
pub metadata: ExecutionRequestMetadata,
|
||||
@@ -101,7 +78,11 @@ impl fmt::Debug for BoundModelExecutionRequest {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("BoundModelExecutionRequest")
|
||||
.field("context", &self.context)
|
||||
.field("client", &self.client)
|
||||
.field(
|
||||
"initiating_plugin_instance_id",
|
||||
&self.initiating_plugin_instance_id,
|
||||
)
|
||||
.field("public_model", &self.public_model)
|
||||
.field("operation", &self.operation.kind())
|
||||
.field("provider", &self.provider)
|
||||
@@ -132,17 +113,12 @@ impl fmt::Debug for NestedModelExecutionRequest {
|
||||
pub trait NestedModelExecutionPort: Send + Sync {
|
||||
fn models(
|
||||
&self,
|
||||
context: BoundModelExecutionContext,
|
||||
client: AuthenticatedClient,
|
||||
cancellation: CancellationToken,
|
||||
protocol: String,
|
||||
client_version: String,
|
||||
) -> BoxFuture<'_, Result<Vec<PublicModelId>, GatewayError>>;
|
||||
|
||||
/// 为插件调用冻结显式执行身份;取消父调用会取消其所有模型执行
|
||||
fn bind(
|
||||
&self,
|
||||
binding: BoundModelExecutionBinding,
|
||||
) -> BoxFuture<'_, Result<BoundModelExecutionContext, GatewayError>>;
|
||||
|
||||
fn start(
|
||||
&self,
|
||||
request: NestedModelExecutionRequest,
|
||||
|
||||
@@ -249,7 +249,6 @@ pub struct RequestObservation {
|
||||
provider: Option<ProviderKind>,
|
||||
account_id: Option<ProviderAccountId>,
|
||||
upstream_model: Option<crate::routing::UpstreamModelId>,
|
||||
response_model: Option<String>,
|
||||
service_tier: Option<String>,
|
||||
outcome: RequestObservationOutcome,
|
||||
send_state: UpstreamSendState,
|
||||
@@ -287,7 +286,6 @@ impl RequestObservation {
|
||||
provider: None,
|
||||
account_id: None,
|
||||
upstream_model: None,
|
||||
response_model: None,
|
||||
service_tier: None,
|
||||
outcome,
|
||||
send_state,
|
||||
@@ -341,11 +339,6 @@ impl RequestObservation {
|
||||
self.upstream_model.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn response_model(&self) -> Option<&str> {
|
||||
self.response_model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn service_tier(&self) -> Option<&str> {
|
||||
self.service_tier.as_deref()
|
||||
@@ -626,7 +619,6 @@ impl RequestObservationDispatch {
|
||||
outcome: RequestObservationOutcome::Rejected,
|
||||
account_id: None,
|
||||
upstream_model: None,
|
||||
response_model: None,
|
||||
service_tier: None,
|
||||
send_state: UpstreamSendState::NotSent,
|
||||
attempt_count: 0,
|
||||
@@ -699,7 +691,6 @@ impl RequestObservationDispatch {
|
||||
provider,
|
||||
account_id: attempt.map(|attempt| attempt.provider_account_id().clone()),
|
||||
upstream_model: attempt.and_then(|attempt| attempt.upstream_model().cloned()),
|
||||
response_model: finalization.upstream_response_model.clone(),
|
||||
service_tier: finalization.service_tier.clone(),
|
||||
outcome,
|
||||
send_state: finalization.send_state,
|
||||
|
||||
@@ -471,7 +471,6 @@ pub type ProviderMiddlewareTerminal = Box<
|
||||
dyn FnOnce(
|
||||
Operation,
|
||||
Vec<MiddlewareHeader>,
|
||||
Option<serde_json::Map<String, serde_json::Value>>,
|
||||
) -> BoxFuture<'static, Result<ProviderStream, ProviderError>>
|
||||
+ Send
|
||||
+ 'static,
|
||||
@@ -490,19 +489,18 @@ fn provider_middleware_next(
|
||||
activate: oneshot::Receiver<()>,
|
||||
observation: Arc<Mutex<MiddlewareSendObservation>>,
|
||||
) -> MiddlewareNext {
|
||||
crate::middleware::compose(Vec::new(), move |mut request| {
|
||||
crate::middleware::compose(Vec::new(), move |request| {
|
||||
Box::pin(async move {
|
||||
let fallback_protocol = operation.protocol().to_owned();
|
||||
if request.has_capability_declaration() {
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
let parameters = request.take_provider_parameters();
|
||||
let (protocol, headers, body) = request.into_parts();
|
||||
validate_attempt_headers(&headers)?;
|
||||
let operation = operation
|
||||
.replace_middleware_wire(protocol, body)
|
||||
.map_err(|_| MiddlewareError::InvalidState)?;
|
||||
let stream = terminal(operation, headers, parameters)
|
||||
let stream = terminal(operation, headers)
|
||||
.await
|
||||
.map_err(MiddlewareError::Provider)?;
|
||||
let metadata = stream.metadata().clone();
|
||||
@@ -736,22 +734,18 @@ pub async fn execute_attempt_middleware(
|
||||
context: MiddlewareContext,
|
||||
operation: Operation,
|
||||
transport: ClientTransport,
|
||||
parameters: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
terminal: ProviderMiddlewareTerminal,
|
||||
) -> Result<ProviderStream, ProviderError> {
|
||||
let Some(plan) = plan else {
|
||||
return terminal(operation, Vec::new(), parameters).await;
|
||||
return terminal(operation, Vec::new()).await;
|
||||
};
|
||||
let mut request = MiddlewareRequest::new(
|
||||
let request = MiddlewareRequest::new(
|
||||
operation.protocol(),
|
||||
Vec::<MiddlewareHeader>::new(),
|
||||
operation
|
||||
.middleware_body()
|
||||
.map_err(|_| middleware_protocol_error(UpstreamSendState::NotSent))?,
|
||||
);
|
||||
if let Some(parameters) = parameters {
|
||||
request = request.with_provider_parameters(parameters);
|
||||
}
|
||||
let (prepared, metadata) = oneshot::channel();
|
||||
let (activate, activation) = oneshot::channel();
|
||||
let observation = Arc::new(Mutex::new(MiddlewareSendObservation {
|
||||
|
||||
@@ -624,7 +624,6 @@ pub struct ProviderResponseObservation {
|
||||
status_code: Option<u16>,
|
||||
request_id: Option<OpaqueUpstreamValue>,
|
||||
service_tier: Option<String>,
|
||||
upstream_response_model: Option<String>,
|
||||
timings: ProviderResponseTimings,
|
||||
// 流式事件分别持有快照,共享不可变头集合,避免逐事件复制头名和集合
|
||||
client_headers: Arc<[ProviderResponseHeader]>,
|
||||
@@ -641,7 +640,6 @@ impl ProviderResponseObservation {
|
||||
status_code: None,
|
||||
request_id: None,
|
||||
service_tier: None,
|
||||
upstream_response_model: None,
|
||||
timings: ProviderResponseTimings::default(),
|
||||
client_headers: Arc::default(),
|
||||
provider_metadata: None,
|
||||
@@ -697,21 +695,6 @@ impl ProviderResponseObservation {
|
||||
self
|
||||
}
|
||||
|
||||
/// 记录 Provider 从上游响应明确取得的模型,不改写发送模型或计费合同
|
||||
#[must_use]
|
||||
pub fn with_upstream_response_model_if_valid(mut self, model: &str) -> Self {
|
||||
let model = model.trim();
|
||||
if validate_text(model, 256, false, None).is_ok() {
|
||||
self.upstream_response_model = Some(model.to_owned());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn upstream_response_model(&self) -> Option<&str> {
|
||||
self.upstream_response_model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_timings(mut self, timings: ProviderResponseTimings) -> Self {
|
||||
self.timings = timings;
|
||||
|
||||
@@ -34,6 +34,7 @@ use engine::ExecutionStore;
|
||||
use engine::admission::{
|
||||
ClientAdmissionPort, ClientAdmissionRecoveryPort, restore_client_admission_startup,
|
||||
};
|
||||
use engine::authentication::ClientIdentityPort;
|
||||
use engine::continuation::NativeContinuationPort;
|
||||
use engine::execution::{
|
||||
ClientApiKeyUsageSink, ClientKeyVerifier, DefaultExecutionService, ExecutionService,
|
||||
@@ -127,6 +128,7 @@ pub struct CoreBundle {
|
||||
client_key_verifier: Arc<dyn ClientKeyVerifier>,
|
||||
snapshot_control: Arc<dyn SnapshotControl>,
|
||||
account_probe: Arc<dyn AccountProbe>,
|
||||
client_identity: Arc<dyn ClientIdentityPort>,
|
||||
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
|
||||
affinity_lookup: Arc<dyn AffinityLookupPort>,
|
||||
health_probes: Vec<Arc<dyn HealthProbe>>,
|
||||
@@ -181,6 +183,7 @@ impl CoreStartup {
|
||||
let execution: Arc<dyn ExecutionService> = service.clone();
|
||||
let client_key_verifier: Arc<dyn ClientKeyVerifier> = service.clone();
|
||||
let account_probe: Arc<dyn AccountProbe> = service.clone();
|
||||
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
|
||||
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
|
||||
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
|
||||
let snapshot_control: Arc<dyn SnapshotControl> = publisher;
|
||||
@@ -191,6 +194,7 @@ impl CoreStartup {
|
||||
client_key_verifier,
|
||||
snapshot_control,
|
||||
account_probe,
|
||||
client_identity,
|
||||
nested_model_execution,
|
||||
affinity_lookup,
|
||||
health_probes,
|
||||
@@ -266,6 +270,11 @@ impl CoreBundle {
|
||||
Arc::clone(&self.account_probe)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
|
||||
Arc::clone(&self.client_identity)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
|
||||
Arc::clone(&self.nested_model_execution)
|
||||
@@ -395,6 +404,7 @@ pub struct CoreCommandPlaneStartup {
|
||||
pub struct CoreCommandPlaneBundle {
|
||||
control: CoreControlPlaneBundle,
|
||||
account_probe: Arc<dyn AccountProbe>,
|
||||
client_identity: Arc<dyn ClientIdentityPort>,
|
||||
nested_model_execution: Arc<dyn NestedModelExecutionPort>,
|
||||
affinity_lookup: Arc<dyn AffinityLookupPort>,
|
||||
}
|
||||
@@ -425,11 +435,13 @@ impl CoreCommandPlaneStartup {
|
||||
.with_snapshot_refresh(Arc::clone(&control.publisher)),
|
||||
);
|
||||
let account_probe: Arc<dyn AccountProbe> = service.clone();
|
||||
let client_identity: Arc<dyn ClientIdentityPort> = service.clone();
|
||||
let nested_model_execution: Arc<dyn NestedModelExecutionPort> = service.clone();
|
||||
let affinity_lookup: Arc<dyn AffinityLookupPort> = service;
|
||||
Ok(CoreCommandPlaneBundle {
|
||||
control,
|
||||
account_probe,
|
||||
client_identity,
|
||||
nested_model_execution,
|
||||
affinity_lookup,
|
||||
})
|
||||
@@ -452,6 +464,11 @@ impl CoreCommandPlaneBundle {
|
||||
self.control.snapshot_control()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn client_identity_port(&self) -> Arc<dyn ClientIdentityPort> {
|
||||
Arc::clone(&self.client_identity)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn nested_model_execution_port(&self) -> Arc<dyn NestedModelExecutionPort> {
|
||||
Arc::clone(&self.nested_model_execution)
|
||||
|
||||
@@ -21,6 +21,17 @@ pub struct ContributedModelAlias {
|
||||
pub target: UpstreamModelId,
|
||||
}
|
||||
|
||||
/// 适配模型的冻结账号支持事实;没有选择账号或改写请求的能力
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ContributedModel {
|
||||
pub owner: String,
|
||||
pub id: PublicModelId,
|
||||
pub provider: ProviderKind,
|
||||
pub accounts: BTreeSet<ProviderAccountId>,
|
||||
pub capabilities: ModelCapabilities,
|
||||
pub presentation: ModelPresentation,
|
||||
}
|
||||
|
||||
/// Provider 客户端目录条目;缺少原生协议正文时使用已编译的通用画像
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProviderModelDescriptor {
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::{fmt, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::routing::{ConfigRevision, ContributedModelAlias};
|
||||
use crate::routing::{ConfigRevision, ContributedModel, ContributedModelAlias};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ExtensionSetId(String);
|
||||
@@ -37,6 +37,10 @@ pub trait ExtensionSetLease: Send + Sync {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn models(&self) -> &[ContributedModel] {
|
||||
&[]
|
||||
}
|
||||
|
||||
/// 局部故障已有请求级处理计划时,集合仍可提供服务
|
||||
fn can_serve(&self) -> bool {
|
||||
self.is_ready()
|
||||
@@ -71,6 +75,11 @@ impl ExtensionSetReference {
|
||||
pub fn model_aliases(&self) -> &[ContributedModelAlias] {
|
||||
self.lease.model_aliases()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn models(&self) -> &[ContributedModel] {
|
||||
self.lease.models()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ExtensionSetReference {
|
||||
|
||||
@@ -11,7 +11,7 @@ pub use crate::account::scope::{
|
||||
};
|
||||
pub use crate::identity::ProviderKind;
|
||||
pub use catalog::{
|
||||
ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
|
||||
ContributedModel, ContributedModelAlias, ProviderCatalogGeneration, ProviderCatalogPort,
|
||||
ProviderCatalogUnavailable, ProviderModelCapabilities, ProviderModelContent,
|
||||
ProviderModelDescriptor, PublicModelDescriptor,
|
||||
};
|
||||
|
||||
@@ -652,6 +652,30 @@ impl RuntimeSnapshot {
|
||||
.map_or(&[], |set| set.model_aliases())
|
||||
}
|
||||
|
||||
fn contributed_models(&self) -> &[super::ContributedModel] {
|
||||
self.extensions.as_ref().map_or(&[], |set| set.models())
|
||||
}
|
||||
|
||||
pub(crate) fn contributed_profiles_for_scope(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> Vec<super::PublicModelProfile> {
|
||||
self.contributed_models()
|
||||
.iter()
|
||||
.filter(|model| {
|
||||
&model.provider == provider
|
||||
&& model.accounts.iter().any(|account| {
|
||||
scope.account_provider(account) == Some(provider)
|
||||
&& scope.allows_model(account, &self.mapped_model(model.id.as_str()))
|
||||
})
|
||||
})
|
||||
.map(|model| {
|
||||
super::PublicModelProfile::new(model.id.clone(), model.presentation.clone())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model_alias(&self, model: &str) -> Option<&super::ContributedModelAlias> {
|
||||
self.model_aliases()
|
||||
.iter()
|
||||
@@ -844,6 +868,12 @@ impl RuntimeSnapshot {
|
||||
.filter(|alias| &alias.provider == provider)
|
||||
.map(|alias| alias.id.clone()),
|
||||
);
|
||||
models.extend(
|
||||
self.contributed_models()
|
||||
.iter()
|
||||
.filter(|model| &model.provider == provider)
|
||||
.map(|model| model.id.clone()),
|
||||
);
|
||||
models.into_iter().collect()
|
||||
}
|
||||
|
||||
@@ -922,10 +952,7 @@ impl RuntimeSnapshot {
|
||||
) -> Vec<super::PublicModelProfile> {
|
||||
let mut profiles = BTreeMap::new();
|
||||
for provider in scope.provider_kinds() {
|
||||
for profile in self.public_model_profiles_for_provider(provider) {
|
||||
if !self.catalog_model_allowed_for_scope(provider, profile.model(), scope) {
|
||||
continue;
|
||||
}
|
||||
for profile in self.profiles_for_provider_scope(provider, scope) {
|
||||
profiles
|
||||
.entry(profile.model().clone())
|
||||
.or_insert_with(|| profile.presentation().clone());
|
||||
@@ -937,6 +964,20 @@ impl RuntimeSnapshot {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn profiles_for_provider_scope(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> Vec<super::PublicModelProfile> {
|
||||
self.public_model_profiles_for_provider(provider)
|
||||
.into_iter()
|
||||
.filter(|profile| {
|
||||
self.native_catalog_model_allowed_for_scope(provider, profile.model(), scope)
|
||||
})
|
||||
.chain(self.contributed_profiles_for_scope(provider, scope))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 已知模型只属于明确声明它的 Provider;未知模型仍可由非完整目录透传
|
||||
#[must_use]
|
||||
pub fn contains_public_model_for_provider(
|
||||
@@ -961,7 +1002,15 @@ impl RuntimeSnapshot {
|
||||
self.provider_models
|
||||
.get(provider)
|
||||
.is_some_and(|models| models.contains_key(&upstream_model))
|
||||
|| self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.any(|model| &model.provider == provider && &model.id == public_model)
|
||||
|| (!self.exhaustive_provider_catalogs.contains(provider)
|
||||
&& !self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.any(|model| &model.id == public_model)
|
||||
&& !self.is_known_upstream_model(&upstream_model))
|
||||
}
|
||||
|
||||
@@ -969,6 +1018,10 @@ impl RuntimeSnapshot {
|
||||
self.provider_models
|
||||
.values()
|
||||
.any(|models| models.contains_key(upstream_model))
|
||||
|| self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.any(|model| model.id.as_str() == upstream_model.as_str())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -988,6 +1041,24 @@ impl RuntimeSnapshot {
|
||||
provider: &ProviderKind,
|
||||
public_model: &PublicModelId,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> bool {
|
||||
self.native_catalog_model_allowed_for_scope(provider, public_model, scope)
|
||||
|| self.contributed_models().iter().any(|model| {
|
||||
&model.provider == provider
|
||||
&& &model.id == public_model
|
||||
&& model.accounts.iter().any(|account| {
|
||||
scope.account_provider(account) == Some(provider)
|
||||
&& scope
|
||||
.allows_model(account, &self.mapped_model(public_model.as_str()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn native_catalog_model_allowed_for_scope(
|
||||
&self,
|
||||
provider: &ProviderKind,
|
||||
public_model: &PublicModelId,
|
||||
scope: &FrozenAccountScope,
|
||||
) -> bool {
|
||||
let upstream_model = self.mapped_model(public_model.as_str());
|
||||
match self
|
||||
@@ -999,7 +1070,17 @@ impl RuntimeSnapshot {
|
||||
scope.account_provider(account) == Some(provider)
|
||||
&& scope.allows_model(account, &upstream_model)
|
||||
}),
|
||||
None => scope.allows_provider_model(provider, &upstream_model),
|
||||
None => {
|
||||
// 只有插件声明的模型不能借非完整原生目录扩大到其他账号
|
||||
let native = self.provider_models.get(provider).is_some_and(|models| {
|
||||
models.keys().any(|model| model.as_str() == upstream_model)
|
||||
});
|
||||
let contributed = self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.any(|model| &model.provider == provider && &model.id == public_model);
|
||||
(native || !contributed) && scope.allows_provider_model(provider, &upstream_model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,7 +1202,11 @@ impl RuntimeSnapshot {
|
||||
}
|
||||
.map_err(|_| RoutingError::InvalidIdentifier)?;
|
||||
// 归属先于账号范围、能力与路由约束判定,原 Provider 被排除不能变成跨平台透传
|
||||
let known_model = self.is_known_upstream_model(&upstream_model);
|
||||
let known_model = self.is_known_upstream_model(&upstream_model)
|
||||
|| self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.any(|model| &model.id == public_model);
|
||||
|
||||
let providers = context.required_provider.as_ref().map_or_else(
|
||||
|| account_scope.provider_kinds().clone(),
|
||||
@@ -1155,17 +1240,64 @@ impl RuntimeSnapshot {
|
||||
.provider_models
|
||||
.get(provider)
|
||||
.and_then(|models| models.get(&upstream_model));
|
||||
let emulated_features = match capabilities {
|
||||
Some(capabilities) => {
|
||||
let Some(emulated) = capabilities.match_requirements(&requirements) else {
|
||||
let contributions: Vec<_> = self
|
||||
.contributed_models()
|
||||
.iter()
|
||||
.filter(|model| &model.provider == provider && &model.id == public_model)
|
||||
.collect();
|
||||
let mut candidate_scope = Arc::clone(&account_scope);
|
||||
let emulated_features = if contributions.is_empty() {
|
||||
match capabilities {
|
||||
Some(capabilities) => {
|
||||
let Some(emulated) = capabilities.match_requirements(&requirements) else {
|
||||
continue;
|
||||
};
|
||||
emulated
|
||||
}
|
||||
None if known_model || self.exhaustive_provider_catalogs.contains(provider) => {
|
||||
continue;
|
||||
};
|
||||
emulated
|
||||
}
|
||||
None => BTreeSet::new(),
|
||||
}
|
||||
None if known_model || self.exhaustive_provider_catalogs.contains(provider) => {
|
||||
} else {
|
||||
let native = capabilities.and_then(|value| value.match_requirements(&requirements));
|
||||
let capable_accounts: BTreeSet<_> = contributions
|
||||
.iter()
|
||||
.filter(|model| {
|
||||
model
|
||||
.capabilities
|
||||
.match_requirements(&requirements)
|
||||
.is_some()
|
||||
})
|
||||
.flat_map(|model| model.accounts.iter())
|
||||
.filter(|account| {
|
||||
account_scope.account_provider(account) == Some(provider)
|
||||
&& account_scope.allows_model(account, upstream_model.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if native.is_some() {
|
||||
// 这些账号实际由适配器执行,不能借用同名原生模型的能力
|
||||
let mut restricted = account_scope.as_ref().clone();
|
||||
for account in contributions.iter().flat_map(|model| &model.accounts) {
|
||||
if !capable_accounts.contains(account) {
|
||||
restricted = restricted.excluding_account(account.clone());
|
||||
}
|
||||
}
|
||||
candidate_scope = Arc::new(restricted);
|
||||
} else {
|
||||
// restricted_to 的空集合表示不收窄,因此必须先拒绝空候选
|
||||
if capable_accounts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
candidate_scope = Arc::new(
|
||||
account_scope.restricted_to(&Default::default(), &capable_accounts),
|
||||
);
|
||||
}
|
||||
if !candidate_scope.allows_provider_model(provider, upstream_model.as_str()) {
|
||||
continue;
|
||||
}
|
||||
None => BTreeSet::new(),
|
||||
native.unwrap_or_default()
|
||||
};
|
||||
candidates.push(ProviderCandidate {
|
||||
provider: provider.clone(),
|
||||
@@ -1173,10 +1305,15 @@ impl RuntimeSnapshot {
|
||||
.provider_model_presentations
|
||||
.get(provider)
|
||||
.and_then(|models| models.get(&upstream_model))
|
||||
.cloned(),
|
||||
.cloned()
|
||||
.or_else(|| {
|
||||
contributions
|
||||
.first()
|
||||
.map(|model| model.presentation.clone())
|
||||
}),
|
||||
upstream_model: Some(upstream_model.clone()),
|
||||
emulated_features,
|
||||
account_scope: Arc::clone(&account_scope),
|
||||
account_scope: candidate_scope,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -491,7 +491,6 @@ struct FinalState {
|
||||
http_version: Option<String>,
|
||||
websocket_pool: Option<String>,
|
||||
service_tier: Option<String>,
|
||||
upstream_response_model: Option<String>,
|
||||
upstream_request_id: Option<String>,
|
||||
upstream_status_code: Option<u16>,
|
||||
transport_decision_wait_ms: Option<u64>,
|
||||
@@ -682,7 +681,6 @@ impl ExecutionStore for FakeStore {
|
||||
http_version: finalization.http_version,
|
||||
websocket_pool: finalization.websocket_pool,
|
||||
service_tier: finalization.service_tier,
|
||||
upstream_response_model: finalization.upstream_response_model,
|
||||
upstream_request_id: finalization.upstream_request_id,
|
||||
upstream_status_code: finalization.upstream_status_code,
|
||||
transport_decision_wait_ms: finalization.timings.transport_decision_wait_ms,
|
||||
@@ -1764,7 +1762,6 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
.with_websocket_pool(WebSocketPoolKind::New)
|
||||
.with_status_code(200)
|
||||
.with_request_id(OpaqueUpstreamValue::new("upstream-observed"))
|
||||
.with_upstream_response_model_if_valid("gpt-returned")
|
||||
.try_with_service_tier("priority")
|
||||
.expect("service tier")
|
||||
.with_timings(ProviderResponseTimings {
|
||||
@@ -1804,10 +1801,6 @@ fn response_observation_is_persisted_but_never_delivered() {
|
||||
assert_eq!(finalization.http_version.as_deref(), Some("HTTP/2"));
|
||||
assert_eq!(finalization.websocket_pool.as_deref(), Some("new"));
|
||||
assert_eq!(finalization.service_tier.as_deref(), Some("priority"));
|
||||
assert_eq!(
|
||||
finalization.upstream_response_model.as_deref(),
|
||||
Some("gpt-returned")
|
||||
);
|
||||
assert_eq!(
|
||||
finalization.upstream_request_id.as_deref(),
|
||||
Some("upstream-observed")
|
||||
@@ -2083,7 +2076,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
.with_http_version(UpstreamHttpVersion::Http11)
|
||||
.with_status_code(503)
|
||||
.with_request_id(OpaqueUpstreamValue::new("discarded-request"))
|
||||
.with_upstream_response_model_if_valid("discarded-model")
|
||||
.with_timings(ProviderResponseTimings {
|
||||
transport_decision_wait_ms: Some(7),
|
||||
connect_ms: Some(11),
|
||||
@@ -2146,7 +2138,6 @@ fn discarded_attempt_observation_does_not_leak_into_retry_result() {
|
||||
|
||||
let state = store.state.lock().expect("store lock");
|
||||
let finalization = &state.finalizations[0];
|
||||
assert_eq!(finalization.upstream_response_model, None);
|
||||
let trace: Value =
|
||||
serde_json::from_str(finalization.diagnostic_trace_json.as_deref().unwrap()).unwrap();
|
||||
let events = trace["events"].as_array().unwrap();
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
//! 身份、冻结权限与查询不建立模型执行图;实际执行才检查调用链并占用执行资源
|
||||
|
||||
use super::*;
|
||||
use gateway_core::engine::{
|
||||
extensions::{ExtensionCallScope, InvocationOrigin},
|
||||
nested::{BoundModelExecutionRequest, NestedModelExecutionPort},
|
||||
};
|
||||
use gateway_core::lifecycle::CancellationToken;
|
||||
|
||||
#[test]
|
||||
fn identity_queries_keep_frozen_permissions_without_using_execution_resources() {
|
||||
block_on(async {
|
||||
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
snapshots.clone(),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
|
||||
fail: false,
|
||||
empty: false,
|
||||
}) as Arc<dyn Provider>])
|
||||
.unwrap(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let settings = service.request_settings().unwrap();
|
||||
let key = ClientApiKeyId::new("key_start_test").unwrap();
|
||||
let client =
|
||||
ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
snapshots.publish(start_snapshot_with_policy(
|
||||
2,
|
||||
false,
|
||||
RateLimits::unlimited(),
|
||||
FastMode::Disabled,
|
||||
));
|
||||
assert!(
|
||||
client
|
||||
.policy()
|
||||
.account_scope()
|
||||
.allows(&ProviderAccountId::new("acct_start").unwrap(),)
|
||||
);
|
||||
assert!(
|
||||
!client
|
||||
.policy()
|
||||
.account_scope()
|
||||
.allows(&ProviderAccountId::new("acct_missing").unwrap(),)
|
||||
);
|
||||
let models = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client.clone(),
|
||||
CancellationToken::default(),
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
|
||||
let frozen = ClientIdentityPort::resolve_client(&service, key.clone(), Some(settings))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(frozen.snapshot().revision().get(), 1);
|
||||
let current = ClientIdentityPort::resolve_client(&service, key, None).await;
|
||||
assert!(matches!(current, Err(error) if error.kind() == GatewayErrorKind::Unauthorized));
|
||||
|
||||
let cancellation = CancellationToken::default();
|
||||
cancellation.cancel();
|
||||
let cancelled = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client,
|
||||
cancellation,
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_scope_limits_apply_only_when_starting_a_model() {
|
||||
block_on(async {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(start_snapshot()),
|
||||
store.clone(),
|
||||
ProviderRegistry::new([Arc::new(NativeCatalogProvider {
|
||||
fail: false,
|
||||
empty: false,
|
||||
}) as Arc<dyn Provider>])
|
||||
.unwrap(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let repeated = ExtensionCallScope::default()
|
||||
.extending("entry".into())
|
||||
.unwrap();
|
||||
let exhausted = (0..ExtensionCallScope::MAXIMUM_DEPTH)
|
||||
.fold(ExtensionCallScope::default(), |scope, index| {
|
||||
scope.extending(format!("ancestor-{index}")).unwrap()
|
||||
});
|
||||
for extension_scope in [repeated, exhausted] {
|
||||
let client = ClientIdentityPort::resolve_client(
|
||||
&service,
|
||||
ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
client: client.clone(),
|
||||
initiating_plugin_instance_id: "entry".into(),
|
||||
cancellation: CancellationToken::default(),
|
||||
extension_scope,
|
||||
public_model: PublicModelId::new("gpt-start").unwrap(),
|
||||
operation: start_operation(),
|
||||
metadata: ExecutionRequestMetadata {
|
||||
protocol: "openai".into(),
|
||||
endpoint: "host.model".into(),
|
||||
transport: ClientTransport::InternalPlugin,
|
||||
stream: false,
|
||||
client_ip: None,
|
||||
user_agent: None,
|
||||
previous_response_id: None,
|
||||
},
|
||||
provider: None,
|
||||
account: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(error) if error.kind() == GatewayErrorKind::PolicyDenied));
|
||||
let models = NestedModelExecutionPort::models(
|
||||
&service,
|
||||
client,
|
||||
CancellationToken::default(),
|
||||
"codex".into(),
|
||||
"0.154.0".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(models, vec![PublicModelId::new("gpt-start").unwrap()]);
|
||||
}
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
struct ParentAwareAuthentication {
|
||||
calls: AtomicUsize,
|
||||
mapped: bool,
|
||||
}
|
||||
|
||||
impl FrontendAuthenticationPlan for ParentAwareAuthentication {
|
||||
fn authenticate<'a>(
|
||||
&'a self,
|
||||
request: &'a ClientAuthenticationRequest,
|
||||
) -> BoxFuture<'a, Result<FrontendAuthenticationDecision, FrontendAuthenticationError>> {
|
||||
Box::pin(async move {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let origin = request.invocation().expect("parent invocation");
|
||||
assert_eq!(origin.request_id, "http-request");
|
||||
assert_eq!(origin.call_id, "http-call");
|
||||
assert!(origin.extensions.contains("authentication-plugin"));
|
||||
assert!(origin.effects.is_some());
|
||||
assert!(!origin.cancellation.is_cancelled());
|
||||
assert_eq!(request.request_context().unwrap().uri, "/plugin/proxy");
|
||||
assert!(request.settings().is_some());
|
||||
Ok(FrontendAuthenticationDecision::Authenticated {
|
||||
principal: "external-principal".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn client_key_id(&self, principal: &str) -> Option<ClientApiKeyId> {
|
||||
assert_eq!(principal, "external-principal");
|
||||
self.mapped
|
||||
.then(|| ClientApiKeyId::new("key_start_test").unwrap())
|
||||
}
|
||||
|
||||
fn exclusive(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verification_keeps_parent_origin_and_requires_the_frontend_principal_mapping() {
|
||||
block_on(async {
|
||||
for mapped in [true, false] {
|
||||
let authentication = FrontendAuthenticationExtensionIndex::default();
|
||||
let plan = Arc::new(ParentAwareAuthentication {
|
||||
calls: AtomicUsize::new(0),
|
||||
mapped,
|
||||
});
|
||||
let snapshot = start_snapshot().with_extensions(Some(authentication_generation(
|
||||
&authentication,
|
||||
"identity-verification",
|
||||
plan.clone(),
|
||||
)));
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
let usage = Arc::new(RecordingClientApiKeyUsage::default());
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
store.clone(),
|
||||
ProviderRegistry::default(),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
usage.clone(),
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
)
|
||||
.with_frontend_authentication(authentication);
|
||||
let settings = service.request_settings().unwrap();
|
||||
let origin = InvocationOrigin {
|
||||
request_id: "http-request".into(),
|
||||
call_id: "http-call".into(),
|
||||
cancellation: CancellationToken::default(),
|
||||
extensions: plugin_origin_scope()
|
||||
.extending("authentication-plugin".into())
|
||||
.unwrap(),
|
||||
plan: None,
|
||||
effects: Some(Arc::new(
|
||||
gateway_core::engine::nested::ExecutionEffects::default(),
|
||||
)),
|
||||
};
|
||||
let mut cancelled_origin = origin.clone();
|
||||
cancelled_origin.cancellation = CancellationToken::default();
|
||||
cancelled_origin.cancellation.cancel();
|
||||
let cancelled = ClientIdentityPort::verify_client(
|
||||
&service,
|
||||
ClientAuthenticationRequest::new("External fixture")
|
||||
.unwrap()
|
||||
.with_invocation(cancelled_origin)
|
||||
.with_settings(settings.clone()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(cancelled, Err(error) if error.kind() == GatewayErrorKind::Cancelled));
|
||||
assert_eq!(plan.calls.load(Ordering::SeqCst), 0);
|
||||
let result = ClientIdentityPort::verify_client(
|
||||
&service,
|
||||
ClientAuthenticationRequest::new("External fixture")
|
||||
.unwrap()
|
||||
.with_invocation(origin)
|
||||
.with_settings(settings.clone()),
|
||||
)
|
||||
.await;
|
||||
if mapped {
|
||||
let client = result.unwrap();
|
||||
assert_eq!(client.policy().key_id().as_str(), "key_start_test");
|
||||
assert_eq!(client.request_settings(), Some(&settings));
|
||||
} else {
|
||||
assert!(
|
||||
matches!(result, Err(error) if error.kind() == GatewayErrorKind::Unauthorized)
|
||||
);
|
||||
}
|
||||
assert_eq!(plan.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(usage.recorded().is_empty());
|
||||
assert!(!store.touched.load(Ordering::SeqCst));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! 执行服务的准入、预算、请求记录与终结行为测试
|
||||
|
||||
mod identity;
|
||||
mod outbound;
|
||||
use gateway_core::routing::extensions::{ExtensionSetId, ExtensionSetLease, ExtensionSetReference};
|
||||
|
||||
@@ -12,6 +13,7 @@ use std::{
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
|
||||
use gateway_core::engine::authentication::ClientIdentityPort;
|
||||
use gateway_core::engine::budget::{ClientBudgetCharge, ClientBudgetError, ClientBudgetPort};
|
||||
use gateway_core::error::GatewayError;
|
||||
|
||||
@@ -1215,9 +1217,7 @@ fn plugin_origin_scope() -> gateway_core::engine::extensions::ExtensionCallScope
|
||||
|
||||
#[test]
|
||||
fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_origin_unknown() {
|
||||
use gateway_core::engine::nested::{
|
||||
BoundModelExecutionBinding, BoundModelExecutionRequest, NestedModelExecutionPort,
|
||||
};
|
||||
use gateway_core::engine::nested::{BoundModelExecutionRequest, NestedModelExecutionPort};
|
||||
block_on(async {
|
||||
for has_origin in [true, false] {
|
||||
let store = Arc::new(TrackingExecutionStore::default());
|
||||
@@ -1234,11 +1234,17 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
|
||||
Arc::new(crate::RecordingDiagnostics::default()),
|
||||
);
|
||||
let input = request(&service, ClientTransport::InternalPlugin);
|
||||
let context = NestedModelExecutionPort::bind(
|
||||
let client = ClientIdentityPort::resolve_client(
|
||||
&service,
|
||||
BoundModelExecutionBinding {
|
||||
settings: None,
|
||||
client_key_id: ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
ClientApiKeyId::new("key_start_test").unwrap(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
client,
|
||||
initiating_plugin_instance_id: "origin-fixture".into(),
|
||||
cancellation: Default::default(),
|
||||
extension_scope: if has_origin {
|
||||
@@ -1246,14 +1252,6 @@ fn explicit_key_model_execution_preserves_http_origin_and_leaves_background_orig
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut started = NestedModelExecutionPort::start_bound(
|
||||
&service,
|
||||
BoundModelExecutionRequest {
|
||||
context: context.clone(),
|
||||
public_model: input.public_model,
|
||||
operation: input.operation,
|
||||
metadata: input.metadata,
|
||||
@@ -2282,7 +2280,6 @@ impl Provider for ObservationProvider {
|
||||
gateway_core::event::ProviderResponseObservation::new(
|
||||
UpstreamTransport::new("http_sse").unwrap(),
|
||||
)
|
||||
.with_upstream_response_model_if_valid("gpt-reported")
|
||||
.with_service_tier_if_valid("priority"),
|
||||
);
|
||||
Ok(ProviderStream::new(
|
||||
@@ -2615,7 +2612,6 @@ fn final_observation_is_emitted_once_for_success_rejection_and_detached_cancella
|
||||
.map(UpstreamModelId::as_str),
|
||||
Some("gpt-start")
|
||||
);
|
||||
assert_eq!(observations[0].response_model(), Some("gpt-reported"));
|
||||
assert_eq!(observations[0].service_tier(), Some("priority"));
|
||||
assert_eq!(
|
||||
observations[0].outcome(),
|
||||
@@ -3306,6 +3302,7 @@ struct FailingProvider;
|
||||
|
||||
struct NativeCatalogProvider {
|
||||
fail: bool,
|
||||
empty: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -3336,6 +3333,9 @@ impl Provider for NativeCatalogProvider {
|
||||
if self.fail {
|
||||
return Err(gateway_core::routing::ProviderCatalogUnavailable);
|
||||
}
|
||||
if self.empty {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
Ok(Some(
|
||||
["gpt-new", "gpt-start"]
|
||||
.into_iter()
|
||||
@@ -3366,7 +3366,10 @@ impl Provider for NativeCatalogProvider {
|
||||
#[test]
|
||||
fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models() {
|
||||
#[derive(Debug)]
|
||||
struct Aliases(Vec<gateway_core::routing::ContributedModelAlias>);
|
||||
struct Aliases(
|
||||
Vec<gateway_core::routing::ContributedModelAlias>,
|
||||
Vec<gateway_core::routing::ContributedModel>,
|
||||
);
|
||||
impl gateway_core::routing::extensions::ExtensionSetLease for Aliases {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
@@ -3374,8 +3377,29 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
|
||||
fn model_aliases(&self) -> &[gateway_core::routing::ContributedModelAlias] {
|
||||
&self.0
|
||||
}
|
||||
fn models(&self) -> &[gateway_core::routing::ContributedModel] {
|
||||
&self.1
|
||||
}
|
||||
}
|
||||
for fail in [false, true] {
|
||||
for (fail, empty) in [(false, false), (false, true), (true, false)] {
|
||||
let contributed = [
|
||||
("gpt-start", "acct_start"),
|
||||
("adapted", "acct_start"),
|
||||
("private-model", "acct_other"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, account)| gateway_core::routing::ContributedModel {
|
||||
owner: "catalog-plugin".into(),
|
||||
id: PublicModelId::new(id).unwrap(),
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
accounts: [ProviderAccountId::new(account).unwrap()].into(),
|
||||
capabilities: ModelCapabilities::new([OperationKind::Generate].into(), None),
|
||||
presentation: gateway_core::routing::ModelPresentation::new(
|
||||
Some(format!("Plugin {id}")),
|
||||
None,
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
let snapshot = start_snapshot();
|
||||
let settings = snapshot
|
||||
.settings()
|
||||
@@ -3390,20 +3414,23 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
|
||||
.with_extensions(Some(ExtensionSetReference::new(
|
||||
gateway_core::routing::extensions::ExtensionSetId::new("catalog-aliases".into())
|
||||
.unwrap(),
|
||||
Arc::new(Aliases(vec![
|
||||
gateway_core::routing::ContributedModelAlias {
|
||||
Arc::new(Aliases(
|
||||
vec![gateway_core::routing::ContributedModelAlias {
|
||||
owner: "catalog-plugin".into(),
|
||||
id: PublicModelId::new("plugin-alias").unwrap(),
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
target: UpstreamModelId::new("gpt-start").unwrap(),
|
||||
},
|
||||
])),
|
||||
}],
|
||||
contributed,
|
||||
)),
|
||||
)));
|
||||
let service = DefaultExecutionService::new(
|
||||
RuntimeSnapshotHandle::new(snapshot),
|
||||
Arc::new(TrackingExecutionStore::default()),
|
||||
ProviderRegistry::new([Arc::new(NativeCatalogProvider { fail }) as Arc<dyn Provider>])
|
||||
.expect("registry"),
|
||||
ProviderRegistry::new([
|
||||
Arc::new(NativeCatalogProvider { fail, empty }) as Arc<dyn Provider>
|
||||
])
|
||||
.expect("registry"),
|
||||
Arc::new(UnusedAdmissions),
|
||||
Arc::new(UnusedContinuation),
|
||||
Arc::new(RecordingClientApiKeyUsage::default()),
|
||||
@@ -3419,8 +3446,35 @@ fn client_catalog_forwards_scope_maps_whole_objects_and_omits_unroutable_models(
|
||||
continue;
|
||||
}
|
||||
let models = result.expect("models");
|
||||
let adapted = models
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
gateway_core::routing::PublicModelDescriptor::Adapted(profile) => {
|
||||
Some(profile.model().as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
adapted,
|
||||
if empty {
|
||||
vec!["gpt-start", "adapted"]
|
||||
} else {
|
||||
vec!["adapted"]
|
||||
}
|
||||
);
|
||||
if empty {
|
||||
assert_eq!(models.len(), 2);
|
||||
continue;
|
||||
}
|
||||
let pairs = models
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
gateway_core::routing::PublicModelDescriptor::Native { .. }
|
||||
)
|
||||
})
|
||||
.map(|entry| {
|
||||
let gateway_core::routing::PublicModelDescriptor::Native { model, payload } = entry
|
||||
else {
|
||||
|
||||
@@ -103,8 +103,7 @@ impl Provider for PreparingProvider {
|
||||
provider,
|
||||
model,
|
||||
account,
|
||||
None,
|
||||
Box::new(move |_, _, _| {
|
||||
Box::new(move |_, _| {
|
||||
Box::pin(async move {
|
||||
let events = futures::stream::once(async move {
|
||||
Err::<ProviderEvent, _>(
|
||||
|
||||
@@ -88,7 +88,7 @@ fn plan(action: Action) -> FrozenMiddlewarePlan {
|
||||
}
|
||||
|
||||
fn terminal(polls: Arc<AtomicUsize>, released: Arc<AtomicUsize>) -> ProviderMiddlewareTerminal {
|
||||
Box::new(move |_, _, _| {
|
||||
Box::new(move |_, _| {
|
||||
Box::pin(async move {
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
ProviderKind::new("openai").unwrap(),
|
||||
@@ -127,7 +127,6 @@ fn inspecting_a_response_waits_for_the_outer_stream_and_keeps_send_facts() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
terminal(polls.clone(), released.clone()),
|
||||
)
|
||||
.await
|
||||
@@ -166,7 +165,6 @@ fn rejecting_metadata_drops_the_unactivated_handler_and_account_lease() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
terminal(polls.clone(), released.clone()),
|
||||
)
|
||||
.await
|
||||
@@ -196,8 +194,7 @@ fn middleware_rejection_before_next_does_not_wait_for_activation() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
Box::new(|_, _, _| panic!("rejected middleware cannot prepare upstream")),
|
||||
Box::new(|_, _| panic!("rejected middleware cannot prepare upstream")),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
|
||||
@@ -340,7 +340,7 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
|
||||
|
||||
let terminal: ProviderMiddlewareTerminal = {
|
||||
let terminal_calls = Arc::clone(&terminal_calls);
|
||||
Box::new(move |operation, headers, _| {
|
||||
Box::new(move |operation, headers| {
|
||||
terminal_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
assert!(headers.is_empty());
|
||||
@@ -392,7 +392,6 @@ fn attempt_middleware_consumes_owned_next_once_and_preserves_host_envelopes() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
terminal,
|
||||
)
|
||||
.await
|
||||
@@ -487,7 +486,7 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
|
||||
payload.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
|
||||
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
|
||||
Box::pin(async move {
|
||||
Ok(ProviderStream::new(
|
||||
empty_provider_stream().metadata().clone(),
|
||||
@@ -501,7 +500,6 @@ fn attempt_passthrough_preserves_parsed_wire_for_every_client_transport() {
|
||||
context(),
|
||||
operation(),
|
||||
transport,
|
||||
None,
|
||||
terminal,
|
||||
)
|
||||
.await
|
||||
@@ -569,7 +567,7 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
|
||||
Some(10),
|
||||
)
|
||||
.unwrap();
|
||||
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _, _| {
|
||||
let terminal: ProviderMiddlewareTerminal = Box::new(move |_, _| {
|
||||
Box::pin(async move {
|
||||
Ok(ProviderStream::new(
|
||||
empty_provider_stream().metadata().clone(),
|
||||
@@ -584,7 +582,6 @@ fn attempt_sse_encoding_keeps_opaque_metadata_from_creating_extra_frames() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpSse,
|
||||
None,
|
||||
terminal,
|
||||
)
|
||||
.await
|
||||
@@ -599,7 +596,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
|
||||
let allowed_calls = Arc::new(AtomicUsize::new(0));
|
||||
let allowed_terminal: ProviderMiddlewareTerminal = {
|
||||
let allowed_calls = Arc::clone(&allowed_calls);
|
||||
Box::new(move |_operation, headers, _| {
|
||||
Box::new(move |_operation, headers| {
|
||||
allowed_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
assert_eq!(headers.len(), 2);
|
||||
@@ -627,7 +624,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
allowed_terminal,
|
||||
)
|
||||
.await
|
||||
@@ -637,7 +633,7 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let terminal: ProviderMiddlewareTerminal = {
|
||||
let calls = calls.clone();
|
||||
Box::new(move |_, headers, _| {
|
||||
Box::new(move |_, headers| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Box::pin(async move {
|
||||
assert_eq!(headers[0].name(), "authorization");
|
||||
@@ -661,7 +657,6 @@ fn attempt_middleware_preserves_multi_value_and_authentication_headers() {
|
||||
context(),
|
||||
operation(),
|
||||
ClientTransport::HttpJson,
|
||||
None,
|
||||
terminal,
|
||||
)
|
||||
.await
|
||||
@@ -741,8 +736,7 @@ fn attempt_middleware_cannot_add_image_generation_to_a_denied_scope() {
|
||||
ProviderKind::new("openai").unwrap(),
|
||||
Some("gpt-test".into()),
|
||||
ProviderAccountId::new("acct_middleware").unwrap(),
|
||||
None,
|
||||
Box::new(move |operation, _, _| {
|
||||
Box::new(move |operation, _| {
|
||||
Box::pin(async move {
|
||||
assert!(operation.image_generation_requested());
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
@@ -99,6 +99,247 @@ struct CountingExtensionPreparation {
|
||||
|
||||
struct AliasLease(Vec<ContributedModelAlias>);
|
||||
|
||||
struct ModelLease(Vec<gateway_core::routing::ContributedModel>);
|
||||
|
||||
#[test]
|
||||
fn same_name_native_and_adapted_sources_keep_account_capabilities_separate() {
|
||||
use gateway_core::{
|
||||
account::{AccountModelAccess, AccountModelAccessMode, ProviderAccountId},
|
||||
operation::{Feature, GenerateRequest, Operation, ProtocolPayload},
|
||||
routing::{ContributedModel, SupportLevel},
|
||||
};
|
||||
struct Catalog;
|
||||
impl ProviderCatalogPort for Catalog {
|
||||
fn catalog_generations(&self) -> BTreeMap<ProviderKind, ProviderCatalogGeneration> {
|
||||
[(
|
||||
ProviderKind::new("alpha").unwrap(),
|
||||
ProviderCatalogGeneration::new(1),
|
||||
)]
|
||||
.into()
|
||||
}
|
||||
fn query_model_capabilities(
|
||||
&self,
|
||||
_: &ProviderKind,
|
||||
) -> BoxFuture<'_, Result<Vec<ProviderModelCapabilities>, ProviderCatalogUnavailable>>
|
||||
{
|
||||
Box::pin(async {
|
||||
Ok(vec![
|
||||
ProviderModelCapabilities::new(
|
||||
UpstreamModelId::new("shared-model").unwrap(),
|
||||
super::capabilities().with_feature(Feature::Tools, SupportLevel::Native),
|
||||
)
|
||||
.with_catalog_accounts([ProviderAccountId::new("acct_a").unwrap()].into())
|
||||
.with_presentation(ModelPresentation::new(Some("Native".into()), None)),
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
let id = PublicModelId::new("shared-model").unwrap();
|
||||
let b = ProviderAccountId::new("acct_b").unwrap();
|
||||
let mut contribution = ContributedModel {
|
||||
owner: "fixture".into(),
|
||||
id: id.clone(),
|
||||
provider: ProviderKind::new("alpha").unwrap(),
|
||||
accounts: [b.clone()].into(),
|
||||
capabilities: super::capabilities(),
|
||||
presentation: ModelPresentation::new(Some("Adapted".into()), None),
|
||||
};
|
||||
let facts = SnapshotFacts::new(
|
||||
revision(1),
|
||||
revision(1),
|
||||
super::settings(),
|
||||
vec![],
|
||||
vec![],
|
||||
["acct_a", "acct_b", "acct_c"]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
SnapshotProviderAccountFacts::new(ProviderAccountId::new(id).unwrap(), "alpha")
|
||||
.with_model_access(
|
||||
AccountModelAccess::new(
|
||||
AccountModelAccessMode::Allowlist,
|
||||
vec![
|
||||
if id == "acct_c" {
|
||||
"other"
|
||||
} else {
|
||||
"shared-model"
|
||||
}
|
||||
.into(),
|
||||
],
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
vec![],
|
||||
);
|
||||
let native = block_on(
|
||||
RuntimeSnapshotCompiler::new(
|
||||
Arc::new(TestSnapshotStore::new(Ok(facts))),
|
||||
Arc::new(Catalog),
|
||||
)
|
||||
.compile(),
|
||||
)
|
||||
.unwrap();
|
||||
let scope = native.all_account_scope();
|
||||
let b_only = Arc::new(scope.restricted_to(&Default::default(), &[b.clone()].into()));
|
||||
assert!(native.public_models_for_scope(&b_only).is_empty());
|
||||
let snapshot = native.with_extensions(Some(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("adapted".into()).unwrap(),
|
||||
Arc::new(ModelLease(vec![contribution.clone()])),
|
||||
)));
|
||||
assert_eq!(snapshot.public_models_for_scope(&b_only), vec![id.clone()]);
|
||||
assert_eq!(
|
||||
snapshot.public_model_profiles_for_scope(&b_only)[0].presentation(),
|
||||
&contribution.presentation
|
||||
);
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&id,
|
||||
&super::operation(),
|
||||
b_only.clone(),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(plan.candidates()[0].account_scope().allows(&b));
|
||||
let tools = Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
serde_json::json!({"model":"shared-model","tools":[{"type":"function","name":"test"}]})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
assert!(
|
||||
snapshot
|
||||
.plan(&id, &tools, b_only, &Default::default())
|
||||
.is_err()
|
||||
);
|
||||
let plan = snapshot
|
||||
.plan(&id, &tools, scope.clone(), &Default::default())
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.candidates()[0]
|
||||
.account_scope()
|
||||
.allows(&ProviderAccountId::new("acct_a").unwrap())
|
||||
);
|
||||
assert!(!plan.candidates()[0].account_scope().allows(&b));
|
||||
contribution.accounts = [ProviderAccountId::new("acct_c").unwrap()].into();
|
||||
let denied = snapshot.with_extensions(Some(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("denied".into()).unwrap(),
|
||||
Arc::new(ModelLease(vec![contribution])),
|
||||
)));
|
||||
let c_only = Arc::new(scope.restricted_to(
|
||||
&Default::default(),
|
||||
&[ProviderAccountId::new("acct_c").unwrap()].into(),
|
||||
));
|
||||
assert!(denied.public_models_for_scope(&c_only).is_empty());
|
||||
assert!(
|
||||
denied
|
||||
.plan(&id, &super::operation(), c_only, &Default::default())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
impl ExtensionSetLease for ModelLease {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn models(&self) -> &[gateway_core::routing::ContributedModel] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_model_without_presentation_cannot_borrow_an_unauthorized_plugin_profile() {
|
||||
let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("private-profile".into()).unwrap(),
|
||||
Arc::new(ModelLease(vec![gateway_core::routing::ContributedModel {
|
||||
owner: "fixture".into(),
|
||||
id: PublicModelId::new("gpt-5.5").unwrap(),
|
||||
provider: ProviderKind::new("openai").unwrap(),
|
||||
accounts: [gateway_core::account::ProviderAccountId::new("acct_private").unwrap()]
|
||||
.into(),
|
||||
capabilities: super::capabilities(),
|
||||
presentation: ModelPresentation::new(Some("Private model".into()), None),
|
||||
}])),
|
||||
)));
|
||||
assert!(
|
||||
snapshot
|
||||
.public_model_profiles_for_scope(&super::account_scope())
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapted_models_limit_visibility_and_routing_to_capable_authorized_accounts() {
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
operation::{Feature, GenerateRequest, Operation, ProtocolPayload},
|
||||
routing::{ContributedModel, SupportLevel},
|
||||
};
|
||||
let account = ProviderAccountId::new("acct_openai").unwrap();
|
||||
let provider = ProviderKind::new("openai").unwrap();
|
||||
let id = PublicModelId::new("adapted-model").unwrap();
|
||||
let contribution = ContributedModel {
|
||||
owner: "fixture".into(),
|
||||
id: id.clone(),
|
||||
provider,
|
||||
accounts: [account.clone()].into(),
|
||||
capabilities: super::capabilities(),
|
||||
presentation: ModelPresentation::new(Some("Adapted".into()), None),
|
||||
};
|
||||
let snapshot = super::snapshot().with_extensions(Some(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("models".into()).unwrap(),
|
||||
Arc::new(ModelLease(vec![contribution.clone()])),
|
||||
)));
|
||||
let scope = super::account_scope();
|
||||
assert!(snapshot.public_models_for_scope(&scope).contains(&id));
|
||||
assert!(
|
||||
snapshot
|
||||
.public_model_profiles_for_scope(&scope)
|
||||
.iter()
|
||||
.any(|p| p.model() == &id)
|
||||
);
|
||||
let plan = snapshot
|
||||
.plan(&id, &super::operation(), scope.clone(), &Default::default())
|
||||
.unwrap();
|
||||
assert_eq!(plan.candidates().len(), 1);
|
||||
assert!(plan.candidates()[0].account_scope().allows(&account));
|
||||
let other = Arc::new(scope.restricted_to(
|
||||
&Default::default(),
|
||||
&[ProviderAccountId::new("acct_xai").unwrap()].into(),
|
||||
));
|
||||
assert!(!snapshot.public_models_for_scope(&other).contains(&id));
|
||||
assert!(
|
||||
snapshot
|
||||
.plan(&id, &super::operation(), other, &Default::default())
|
||||
.is_err()
|
||||
);
|
||||
let tools = Operation::Generate(GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object("openai", serde_json::json!({"model":"adapted-model","input":[],"tools":[{"type":"function","name":"test","parameters":{}}]}).as_object().unwrap().clone()).unwrap(),
|
||||
));
|
||||
assert!(
|
||||
snapshot
|
||||
.plan(&id, &tools, scope.clone(), &Default::default())
|
||||
.is_err()
|
||||
);
|
||||
let mut supported = contribution;
|
||||
supported.capabilities = supported
|
||||
.capabilities
|
||||
.with_feature(Feature::Tools, SupportLevel::Native);
|
||||
let capable = snapshot.with_extensions(Some(ExtensionSetReference::new(
|
||||
ExtensionSetId::new("capable".into()).unwrap(),
|
||||
Arc::new(ModelLease(vec![supported])),
|
||||
)));
|
||||
assert!(
|
||||
capable
|
||||
.plan(&id, &tools, scope, &Default::default())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
impl ExtensionSetLease for AliasLease {
|
||||
fn is_ready(&self) -> bool {
|
||||
true
|
||||
|
||||
@@ -29,7 +29,7 @@ percent-encoding = "2.3.2"
|
||||
reqwest = { version = "=0.12.28", default-features = false, features = ["json", "stream", "rustls-tls-native-roots", "socks"] }
|
||||
rustls = { version = "=0.23.46", default-features = false, features = ["aws_lc_rs", "std", "tls12"] }
|
||||
rustls-native-certs = "=0.8.3"
|
||||
scraper = "0.27.0"
|
||||
scraper = "0.29.0"
|
||||
semver = "1.0.27"
|
||||
secrecy = "0.10.3"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
|
||||
@@ -24,7 +24,7 @@ gateway-plugin-sdk = { path = "../sdk", features = ["io"] }
|
||||
gateway-protocol = { path = "../../gateway-protocol" }
|
||||
hex = "0.4.3"
|
||||
image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "ico", "jpeg", "png", "webp"] }
|
||||
jsonschema = { version = "0.58.4", default-features = false }
|
||||
jsonschema = { version = "0.60.0", default-features = false }
|
||||
roxmltree = "0.21.1"
|
||||
semver = "1.0.27"
|
||||
secrecy = "0.10.3"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"manifest_schema_versions": [2],
|
||||
"protocol_versions": [5],
|
||||
"protocol_versions": [7],
|
||||
"capabilities": [
|
||||
{ "capability": "frontend_authentication", "versions": [2] },
|
||||
{ "capability": "scheduler", "versions": [3] },
|
||||
{ "capability": "model_router", "versions": [2] },
|
||||
{ "capability": "model_catalog", "versions": [1] },
|
||||
{ "capability": "model_catalog", "versions": [1, 2] },
|
||||
{ "capability": "retry_policy", "versions": [2] },
|
||||
{ "capability": "middleware", "versions": [6] },
|
||||
{ "capability": "upstream_adapter", "versions": [5] },
|
||||
|
||||
@@ -1,29 +1,49 @@
|
||||
//! 编译插件模型目录贡献并校验能力绑定
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
plugins::instances::{PluginCapabilityBinding, PluginInstance},
|
||||
};
|
||||
use gateway_core::routing::{ContributedModelAlias, ProviderKind, PublicModelId, UpstreamModelId};
|
||||
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::catalog::ModelCatalogRegistration};
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
operation::{Feature, OperationKind},
|
||||
routing::{
|
||||
ContributedModel, ContributedModelAlias, ModelCapabilities, ModelPresentation,
|
||||
ProviderKind, PublicModelId, SupportLevel, UpstreamModelId,
|
||||
},
|
||||
};
|
||||
use gateway_plugin_sdk::{
|
||||
Capability, Manifest, Stage,
|
||||
call::{
|
||||
catalog::{ModelCatalogRegistration, ModelCatalogRegistrationV2},
|
||||
middleware::RequestFeature,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::RpcSession;
|
||||
|
||||
pub(crate) struct PreparedCatalog {
|
||||
pub(crate) aliases: Vec<ContributedModelAlias>,
|
||||
pub(crate) models: Vec<ContributedModel>,
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare(
|
||||
manifest: &Manifest,
|
||||
instance: &PluginInstance,
|
||||
session: &RpcSession,
|
||||
) -> Result<Vec<ContributedModelAlias>, AdminError> {
|
||||
adapters: &[super::upstream_adapter::AdapterEntry],
|
||||
) -> Result<PreparedCatalog, AdminError> {
|
||||
if !manifest.contributes.contains_key(&Capability::ModelCatalog) {
|
||||
return Ok(Vec::new());
|
||||
return Ok(PreparedCatalog {
|
||||
aliases: Vec::new(),
|
||||
models: Vec::new(),
|
||||
});
|
||||
}
|
||||
validate_bindings(manifest, &instance.bindings)?;
|
||||
let reply = session
|
||||
.call(
|
||||
"model_catalog.register",
|
||||
session.context(Stage::Registration, Duration::from_secs(5)),
|
||||
session.context(Stage::Registration, session.budget(Stage::Registration)),
|
||||
serde_json::json!({}),
|
||||
Vec::new(),
|
||||
)
|
||||
@@ -32,13 +52,19 @@ pub(crate) async fn prepare(
|
||||
if reply.payload.len() > 64 * 1024 {
|
||||
return Err(AdminError::invalid("插件模型目录信封无效或过大"));
|
||||
}
|
||||
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
|
||||
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
|
||||
if registration.models.len() > 256 {
|
||||
return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名"));
|
||||
let (aliases, models) = if manifest.contributes[&Capability::ModelCatalog].version == 2 {
|
||||
let registration: ModelCatalogRegistrationV2 = serde_json::from_slice(&reply.payload)
|
||||
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
|
||||
(registration.aliases, registration.models)
|
||||
} else {
|
||||
let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload)
|
||||
.map_err(|_| AdminError::invalid("插件模型目录声明无效"))?;
|
||||
(registration.models, Vec::new())
|
||||
};
|
||||
if aliases.len().saturating_add(models.len()) > 256 {
|
||||
return Err(AdminError::invalid("单个插件最多贡献 256 个模型条目"));
|
||||
}
|
||||
registration
|
||||
.models
|
||||
let aliases = aliases
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let id = PublicModelId::new(model.id)
|
||||
@@ -52,7 +78,79 @@ pub(crate) async fn prepare(
|
||||
.map_err(|_| AdminError::invalid("模型别名目标无效"))?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<_, AdminError>>()?;
|
||||
let models = models
|
||||
.into_iter()
|
||||
.map(|model| {
|
||||
let mut targets = adapters
|
||||
.iter()
|
||||
.filter_map(|entry| entry.catalog_target(&model.adapter_id, &model.id));
|
||||
let target = targets
|
||||
.next()
|
||||
.filter(|_| targets.next().is_none())
|
||||
.ok_or_else(|| AdminError::invalid("适配模型必须关联同实例的静态账号适配器"))?;
|
||||
let mut capabilities =
|
||||
ModelCapabilities::new([OperationKind::Generate].into(), model.max_output_tokens);
|
||||
for feature in model.features {
|
||||
let feature = match feature {
|
||||
RequestFeature::Tools => Feature::Tools,
|
||||
RequestFeature::Vision => Feature::Vision,
|
||||
RequestFeature::Reasoning => Feature::Reasoning,
|
||||
RequestFeature::JsonSchema => Feature::JsonSchema,
|
||||
};
|
||||
capabilities = capabilities.with_feature(feature, SupportLevel::Native);
|
||||
}
|
||||
let p = model.presentation;
|
||||
Ok(ContributedModel {
|
||||
owner: instance.id.clone(),
|
||||
id: PublicModelId::new(model.id)
|
||||
.map_err(|_| AdminError::invalid("适配模型 ID 无效"))?,
|
||||
provider: ProviderKind::new(target.provider.as_str())
|
||||
.map_err(|_| AdminError::invalid("适配模型 Provider 无效"))?,
|
||||
accounts: target
|
||||
.account_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
ProviderAccountId::new(id.clone())
|
||||
.map_err(|_| AdminError::invalid("适配模型账号无效"))
|
||||
})
|
||||
.collect::<Result<_, _>>()?,
|
||||
capabilities,
|
||||
presentation: ModelPresentation::new(p.display_name, p.description)
|
||||
.with_reasoning(p.default_reasoning_effort, p.supported_reasoning_efforts)
|
||||
.with_context_window_tokens(p.context_window_tokens)
|
||||
.with_max_context_window_tokens(p.max_context_window_tokens)
|
||||
.with_image_input(p.image_input)
|
||||
.with_agent_tools(p.agent_tools, p.parallel_tool_calls)
|
||||
.with_search_tool(p.search_tool)
|
||||
.with_image_detail_original(p.image_detail_original)
|
||||
.with_verbosity(p.verbosity)
|
||||
.with_hidden(p.hidden),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, AdminError>>()?;
|
||||
Ok(PreparedCatalog { aliases, models })
|
||||
}
|
||||
|
||||
/// 新目录的同名画像必须一致,不由注册顺序拼接能力或改变别名目标
|
||||
pub(crate) fn validate_models(
|
||||
aliases: &[ContributedModelAlias],
|
||||
models: &[ContributedModel],
|
||||
) -> Result<(), AdminError> {
|
||||
for (index, model) in models.iter().enumerate() {
|
||||
if aliases.iter().any(|alias| alias.id == model.id)
|
||||
|| models[..index].iter().any(|other| {
|
||||
other.id == model.id
|
||||
&& (other.provider != model.provider
|
||||
|| other.presentation != model.presentation
|
||||
|| other.capabilities != model.capabilities
|
||||
|| !other.accounts.is_disjoint(&model.accounts))
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("适配模型目录声明冲突"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_bindings(
|
||||
|
||||
@@ -48,12 +48,13 @@ impl PluginCommandSession {
|
||||
let payload = serde_json::to_vec(&invocation)
|
||||
.map_err(|_| AdminError::invalid("插件命令参数无法编码"))?;
|
||||
let mut saved_accounts = 0;
|
||||
let deadline = tokio::time::Instant::now() + command.session.budget(Stage::CommandLine);
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancellation.cancelled() => Ok(Err("调用已取消")),
|
||||
result = tokio::time::timeout(
|
||||
self.limits.maximum_call_timeout,
|
||||
command.execute(payload, self.limits, &mut saved_accounts),
|
||||
result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
command.execute(payload, self.limits, deadline, &mut saved_accounts),
|
||||
) => result,
|
||||
};
|
||||
match result {
|
||||
@@ -76,11 +77,14 @@ impl PluginCommand {
|
||||
&self,
|
||||
payload: Vec<u8>,
|
||||
limits: RpcLimits,
|
||||
deadline: tokio::time::Instant,
|
||||
saved_accounts: &mut usize,
|
||||
) -> Result<PluginCommandOutput, &'static str> {
|
||||
let mut context = self
|
||||
.session
|
||||
.context(Stage::CommandLine, limits.maximum_call_timeout);
|
||||
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err("超过命令总期限");
|
||||
}
|
||||
let mut context = self.session.context(Stage::CommandLine, remaining);
|
||||
context.request_id = Some(uuid::Uuid::new_v4().to_string());
|
||||
let scope = self
|
||||
.callbacks
|
||||
@@ -135,6 +139,7 @@ fn rpc_failure(error: RpcError) -> &'static str {
|
||||
}
|
||||
RpcError::Capacity | RpcError::Overloaded => "插件调用容量不足",
|
||||
RpcError::Context(_) => "调用上下文无效",
|
||||
RpcError::Budget(_) => "调用预算冲突",
|
||||
RpcError::Remote(_) => "插件拒绝命令",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::PluginCommand;
|
||||
use crate::{RpcSession, callback::PluginCallbacks};
|
||||
use gateway_admin::model::{AdminError, plugins::instances::PluginInstance};
|
||||
use gateway_plugin_sdk::{Capability, Manifest, Stage, call::management::CommandRegistration};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
const MAXIMUM_REGISTRATION_BYTES: usize = 64 * 1024;
|
||||
|
||||
@@ -23,7 +23,7 @@ pub(crate) async fn prepare(
|
||||
let reply = session
|
||||
.call(
|
||||
"command_line.register",
|
||||
session.context(Stage::Registration, Duration::from_secs(5)),
|
||||
session.context(Stage::Registration, session.budget(Stage::Registration)),
|
||||
serde_json::json!({}),
|
||||
Vec::new(),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 编译插件客户端认证绑定,并将认证结果映射为宿主身份
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc, time::Duration};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_admin::model::{
|
||||
@@ -26,8 +26,6 @@ use gateway_plugin_sdk::{
|
||||
|
||||
use crate::{RpcSession, callback::PluginCallbacks};
|
||||
|
||||
const IDENTIFIER_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct FrontendAuthenticationEntry {
|
||||
identities: BTreeMap<String, ClientApiKeyId>,
|
||||
@@ -47,7 +45,7 @@ pub(crate) async fn prepare_entry(
|
||||
let reply = session
|
||||
.call(
|
||||
"frontend_auth.identifier",
|
||||
session.context(Stage::Registration, IDENTIFIER_TIMEOUT),
|
||||
session.context(Stage::Registration, session.budget(Stage::Registration)),
|
||||
serde_json::json!({}),
|
||||
Vec::new(),
|
||||
)
|
||||
@@ -167,19 +165,16 @@ fn valid_identifier(value: &str) -> bool {
|
||||
|
||||
pub(crate) struct PluginFrontendAuthenticationPlan {
|
||||
entry: FrontendAuthenticationEntry,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl PluginFrontendAuthenticationPlan {
|
||||
pub(crate) fn compile(
|
||||
mut entries: Vec<FrontendAuthenticationEntry>,
|
||||
maximum_call_timeout: Duration,
|
||||
) -> Result<Option<Arc<Self>>, AdminError> {
|
||||
match entries.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(Some(Arc::new(Self {
|
||||
entry: entries.pop().expect("one entry was checked"),
|
||||
timeout: maximum_call_timeout.min(Duration::from_secs(30)),
|
||||
}))),
|
||||
_ => Err(AdminError::invalid("同一发布集合只能启用一个入口认证插件")),
|
||||
}
|
||||
@@ -197,23 +192,32 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan {
|
||||
.invocation
|
||||
.as_ref()
|
||||
.ok_or(FrontendAuthenticationError)?;
|
||||
let mut context = session.context(Stage::Authentication, self.timeout);
|
||||
let mut context =
|
||||
session.context(Stage::Authentication, session.budget(Stage::Authentication));
|
||||
context.request = crate::adapter::request_context(request.request_context());
|
||||
context.request_id = request.invocation().map(|origin| origin.request_id.clone());
|
||||
let _scope = callbacks
|
||||
.prepare_frontend_authentication(&context, request.shared_request_context())
|
||||
.prepare_frontend_authentication(&context, request)
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let input = FrontendAuthenticationRequest {
|
||||
authorization: request.authorization().to_owned(),
|
||||
};
|
||||
let reply = session
|
||||
.call(
|
||||
"frontend_auth.authenticate",
|
||||
context,
|
||||
serde_json::json!({}),
|
||||
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let invocation = session.call(
|
||||
"frontend_auth.authenticate",
|
||||
context,
|
||||
serde_json::json!({}),
|
||||
serde_json::to_vec(&input).map_err(|_| FrontendAuthenticationError)?,
|
||||
);
|
||||
let reply = if let Some(origin) = request.invocation() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = origin.cancellation.cancelled() => return Err(FrontendAuthenticationError),
|
||||
reply = invocation => reply,
|
||||
}
|
||||
} else {
|
||||
invocation.await
|
||||
}
|
||||
.map_err(|_| FrontendAuthenticationError)?;
|
||||
let result: FrontendAuthenticationResult =
|
||||
serde_json::from_slice(&reply.payload).map_err(|_| FrontendAuthenticationError)?;
|
||||
match result {
|
||||
|
||||
@@ -165,9 +165,10 @@ impl ManagementEntry {
|
||||
{
|
||||
return Err(invalid_state());
|
||||
}
|
||||
let mut context = self
|
||||
.session
|
||||
.context(Stage::PublicManagement, limits.maximum_call_timeout);
|
||||
let mut context = self.session.context(
|
||||
Stage::PublicManagement,
|
||||
self.session.budget(Stage::PublicManagement),
|
||||
);
|
||||
context.request = crate::adapter::request_context(request.request_context.as_deref());
|
||||
context.request_id = Some(request.request_id);
|
||||
let _scope = self
|
||||
|
||||
@@ -98,7 +98,7 @@ impl ManagementEntry {
|
||||
.map_err(|_| AdminError::invalid("插件管理请求无法编码"))?;
|
||||
let mut context = self
|
||||
.session
|
||||
.context(Stage::Management, limits.maximum_call_timeout);
|
||||
.context(Stage::Management, self.session.budget(Stage::Management));
|
||||
context.request = crate::adapter::request_context(request.request_context.as_deref());
|
||||
context.request_id = Some(request.request_id);
|
||||
let _scope = self
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 将插件管理声明编译为页面、路由与资源入口
|
||||
|
||||
use std::{collections::BTreeMap, sync::Arc, time::Duration};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
@@ -34,7 +34,7 @@ pub(crate) async fn prepare(
|
||||
let reply = session
|
||||
.call(
|
||||
"management.register",
|
||||
session.context(Stage::Registration, Duration::from_secs(5)),
|
||||
session.context(Stage::Registration, session.budget(Stage::Registration)),
|
||||
serde_json::json!({}),
|
||||
Vec::new(),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ use gateway_core::engine::middleware::{
|
||||
MiddlewareRequest, MiddlewareResponse,
|
||||
};
|
||||
use gateway_core::middleware::{Middleware, Next};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
// 协议适配只提供选择条件与单层调用;顺序、上下文捕获和续体拼接共用这一个入口
|
||||
type Invoke<Context, Input, Output, Error> = fn(
|
||||
@@ -15,7 +15,6 @@ type Invoke<Context, Input, Output, Error> = fn(
|
||||
Context,
|
||||
Input,
|
||||
Next<Input, Output, Error>,
|
||||
Duration,
|
||||
) -> BoxFuture<'static, Result<Output, Error>>;
|
||||
|
||||
impl PluginMiddlewarePlan {
|
||||
@@ -42,8 +41,7 @@ impl PluginMiddlewarePlan {
|
||||
.map(|entry| -> Middleware<Input, Output, Error> {
|
||||
let entry = Arc::clone(entry);
|
||||
let context = context.clone();
|
||||
let timeout = self.middleware_timeout;
|
||||
Box::new(move |input, next| invoke(entry, context, input, next, timeout))
|
||||
Box::new(move |input, next| invoke(entry, context, input, next))
|
||||
})
|
||||
.collect();
|
||||
gateway_core::middleware::compose(layers, move |input| next.run(input))
|
||||
@@ -70,10 +68,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
|
||||
MiddlewareMount::Outbound,
|
||||
context,
|
||||
outbound_matches,
|
||||
|entry, context, request, next, timeout| {
|
||||
Box::pin(super::outbound::invoke(
|
||||
entry, context, request, next, timeout,
|
||||
))
|
||||
|entry, context, request, next| {
|
||||
Box::pin(super::outbound::invoke(entry, context, request, next))
|
||||
},
|
||||
next,
|
||||
)
|
||||
@@ -99,8 +95,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
|
||||
MiddlewareMount::Service,
|
||||
context,
|
||||
|entry, context| !context.extensions.contains(&entry.instance_id),
|
||||
|entry, context, input, next, timeout| {
|
||||
Box::pin(super::service::invoke(entry, context, input, next, timeout))
|
||||
|entry, context, input, next| {
|
||||
Box::pin(super::service::invoke(entry, context, input, next))
|
||||
},
|
||||
next,
|
||||
)
|
||||
@@ -123,10 +119,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
|
||||
MiddlewareMount::WebSocket,
|
||||
context,
|
||||
|_entry, _context| true,
|
||||
|entry, context, message, next, timeout| {
|
||||
Box::pin(super::websocket::invoke(
|
||||
entry, context, message, next, timeout,
|
||||
))
|
||||
|entry, context, message, next| {
|
||||
Box::pin(super::websocket::invoke(entry, context, message, next))
|
||||
},
|
||||
next,
|
||||
)
|
||||
@@ -147,8 +141,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
|
||||
MiddlewareMount::Http,
|
||||
context,
|
||||
|entry, context| !context.extensions.contains(&entry.instance_id),
|
||||
|entry, context, request, next, timeout| {
|
||||
Box::pin(super::http::invoke(entry, context, request, next, timeout))
|
||||
|entry, context, request, next| {
|
||||
Box::pin(super::http::invoke(entry, context, request, next))
|
||||
},
|
||||
next,
|
||||
)
|
||||
@@ -173,10 +167,8 @@ impl MiddlewarePlan for PluginMiddlewarePlan {
|
||||
context.model(),
|
||||
)
|
||||
},
|
||||
|entry, context, request, next, timeout| {
|
||||
Box::pin(super::request::invoke(
|
||||
entry, context, request, next, timeout,
|
||||
))
|
||||
|entry, context, request, next| {
|
||||
Box::pin(super::request::invoke(entry, context, request, next))
|
||||
},
|
||||
next,
|
||||
)
|
||||
|
||||
@@ -20,7 +20,6 @@ pub(super) async fn invoke(
|
||||
context: core::Context,
|
||||
request: core::Request,
|
||||
next: core::Next,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<core::Response, MiddlewareError> {
|
||||
let Some(ports) = &entry.invocation else {
|
||||
return if entry.failure_policy == PluginFailurePolicy::Delegate {
|
||||
@@ -35,7 +34,9 @@ pub(super) async fn invoke(
|
||||
.and_then(|settings| settings.runtime.as_ref())
|
||||
.map_or(serde_json::Value::Null, |settings| settings.inspect());
|
||||
let (invocation, request) = Invocation::new(request, next, entry.instance_id.clone())?;
|
||||
let mut call = ports.session.context(Stage::Http, timeout);
|
||||
let mut call = ports
|
||||
.session
|
||||
.context(Stage::Http, ports.session.budget(Stage::Http));
|
||||
call.request = crate::adapter::request_context(context.extensions.request());
|
||||
call.request_id = Some(context.request_id.clone());
|
||||
call.resource_stream = true;
|
||||
@@ -50,7 +51,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.request_id.clone(),
|
||||
call_id: context.call_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan,
|
||||
plan: Some(plan),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions.clone(),
|
||||
invocation.clone(),
|
||||
|
||||
@@ -18,7 +18,6 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -168,7 +167,6 @@ fn compile_entry(
|
||||
|
||||
pub(crate) struct PluginMiddlewarePlan {
|
||||
middleware: BTreeMap<MiddlewareMount, Box<[Arc<MiddlewareEntry>]>>,
|
||||
middleware_timeout: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginMiddlewarePlan {
|
||||
@@ -181,10 +179,7 @@ impl fmt::Debug for PluginMiddlewarePlan {
|
||||
}
|
||||
|
||||
impl PluginMiddlewarePlan {
|
||||
pub(crate) fn compile(
|
||||
mut entries: Vec<MiddlewareEntry>,
|
||||
maximum_call_timeout: Duration,
|
||||
) -> Option<Arc<Self>> {
|
||||
pub(crate) fn compile(mut entries: Vec<MiddlewareEntry>) -> Option<Arc<Self>> {
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -208,7 +203,6 @@ impl PluginMiddlewarePlan {
|
||||
.into_iter()
|
||||
.map(|(mount, entries)| (mount, entries.into_boxed_slice()))
|
||||
.collect(),
|
||||
middleware_timeout: maximum_call_timeout,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 在 Provider 发送前组合完整出站请求,并沿用模型数据面的调用资源
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::model::plugins::instances::PluginFailurePolicy;
|
||||
use gateway_core::engine::middleware::{MiddlewareContext, MiddlewareError, outbound as core};
|
||||
@@ -17,7 +17,6 @@ pub(super) async fn invoke(
|
||||
context: MiddlewareContext,
|
||||
request: core::Request,
|
||||
next: core::Next,
|
||||
timeout: Duration,
|
||||
) -> Result<core::Request, MiddlewareError> {
|
||||
let Some(ports) = &entry.invocation else {
|
||||
return if entry.failure_policy == PluginFailurePolicy::Delegate {
|
||||
@@ -54,7 +53,12 @@ pub(super) async fn invoke(
|
||||
.get(),
|
||||
},
|
||||
};
|
||||
let mut call = ports.session.context(Stage::Outbound, timeout);
|
||||
let mut call = ports.session.context(
|
||||
Stage::Outbound,
|
||||
context
|
||||
.deadline()
|
||||
.bounded(ports.session.budget(Stage::Outbound)),
|
||||
);
|
||||
call.request = crate::adapter::request_context(context.extension_scope().request());
|
||||
call.request_id = Some(input.metadata.request_id.clone());
|
||||
call.attempt_id = Some(format!(
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! 请求与尝试中间件的 RPC 调用、失败回退和响应正文生命周期
|
||||
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::future::BoxFuture;
|
||||
@@ -37,7 +34,6 @@ pub(super) async fn invoke(
|
||||
context: MiddlewareContext,
|
||||
request: MiddlewareRequest,
|
||||
next: MiddlewareNext,
|
||||
maximum_timeout: Duration,
|
||||
) -> Result<MiddlewareResponse, MiddlewareError> {
|
||||
let Some(invocation_ports) = &entry.invocation else {
|
||||
return if entry.failure_policy
|
||||
@@ -48,10 +44,7 @@ pub(super) async fn invoke(
|
||||
Err(MiddlewareError::Fault)
|
||||
};
|
||||
};
|
||||
if maximum_timeout.is_zero()
|
||||
|| context.deadline().is_elapsed()
|
||||
|| context.cancellation().is_cancelled()
|
||||
{
|
||||
if context.deadline().is_elapsed() || context.cancellation().is_cancelled() {
|
||||
return Err(MiddlewareError::Fault);
|
||||
}
|
||||
let invocation = MiddlewareInvocation::new(&context, request, next, entry.instance_id.clone());
|
||||
@@ -59,18 +52,21 @@ pub(super) async fn invoke(
|
||||
Ok(projection) => projection,
|
||||
Err(_) => return recover_rpc(&entry, &invocation, RpcError::Context(None)).await,
|
||||
};
|
||||
let stage = match entry.mount {
|
||||
MiddlewareMount::Http
|
||||
| MiddlewareMount::WebSocket
|
||||
| MiddlewareMount::Service
|
||||
| MiddlewareMount::Outbound => {
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
MiddlewareMount::Request => Stage::Request,
|
||||
MiddlewareMount::Attempt => Stage::Attempt,
|
||||
};
|
||||
let mut call_context = invocation_ports.session.context(
|
||||
match entry.mount {
|
||||
MiddlewareMount::Http
|
||||
| MiddlewareMount::WebSocket
|
||||
| MiddlewareMount::Service
|
||||
| MiddlewareMount::Outbound => {
|
||||
return Err(MiddlewareError::InvalidState);
|
||||
}
|
||||
MiddlewareMount::Request => Stage::Request,
|
||||
MiddlewareMount::Attempt => Stage::Attempt,
|
||||
},
|
||||
maximum_timeout,
|
||||
stage,
|
||||
context
|
||||
.deadline()
|
||||
.bounded(invocation_ports.session.budget(stage)),
|
||||
);
|
||||
call_context.resource_stream = true;
|
||||
call_context.request = crate::adapter::request_context(context.extension_scope().request());
|
||||
@@ -98,7 +94,6 @@ pub(super) async fn invoke(
|
||||
scope,
|
||||
)?;
|
||||
let head = MiddlewareRequestHead {
|
||||
provider_parameters: invocation.provider_parameters(),
|
||||
settings_sources: invocation.settings_sources(),
|
||||
settings: invocation
|
||||
.settings()
|
||||
|
||||
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
|
||||
context: core::Context,
|
||||
input: core::Value,
|
||||
next: core::Next,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<core::Value, core::Error> {
|
||||
let Some(ports) = &entry.invocation else {
|
||||
return if entry.failure_policy == PluginFailurePolicy::Delegate {
|
||||
@@ -26,7 +25,9 @@ pub(super) async fn invoke(
|
||||
};
|
||||
};
|
||||
let invocation = Invocation::new(next);
|
||||
let mut call = ports.session.context(Stage::Service, timeout);
|
||||
let mut call = ports
|
||||
.session
|
||||
.context(Stage::Service, ports.session.budget(Stage::Service));
|
||||
call.request = crate::adapter::request_context(context.extensions.request());
|
||||
call.request_id = Some(context.request_id.clone());
|
||||
let _binding = ports
|
||||
@@ -37,7 +38,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.request_id.clone(),
|
||||
call_id: context.call_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan: context.plan.clone(),
|
||||
plan: Some(context.plan.clone()),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions.clone(),
|
||||
invocation,
|
||||
|
||||
@@ -16,7 +16,6 @@ pub(super) async fn invoke(
|
||||
context: core::Context,
|
||||
message: core::Message,
|
||||
next: core::Next,
|
||||
timeout: std::time::Duration,
|
||||
) -> Result<Option<core::Message>, MiddlewareError> {
|
||||
let Some(ports) = &entry.invocation else {
|
||||
return if entry.failure_policy == PluginFailurePolicy::Delegate {
|
||||
@@ -44,7 +43,9 @@ pub(super) async fn invoke(
|
||||
.encode(message)
|
||||
.map_err(|_| MiddlewareError::Fault)?,
|
||||
};
|
||||
let mut call = ports.session.context(Stage::WebSocket, timeout);
|
||||
let mut call = ports
|
||||
.session
|
||||
.context(Stage::WebSocket, ports.session.budget(Stage::WebSocket));
|
||||
call.request = crate::adapter::request_context(context.extensions.request());
|
||||
call.request_id = Some(context.connection_id.clone());
|
||||
let _binding = ports
|
||||
@@ -55,7 +56,9 @@ pub(super) async fn invoke(
|
||||
request_id: context.connection_id.clone(),
|
||||
call_id: call.resource_scope_id.clone(),
|
||||
cancellation: context.cancellation.clone(),
|
||||
plan,
|
||||
plan: Some(plan),
|
||||
extensions: context.extensions.clone(),
|
||||
effects: None,
|
||||
}),
|
||||
context.extensions,
|
||||
invocation.clone(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use gateway_admin::model::{
|
||||
@@ -31,7 +31,6 @@ use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc};
|
||||
|
||||
use crate::{RpcSession, adapter::scope::BindingScope};
|
||||
|
||||
const OBSERVATION_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const OBSERVATION_ENVELOPE_RESERVE: usize = 16 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -57,10 +56,11 @@ impl ObserverEntry {
|
||||
event: Event,
|
||||
payload: Vec<u8>,
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
timeout: Duration,
|
||||
) {
|
||||
let request_id = event.request_id();
|
||||
let mut context = self.session.context(Stage::Observation, timeout);
|
||||
let mut context = self
|
||||
.session
|
||||
.context(Stage::Observation, self.session.budget(Stage::Observation));
|
||||
context.request = crate::adapter::request_context(extensions.request());
|
||||
context.request_id = Some(request_id.to_owned());
|
||||
let Ok(_scope) = self.callbacks.prepare_extensions(&context, extensions) else {
|
||||
@@ -166,7 +166,6 @@ pub(crate) struct PluginObserverPlan {
|
||||
websocket_queues: Arc<Mutex<BTreeMap<String, mpsc::Sender<QueuedWebSocketObservation>>>>,
|
||||
websocket_queue_capacity: usize,
|
||||
maximum_websocket_payload_bytes: usize,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
struct QueuedWebSocketObservation {
|
||||
@@ -180,7 +179,6 @@ impl PluginObserverPlan {
|
||||
pub(crate) fn compile(
|
||||
mut entries: Vec<ObserverEntry>,
|
||||
maximum_calls: usize,
|
||||
maximum_call_timeout: Duration,
|
||||
maximum_buffered_body_bytes: usize,
|
||||
) -> Option<Arc<Self>> {
|
||||
if entries.is_empty() {
|
||||
@@ -205,7 +203,6 @@ impl PluginObserverPlan {
|
||||
websocket_queues: Arc::new(Mutex::new(BTreeMap::new())),
|
||||
websocket_queue_capacity: maximum_calls.clamp(1, 256),
|
||||
maximum_websocket_payload_bytes,
|
||||
timeout: maximum_call_timeout.min(OBSERVATION_TIMEOUT),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -326,7 +323,6 @@ impl RequestObserverPlan for PluginObserverPlan {
|
||||
return;
|
||||
};
|
||||
let entries = Arc::clone(&self.entries);
|
||||
let timeout = self.timeout;
|
||||
handle.spawn(async move {
|
||||
let _generation = generation;
|
||||
let _slot = slot;
|
||||
@@ -342,7 +338,6 @@ impl RequestObserverPlan for PluginObserverPlan {
|
||||
Event::RequestCompleted(Box::new(wire_observation(&observation))),
|
||||
Vec::new(),
|
||||
observation.extension_scope().clone(),
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -372,10 +367,8 @@ impl PluginObserverPlan {
|
||||
let (sender, receiver) = mpsc::channel(self.websocket_queue_capacity);
|
||||
queues.insert(request_id.to_owned(), sender.clone());
|
||||
let entries = Arc::clone(&self.entries);
|
||||
let timeout = self.timeout;
|
||||
handle.spawn(dispatch_websocket_queue(
|
||||
entries,
|
||||
timeout,
|
||||
generation,
|
||||
worker_slot,
|
||||
receiver,
|
||||
@@ -386,7 +379,6 @@ impl PluginObserverPlan {
|
||||
|
||||
async fn dispatch_websocket_queue(
|
||||
entries: Arc<[ObserverEntry]>,
|
||||
timeout: Duration,
|
||||
generation: ExtensionSetReference,
|
||||
worker_slot: OwnedSemaphorePermit,
|
||||
mut receiver: mpsc::Receiver<QueuedWebSocketObservation>,
|
||||
@@ -422,7 +414,6 @@ async fn dispatch_websocket_queue(
|
||||
Event::WebSocketResponse(Box::new(request)),
|
||||
queued.payload.to_vec(),
|
||||
queued.observation.extension_scope().clone(),
|
||||
timeout,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -455,7 +446,6 @@ fn wire_observation(observation: &RequestObservation) -> RequestCompleted {
|
||||
upstream_model: observation
|
||||
.upstream_model()
|
||||
.map(|model| model.as_str().to_owned()),
|
||||
response_model: observation.response_model().map(str::to_owned),
|
||||
service_tier: observation.service_tier().map(str::to_owned),
|
||||
requested_model: observation
|
||||
.requested_model()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mod retry;
|
||||
mod route_schedule;
|
||||
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
@@ -13,8 +13,6 @@ use gateway_plugin_sdk::{Capability, Manifest, Stage};
|
||||
|
||||
use crate::{RpcSession, adapter::scope::BindingScope, callback::PluginCallbacks};
|
||||
|
||||
const POLICY_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) enum PolicyEntry {
|
||||
Router(ModelRouterEntry),
|
||||
@@ -219,7 +217,6 @@ pub(crate) struct PluginRequestPolicyPlan {
|
||||
routers: Arc<[ModelRouterEntry]>,
|
||||
schedulers: Arc<[AccountSchedulerEntry]>,
|
||||
retries: Arc<[RetryEntry]>,
|
||||
policy_timeout: Duration,
|
||||
}
|
||||
|
||||
impl fmt::Debug for PluginRequestPolicyPlan {
|
||||
@@ -234,10 +231,7 @@ impl fmt::Debug for PluginRequestPolicyPlan {
|
||||
}
|
||||
|
||||
impl PluginRequestPolicyPlan {
|
||||
pub(crate) fn compile(
|
||||
entries: Vec<PolicyEntry>,
|
||||
maximum_call_timeout: Duration,
|
||||
) -> Result<Option<Arc<Self>>, AdminError> {
|
||||
pub(crate) fn compile(entries: Vec<PolicyEntry>) -> Result<Option<Arc<Self>>, AdminError> {
|
||||
let mut routers = Vec::new();
|
||||
let mut schedulers = Vec::new();
|
||||
let mut retries = Vec::new();
|
||||
@@ -277,7 +271,6 @@ impl PluginRequestPolicyPlan {
|
||||
routers: routers.into(),
|
||||
schedulers: schedulers.into(),
|
||||
retries: retries.into(),
|
||||
policy_timeout: maximum_call_timeout.min(POLICY_TIMEOUT),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ use super::RetryEntry;
|
||||
pub(super) fn decide(
|
||||
entries: Arc<[RetryEntry]>,
|
||||
input: RetryInput,
|
||||
timeout: Duration,
|
||||
) -> BoxFuture<'static, Result<RetryDecision, RequestPolicyFault>> {
|
||||
Box::pin(async move {
|
||||
let started = std::time::Instant::now();
|
||||
for entry in entries.iter().filter(|entry| {
|
||||
let started = tokio::time::Instant::now();
|
||||
let matched = entries.iter().filter(|entry| {
|
||||
!input.extension_scope.contains(&entry.instance_id)
|
||||
&& entry.scope.matches_processing(
|
||||
&input.client_key_id,
|
||||
@@ -29,23 +28,32 @@ pub(super) fn decide(
|
||||
Some(&input.facts.provider),
|
||||
input.facts.model.as_deref(),
|
||||
)
|
||||
}) {
|
||||
});
|
||||
let timeout = matched
|
||||
.clone()
|
||||
.filter_map(|entry| entry.invocation.as_ref())
|
||||
.map(|invocation| invocation.session.budget(Stage::Retry))
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
.min(input.facts.remaining_deadline);
|
||||
for entry in matched {
|
||||
// 整条委托链共享预算,不能每个插件重新取得完整等待时间
|
||||
let remaining = timeout
|
||||
.min(input.facts.remaining_deadline)
|
||||
.saturating_sub(started.elapsed());
|
||||
let remaining = timeout.saturating_sub(started.elapsed());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
match call(entry, &input, remaining).await {
|
||||
Ok(RetryDecision::Delegate) => {}
|
||||
Ok(decision) => return Ok(decision),
|
||||
Err(()) => tracing::warn!(
|
||||
plugin_id = entry.plugin_id,
|
||||
instance_id = entry.instance_id,
|
||||
request_id = input.request_id.as_str(),
|
||||
"插件重试策略无效或调用失败,委托后续策略"
|
||||
),
|
||||
match tokio::time::timeout(remaining, call(entry, &input, remaining)).await {
|
||||
Err(_) => break,
|
||||
Ok(result) => match result {
|
||||
Ok(RetryDecision::Delegate) => {}
|
||||
Ok(decision) => return Ok(decision),
|
||||
Err(()) => tracing::warn!(
|
||||
plugin_id = entry.plugin_id,
|
||||
instance_id = entry.instance_id,
|
||||
request_id = input.request_id.as_str(),
|
||||
"插件重试策略无效或调用失败,委托后续策略"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(RetryDecision::Delegate)
|
||||
@@ -79,7 +87,10 @@ async fn call(
|
||||
.unwrap_or(u64::MAX),
|
||||
allowed_actions,
|
||||
};
|
||||
let mut context = invocation.session.context(Stage::Retry, timeout);
|
||||
let mut context = invocation.session.context(
|
||||
Stage::Retry,
|
||||
timeout.min(invocation.session.budget(Stage::Retry)),
|
||||
);
|
||||
context.request = crate::adapter::request_context(input.extension_scope.request());
|
||||
context.request_id = Some(request.request_id.clone());
|
||||
context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 插件模型路由与账号调度调用,以及请求事实的线协议投影
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use futures::future::BoxFuture;
|
||||
@@ -38,7 +38,7 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
|
||||
input: gateway_core::engine::policy::RetryInput,
|
||||
) -> BoxFuture<'static, Result<gateway_core::engine::policy::RetryDecision, RequestPolicyFault>>
|
||||
{
|
||||
super::retry::decide(self.retries.clone(), input, self.policy_timeout)
|
||||
super::retry::decide(self.retries.clone(), input)
|
||||
}
|
||||
|
||||
fn route_model(
|
||||
@@ -46,12 +46,11 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
|
||||
input: ModelRouteInput,
|
||||
) -> BoxFuture<'static, Result<ModelRouteDecision, RequestPolicyFault>> {
|
||||
let routers = std::sync::Arc::clone(&self.routers);
|
||||
let timeout = self.policy_timeout;
|
||||
Box::pin(async move {
|
||||
for entry in routers.iter().filter(|entry| {
|
||||
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_route(&input)
|
||||
}) {
|
||||
let result = route_with_entry(entry, &input, timeout).await;
|
||||
let result = route_with_entry(entry, &input).await;
|
||||
match result {
|
||||
Ok(ModelRouteDecision::Unhandled) => continue,
|
||||
Ok(decision) => return Ok(decision),
|
||||
@@ -78,14 +77,13 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
|
||||
input: AccountScheduleInput,
|
||||
) -> BoxFuture<'static, Result<AccountScheduleDecision, RequestPolicyFault>> {
|
||||
let schedulers = std::sync::Arc::clone(&self.schedulers);
|
||||
let timeout = self.policy_timeout;
|
||||
Box::pin(async move {
|
||||
let Some(entry) = schedulers.iter().find(|entry| {
|
||||
!input.suppresses_plugin(&entry.instance_id) && entry.scope.matches_schedule(&input)
|
||||
}) else {
|
||||
return Ok(AccountScheduleDecision::Delegate);
|
||||
};
|
||||
match schedule_with_entry(entry, &input, timeout).await {
|
||||
match schedule_with_entry(entry, &input).await {
|
||||
Ok(decision) => Ok(decision),
|
||||
Err(())
|
||||
if entry.failure_policy
|
||||
@@ -108,7 +106,6 @@ impl RequestPolicyPlan for PluginRequestPolicyPlan {
|
||||
async fn route_with_entry(
|
||||
entry: &ModelRouterEntry,
|
||||
input: &ModelRouteInput,
|
||||
timeout: Duration,
|
||||
) -> Result<ModelRouteDecision, ()> {
|
||||
let invocation = entry.invocation.as_ref().ok_or(())?;
|
||||
let projection = operation_projection(input.operation())?;
|
||||
@@ -126,7 +123,9 @@ async fn route_with_entry(
|
||||
.collect(),
|
||||
headers,
|
||||
};
|
||||
let mut context = invocation.session.context(Stage::Routing, timeout);
|
||||
let mut context = invocation
|
||||
.session
|
||||
.context(Stage::Routing, invocation.session.budget(Stage::Routing));
|
||||
context.request = crate::adapter::request_context(input.extension_scope().request());
|
||||
context.request_id = Some(input.request_id().as_str().to_owned());
|
||||
let _network_scope = invocation
|
||||
@@ -172,7 +171,6 @@ async fn route_with_entry(
|
||||
async fn schedule_with_entry(
|
||||
entry: &AccountSchedulerEntry,
|
||||
input: &AccountScheduleInput,
|
||||
timeout: Duration,
|
||||
) -> Result<AccountScheduleDecision, ()> {
|
||||
let invocation = entry.invocation.as_ref().ok_or(())?;
|
||||
let candidates = input.candidates();
|
||||
@@ -197,7 +195,10 @@ async fn schedule_with_entry(
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let mut context = invocation.session.context(Stage::Scheduling, timeout);
|
||||
let mut context = invocation.session.context(
|
||||
Stage::Scheduling,
|
||||
invocation.session.budget(Stage::Scheduling),
|
||||
);
|
||||
context.request = crate::adapter::request_context(input.extension_scope().request());
|
||||
context.request_id = Some(input.request_id().as_str().to_owned());
|
||||
context.attempt_id = Some(format!(
|
||||
|
||||
@@ -86,6 +86,14 @@ impl BindingScope {
|
||||
!self.providers.is_empty()
|
||||
}
|
||||
|
||||
/// 静态目录只能继承账号范围,不能丢失请求级 Key 或组条件
|
||||
pub(crate) fn matches_catalog(&self, provider: &ProviderKind, model: &str) -> bool {
|
||||
self.client_keys.is_empty()
|
||||
&& self.account_groups.is_empty()
|
||||
&& self.matches_provider(Some(provider))
|
||||
&& self.matches_model(Some(model))
|
||||
}
|
||||
|
||||
pub(crate) fn matches_observation(&self, observation: &RequestObservation) -> bool {
|
||||
self.matches_identity(observation.client_key_id(), observation.account_group_ids())
|
||||
&& self.matches_provider(observation.provider())
|
||||
|
||||
@@ -28,6 +28,7 @@ pub(super) struct EventDecoder {
|
||||
usage: Usage,
|
||||
contents: usize,
|
||||
pub(super) completed: bool,
|
||||
pub(super) replay_blocked: bool,
|
||||
}
|
||||
|
||||
pub(super) struct DecodedEvent {
|
||||
@@ -50,6 +51,10 @@ impl EventDecoder {
|
||||
if self.completed || message.event.facts.len() > 64 {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
// 即使与失败同批携带,也不能把 wire、工具或其他业务事实丢弃后重放
|
||||
self.replay_blocked |= !message.event.facts.is_empty()
|
||||
|| message.event.wire.is_some()
|
||||
|| message.continuation.is_some();
|
||||
let wire = message
|
||||
.event
|
||||
.wire
|
||||
|
||||
@@ -75,12 +75,34 @@ pub(super) fn execute(
|
||||
let result = state.next().await;
|
||||
match result {
|
||||
Ok(event) => Ok(Some((event, state))),
|
||||
Err(error) => Err(state.invocation.account.record_failure(error).await),
|
||||
Err(error) => {
|
||||
let error = state.finish_error(error);
|
||||
Err(state.invocation.account.record_failure(error).await)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
impl Execution {
|
||||
fn finish_error(&mut self, mut error: ProviderError) -> ProviderError {
|
||||
if let Some(active) = self.active.take() {
|
||||
let managed = Arc::clone(&active.managed);
|
||||
// RpcStream 析构同步撤销回调并封存未完成发送,证明不能早于这一步
|
||||
drop(active);
|
||||
if error.kind() == ProviderErrorKind::RateLimited
|
||||
&& error.upstream_status() == Some(429)
|
||||
&& error.send_state() == UpstreamSendState::Sent
|
||||
&& managed.send_state.get() == UpstreamSendState::Sent
|
||||
&& managed.replay.rejected_once()
|
||||
&& !self.decoder.replay_blocked
|
||||
&& !self.invocation.context.cancellation().is_cancelled()
|
||||
{
|
||||
error = error.with_replay_safe();
|
||||
}
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
async fn next(&mut self) -> Result<gateway_core::event::ProviderEvent, ProviderError> {
|
||||
if self.active.is_none() {
|
||||
self.active = Some(self.begin().await?);
|
||||
@@ -167,7 +189,7 @@ impl Execution {
|
||||
let timeout = invocation
|
||||
.context
|
||||
.deadline()
|
||||
.bounded(self.adapter.session.maximum_call_timeout());
|
||||
.bounded(self.adapter.session.budget(Stage::Upstream));
|
||||
if timeout.is_zero() || invocation.context.cancellation().is_cancelled() {
|
||||
return Err(ProviderError::new(
|
||||
ProviderErrorKind::Cancelled,
|
||||
|
||||
@@ -37,6 +37,24 @@ pub(crate) struct AdapterEntry {
|
||||
adapter: Option<Arc<PluginUpstreamAdapter>>,
|
||||
}
|
||||
|
||||
impl AdapterEntry {
|
||||
pub(crate) fn catalog_target(
|
||||
&self,
|
||||
id: &str,
|
||||
model: &str,
|
||||
) -> Option<&UpstreamAdapterDeclaration> {
|
||||
let adapter = self.adapter.as_ref()?;
|
||||
let declaration = &adapter.declaration;
|
||||
let provider = ProviderKind::new(declaration.provider.as_str()).ok()?;
|
||||
(declaration.id == id
|
||||
&& !declaration.account_ids.is_empty()
|
||||
&& (declaration.models.is_empty()
|
||||
|| declaration.models.iter().any(|value| value == model))
|
||||
&& self.scope.matches_catalog(&provider, model))
|
||||
.then_some(declaration)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PluginUpstreamAdapter {
|
||||
instance_id: String,
|
||||
contribution_id: String,
|
||||
|
||||
@@ -47,12 +47,6 @@ pub(super) fn merge(
|
||||
.try_with_service_tier(value)
|
||||
.map_err(|source| invalid(sent).with_source(source))?;
|
||||
}
|
||||
if let Some(value) = update.upstream_response_model {
|
||||
current = current.with_upstream_response_model_if_valid(&value);
|
||||
if current.upstream_response_model() != Some(value.trim()) {
|
||||
return Err(invalid(sent));
|
||||
}
|
||||
}
|
||||
if !update.client_headers.is_empty() {
|
||||
current = current.with_client_headers(
|
||||
update
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 校验并准备插件上游适配声明与能力绑定
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gateway_admin::model::{
|
||||
AdminError,
|
||||
@@ -56,7 +56,7 @@ pub(crate) async fn prepare(
|
||||
let reply = session
|
||||
.call(
|
||||
REGISTER_METHOD,
|
||||
session.context(Stage::Registration, Duration::from_secs(5)),
|
||||
session.context(Stage::Registration, session.budget(Stage::Registration)),
|
||||
serde_json::json!({}),
|
||||
vec![],
|
||||
)
|
||||
|
||||
@@ -20,8 +20,8 @@ use gateway_core::{
|
||||
use gateway_plugin_sdk::{
|
||||
CallContext, ErrorCode, PluginFault,
|
||||
call::host::{
|
||||
AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult, AuthRuntimeAccount,
|
||||
AuthSaveRequest, AuthSaveResult, CredentialFacts,
|
||||
self as wire, AuthCredential, AuthGetRequest, AuthListRequest, AuthListResult,
|
||||
AuthRuntimeAccount, AuthSaveRequest, AuthSaveResult, CredentialFacts,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -66,6 +66,32 @@ impl PluginAccounts {
|
||||
Self { slot }
|
||||
}
|
||||
|
||||
pub(super) async fn http_proxy(
|
||||
&self,
|
||||
account_id: String,
|
||||
credential_revision: u64,
|
||||
) -> Result<Option<gateway_core::account::OutboundProxy>, PluginFault> {
|
||||
let id = ProviderAccountId::new(account_id).map_err(|_| invalid())?;
|
||||
let expected = Revision::new(credential_revision).map_err(|_| invalid())?;
|
||||
let account = self
|
||||
.slot
|
||||
.upgrade()
|
||||
.map_err(map_admin_error)?
|
||||
.get_runtime(&id)
|
||||
.await
|
||||
.map_err(map_admin_error)?;
|
||||
if !account.enabled {
|
||||
return Err(denied());
|
||||
}
|
||||
if account.credential_revision != expected {
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Conflict,
|
||||
"account credential revision has changed",
|
||||
));
|
||||
}
|
||||
Ok(account.effective_outbound_proxy().cloned())
|
||||
}
|
||||
|
||||
pub(super) async fn call(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
@@ -79,7 +105,7 @@ impl PluginAccounts {
|
||||
}
|
||||
let access = self.slot.upgrade().map_err(map_admin_error)?;
|
||||
match method {
|
||||
"host.auth.list" => {
|
||||
wire::AUTH_LIST => {
|
||||
let request: AuthListRequest = decode(payload)?;
|
||||
let limit = PageSize::new(request.limit).map_err(|_| invalid())?;
|
||||
let cursor = request
|
||||
@@ -105,7 +131,7 @@ impl PluginAccounts {
|
||||
next_cursor: page.next_cursor.map(|cursor| cursor.as_str().to_owned()),
|
||||
})
|
||||
}
|
||||
"host.auth.get_runtime" => {
|
||||
wire::AUTH_GET_RUNTIME => {
|
||||
let request: AuthGetRequest = decode(payload)?;
|
||||
let account_id =
|
||||
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
|
||||
@@ -115,7 +141,7 @@ impl PluginAccounts {
|
||||
.map_err(map_admin_error)?;
|
||||
encode(&runtime_account(&account))
|
||||
}
|
||||
"host.auth.get" => {
|
||||
wire::AUTH_GET => {
|
||||
let request: AuthGetRequest = decode(payload)?;
|
||||
let account_id =
|
||||
ProviderAccountId::new(request.account_id).map_err(|_| invalid())?;
|
||||
@@ -131,7 +157,7 @@ impl PluginAccounts {
|
||||
facts: credential_facts(account, credential.provider_material),
|
||||
})
|
||||
}
|
||||
"host.auth.save" => {
|
||||
wire::AUTH_SAVE => {
|
||||
let request: AuthSaveRequest = decode(payload)?;
|
||||
encode(&self.save(context, scope, request).await?)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ pub(super) struct CallResources {
|
||||
pub(super) cancellation: CancellationToken,
|
||||
pub(super) scope: Arc<CallbackScope>,
|
||||
pub(super) http_resources: Arc<http_middleware::resources::Resources>,
|
||||
pub(super) model_bindings: tokio::sync::Mutex<
|
||||
BTreeMap<String, gateway_core::engine::execution::BoundModelExecutionContext>,
|
||||
>,
|
||||
pub(super) client_identities:
|
||||
tokio::sync::Mutex<BTreeMap<String, gateway_core::engine::execution::AuthenticatedClient>>,
|
||||
pub(super) state: Mutex<CallState>,
|
||||
}
|
||||
|
||||
@@ -32,13 +31,18 @@ impl CallResources {
|
||||
Self {
|
||||
deadline: deadline.clone(),
|
||||
operation_timeout: Duration::from_millis(context.timeout_ms),
|
||||
cancellation: CancellationToken::new(),
|
||||
cancellation: scope
|
||||
.origin
|
||||
.as_ref()
|
||||
.map_or_else(CancellationToken::new, |origin| {
|
||||
origin.cancellation.child_token()
|
||||
}),
|
||||
scope,
|
||||
http_resources: middleware
|
||||
.as_ref()
|
||||
.and_then(|middleware| middleware.http_resources())
|
||||
.unwrap_or_default(),
|
||||
model_bindings: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
client_identities: tokio::sync::Mutex::new(BTreeMap::new()),
|
||||
state: Mutex::new(CallState {
|
||||
middleware,
|
||||
..CallState::default()
|
||||
@@ -46,6 +50,40 @@ impl CallResources {
|
||||
}
|
||||
}
|
||||
|
||||
/// 子调用共享受信来源;身份解析与执行准入仍留在各能力 owner
|
||||
pub(super) fn child_origin(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
) -> Result<gateway_core::engine::extensions::InvocationOrigin, PluginFault> {
|
||||
let parent = self.scope.origin.as_ref();
|
||||
Ok(gateway_core::engine::extensions::InvocationOrigin {
|
||||
request_id: parent.map_or_else(
|
||||
|| {
|
||||
context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone())
|
||||
},
|
||||
|origin| origin.request_id.clone(),
|
||||
),
|
||||
call_id: parent.map_or_else(
|
||||
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|
||||
|origin| origin.call_id.clone(),
|
||||
),
|
||||
cancellation: self.cancellation.child_token(),
|
||||
extensions: if self.scope.extension_scope.contains(&context.instance_id) {
|
||||
self.scope.extension_scope.clone()
|
||||
} else {
|
||||
self.scope
|
||||
.extension_scope
|
||||
.extending(context.instance_id.clone())
|
||||
.ok_or_else(super::invalid)?
|
||||
},
|
||||
plan: parent.and_then(|origin| origin.plan.clone()),
|
||||
effects: self.scope.execution_effects.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn close(&self) {
|
||||
self.cancellation.cancel();
|
||||
let mut state = self
|
||||
@@ -114,7 +152,9 @@ impl CallResources {
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.middleware
|
||||
.clone();
|
||||
middleware.and_then(|middleware| middleware.request_settings())
|
||||
middleware
|
||||
.and_then(|middleware| middleware.request_settings())
|
||||
.or_else(|| self.scope.settings.clone())
|
||||
}
|
||||
|
||||
/// 建立受管流后,网络操作各自计时;连接空闲时间不消耗后续操作的预算
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! 客户端认证、冻结权限查询与调用内身份复用
|
||||
|
||||
use std::sync::{Arc, OnceLock, Weak};
|
||||
|
||||
use gateway_admin::model::AdminError;
|
||||
use gateway_core::{
|
||||
account::ProviderAccountId,
|
||||
engine::{
|
||||
authentication::{ClientAuthenticationRequest, ClientIdentityPort},
|
||||
execution::AuthenticatedClient,
|
||||
},
|
||||
policy::ClientApiKeyId,
|
||||
routing::UpstreamModelId,
|
||||
};
|
||||
use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, call::data};
|
||||
|
||||
use super::{CallResources, denied, invalid};
|
||||
use crate::RpcReply;
|
||||
|
||||
const MAX_CLIENT_IDENTITIES_PER_CALL: usize = 16;
|
||||
|
||||
pub(crate) struct PluginClientIdentityPortSlot {
|
||||
port: OnceLock<Weak<dyn ClientIdentityPort>>,
|
||||
}
|
||||
|
||||
impl PluginClientIdentityPortSlot {
|
||||
pub(crate) const fn new() -> Self {
|
||||
Self {
|
||||
port: OnceLock::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind(&self, port: &Arc<dyn ClientIdentityPort>) -> Result<(), AdminError> {
|
||||
self.port
|
||||
.set(Arc::downgrade(port))
|
||||
.map_err(|_| AdminError::conflict("插件身份端口已经绑定"))
|
||||
}
|
||||
|
||||
fn upgrade(&self) -> Result<Arc<dyn ClientIdentityPort>, PluginFault> {
|
||||
self.port.get().and_then(Weak::upgrade).ok_or_else(denied)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PluginClients {
|
||||
slot: Arc<PluginClientIdentityPortSlot>,
|
||||
}
|
||||
|
||||
impl PluginClients {
|
||||
pub(super) fn new(slot: Arc<PluginClientIdentityPortSlot>) -> Self {
|
||||
Self { slot }
|
||||
}
|
||||
|
||||
pub(super) async fn call(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
call: &Arc<CallResources>,
|
||||
method: &str,
|
||||
params: serde_json::Value,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
match method {
|
||||
data::CLIENT_VERIFY => {
|
||||
if params != serde_json::json!({}) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let request: data::VerifyClientRequest =
|
||||
serde_json::from_slice(&payload).map_err(|_| invalid())?;
|
||||
let mut authentication = ClientAuthenticationRequest::new(request.authorization)
|
||||
.map_err(|_| invalid())?
|
||||
.with_invocation(call.child_origin(context)?);
|
||||
if let Some(settings) = call.request_settings() {
|
||||
authentication = authentication.with_settings(settings);
|
||||
}
|
||||
let port = self.slot.upgrade()?;
|
||||
call.observe_dispatch()?;
|
||||
let client = port
|
||||
.verify_client(authentication)
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
super::admin::encode(&data::VerifiedClient {
|
||||
client_key_id: client.policy().key_id().as_str().to_owned(),
|
||||
})
|
||||
}
|
||||
data::ACCESS_CHECK => {
|
||||
if params != serde_json::json!({}) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let request: data::AccessQuery =
|
||||
serde_json::from_slice(&payload).map_err(|_| invalid())?;
|
||||
let client = self.identity(call, request.client_key_id).await?;
|
||||
let scope = client.policy().account_scope();
|
||||
let model = request
|
||||
.upstream_model
|
||||
.map(UpstreamModelId::new)
|
||||
.transpose()
|
||||
.map_err(|_| invalid())?;
|
||||
let accounts = request
|
||||
.account_ids
|
||||
.into_iter()
|
||||
.map(|account_id| {
|
||||
let account =
|
||||
ProviderAccountId::new(account_id.clone()).map_err(|_| invalid())?;
|
||||
let allowed = model.as_ref().map_or_else(
|
||||
|| scope.allows(&account),
|
||||
|model| scope.allows_model(&account, model.as_str()),
|
||||
);
|
||||
Ok(data::AccountAccessFacts {
|
||||
account_id,
|
||||
allowed,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, PluginFault>>()?;
|
||||
super::admin::encode(&data::AccessFacts {
|
||||
schema_version: 1,
|
||||
client_key_id: client.policy().key_id().as_str().to_owned(),
|
||||
accounts,
|
||||
})
|
||||
}
|
||||
_ => Err(denied()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn identity(
|
||||
&self,
|
||||
call: &CallResources,
|
||||
client_key_id: String,
|
||||
) -> Result<AuthenticatedClient, PluginFault> {
|
||||
let port = self.slot.upgrade()?;
|
||||
let key = ClientApiKeyId::new(client_key_id.clone()).map_err(|_| invalid())?;
|
||||
let settings = call.request_settings();
|
||||
let mut identities = call.client_identities.lock().await;
|
||||
if let Some(client) = identities
|
||||
.get(&client_key_id)
|
||||
.filter(|client| client.request_settings() == settings.as_ref())
|
||||
{
|
||||
return Ok(client.clone());
|
||||
}
|
||||
if !identities.contains_key(&client_key_id)
|
||||
&& identities.len() >= MAX_CLIENT_IDENTITIES_PER_CALL
|
||||
{
|
||||
return Err(PluginFault::new(
|
||||
ErrorCode::Capacity,
|
||||
"client identity capacity is exhausted",
|
||||
));
|
||||
}
|
||||
let client = port
|
||||
.resolve_client(key, settings)
|
||||
.await
|
||||
.map_err(super::error::gateway)?;
|
||||
// 查询与执行共用调用内快照,执行链和子执行取消只在真正启动时建立
|
||||
identities.insert(client_key_id, client.clone());
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ impl PluginData {
|
||||
next_cursor: page.next_cursor.map(|id| id.as_str().to_owned()),
|
||||
})
|
||||
}
|
||||
data::QUOTA_GET | data::QUOTA_REFRESH => {
|
||||
data::QUOTA_GET | data::QUOTA_REFRESH | data::QUOTA_DETAILS => {
|
||||
let accounts = self.accounts.upgrade().map_err(map_admin_error)?;
|
||||
let query: data::QuotaFactsQuery =
|
||||
serde_json::from_slice(payload).map_err(|_| invalid())?;
|
||||
@@ -109,6 +109,35 @@ impl PluginData {
|
||||
accounts.get_quota(&account_id).await
|
||||
}
|
||||
.map_err(map_admin_error)?;
|
||||
if method == data::QUOTA_DETAILS {
|
||||
return super::admin::encode(&data::QuotaDetails {
|
||||
schema_version: 1,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
observed_at_ms: quota.observed_at.map(|time| time.timestamp_millis()),
|
||||
limit_reached: quota.limit_reached,
|
||||
windows: quota
|
||||
.windows
|
||||
.into_iter()
|
||||
.map(|window| data::QuotaWindowDetails {
|
||||
key: window.key,
|
||||
group: window.group,
|
||||
label: window.label,
|
||||
limit_id: window.limit_id,
|
||||
limit_name: window.limit_name,
|
||||
role: window.role.map(|role| role.as_str().to_owned()),
|
||||
window_seconds: window.window_seconds,
|
||||
used_percent: window.used_percent,
|
||||
reset_at_ms: window.reset_at.map(|time| time.timestamp_millis()),
|
||||
limit_reached: window.limit_reached,
|
||||
})
|
||||
.collect(),
|
||||
credits: quota.credits.map(|credits| data::QuotaCredits {
|
||||
has_credits: credits.has_credits,
|
||||
unlimited: credits.unlimited,
|
||||
balance: credits.balance,
|
||||
}),
|
||||
});
|
||||
}
|
||||
serde_json::to_vec(&data::QuotaFacts {
|
||||
schema_version: 1,
|
||||
account_id: account_id.as_str().to_owned(),
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
//! 将有效父调用的宿主回调分派到所属原生能力,并随调用撤销资源
|
||||
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_plugin_sdk::{
|
||||
CallContext, ErrorCode, PluginFault,
|
||||
call::{host, middleware as middleware_wire, upstream_adapter as upstream_wire},
|
||||
client::CallDeadline,
|
||||
};
|
||||
|
||||
use super::{CallResources, CallbackScope, PluginCallbacks, denied, http, http_dispatch, upstream};
|
||||
use crate::{CallbackHandler, RpcReply};
|
||||
|
||||
impl CallbackHandler for PluginCallbacks {
|
||||
fn begin(&self, context: &CallContext, deadline: &CallDeadline) {
|
||||
let scope = self
|
||||
.scopes
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&context.resource_scope_id)
|
||||
.and_then(Weak::upgrade)
|
||||
.unwrap_or_else(|| {
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
if context.stage == gateway_plugin_sdk::Stage::Registration {
|
||||
scope.request_body_limit = self.registration_body_limit;
|
||||
}
|
||||
Arc::new(scope)
|
||||
});
|
||||
let middleware = self
|
||||
.pending_middleware
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&context.resource_scope_id);
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(
|
||||
context.call_id,
|
||||
Arc::new(CallResources::new(context, deadline, scope, middleware)),
|
||||
);
|
||||
}
|
||||
|
||||
fn finish(&self, context: &CallContext) {
|
||||
if let Some(call) = self
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&context.call_id)
|
||||
{
|
||||
call.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn call(
|
||||
&self,
|
||||
context: CallContext,
|
||||
method: String,
|
||||
params: serde_json::Value,
|
||||
payload: Vec<u8>,
|
||||
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
|
||||
let call = self
|
||||
.calls
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&context.call_id)
|
||||
.cloned();
|
||||
let http = self.http.clone();
|
||||
let network = self.network.clone();
|
||||
let maximum_payload = self.maximum_payload;
|
||||
let log = self.log.clone();
|
||||
let private_state = self.private_state.clone();
|
||||
let accounts = self.accounts.clone();
|
||||
let resources = self.resources.clone();
|
||||
let data = self.data.clone();
|
||||
let keys = self.keys.clone();
|
||||
let clients = self.clients.clone();
|
||||
let models = self.models.clone();
|
||||
let request_limits = self.request_limits.clone();
|
||||
let affinity = self.affinity.clone();
|
||||
let services = self.services.clone();
|
||||
let service_actor = self.service_actor.clone();
|
||||
Box::pin(async move {
|
||||
let call = call.ok_or_else(denied)?;
|
||||
let scope = &call.scope;
|
||||
let (middleware, is_private_state) = {
|
||||
let state = call
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if state.closed || call.deadline.is_elapsed() {
|
||||
return Err(denied());
|
||||
}
|
||||
if method == host::LOG {
|
||||
return log.record(&context, params, &payload);
|
||||
}
|
||||
// 只证明受管 HTTP 拒绝;其他能力即使只读,也不推测其业务副作用
|
||||
if let Some(managed) = &scope.upstream
|
||||
&& !matches!(
|
||||
method.as_str(),
|
||||
upstream_wire::HTTP_DO
|
||||
| upstream_wire::HTTP_DO_STREAM
|
||||
| upstream_wire::HTTP_STREAM_READ
|
||||
| upstream_wire::HTTP_STREAM_CLOSE
|
||||
)
|
||||
{
|
||||
managed.replay.invalidate();
|
||||
}
|
||||
let middleware = if matches!(
|
||||
method.as_str(),
|
||||
middleware_wire::NEXT_METHOD
|
||||
| middleware_wire::BODY_READ_METHOD
|
||||
| middleware_wire::BODY_FACTS_METHOD
|
||||
| middleware_wire::BODY_CLOSE_METHOD
|
||||
| middleware_wire::http::UPGRADE_METHOD
|
||||
| middleware_wire::http::RECEIVE_METHOD
|
||||
| middleware_wire::websocket::SEND_METHOD
|
||||
) {
|
||||
Some(state.middleware.clone().ok_or_else(denied)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_private_state = matches!(
|
||||
method.as_str(),
|
||||
host::STATE_GET | host::STATE_PUT | host::STATE_DELETE
|
||||
);
|
||||
(middleware, is_private_state)
|
||||
};
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
middleware_wire::http::DISPATCH_METHOD
|
||||
| middleware_wire::http::BODY_READ_METHOD
|
||||
| middleware_wire::http::BODY_CLOSE_METHOD
|
||||
| middleware_wire::http::BODY_CREATE_METHOD
|
||||
| middleware_wire::http::BODY_WRITE_METHOD
|
||||
) {
|
||||
return tokio::select! {
|
||||
biased;
|
||||
() = call.cancellation.cancelled() => Err(PluginFault::new(ErrorCode::Cancelled, "HTTP callback cancelled")),
|
||||
result = http_dispatch::call(&services, &context, &call, &method, params, payload, maximum_payload) => result,
|
||||
};
|
||||
}
|
||||
if let Some(middleware) = middleware {
|
||||
return middleware
|
||||
.invoke(method, params, payload, maximum_payload)
|
||||
.await;
|
||||
}
|
||||
if method == gateway_plugin_sdk::call::services::CALL_METHOD {
|
||||
return services
|
||||
.call(&context, &call, service_actor, params, &payload)
|
||||
.await;
|
||||
}
|
||||
if is_private_state {
|
||||
return private_state.call(&method, params, &payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
gateway_plugin_sdk::call::resources::GROUP_ENSURE
|
||||
| gateway_plugin_sdk::call::resources::GROUP_MEMBERS
|
||||
| gateway_plugin_sdk::call::resources::KEY_ENSURE
|
||||
) {
|
||||
return resources.call(&context, &method, params, &payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
host::KEYS_LIST
|
||||
| gateway_plugin_sdk::call::key_budgets::RESET
|
||||
| gateway_plugin_sdk::call::key_budgets::GET
|
||||
| gateway_plugin_sdk::call::key_budgets::UPDATE_LIMITS
|
||||
) {
|
||||
return keys.call(&context, &method, params, &payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
gateway_plugin_sdk::call::data::CLIENT_VERIFY
|
||||
| gateway_plugin_sdk::call::data::ACCESS_CHECK
|
||||
) {
|
||||
return clients
|
||||
.call(&context, &call, &method, params, payload)
|
||||
.await;
|
||||
}
|
||||
if method == host::MODELS_LIST {
|
||||
return models.call(&context, &call, &method, params, payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
host::MODEL_EXECUTE
|
||||
| host::MODEL_EXECUTE_STREAM
|
||||
| host::MODEL_STREAM_READ
|
||||
| host::MODEL_STREAM_CLOSE
|
||||
) {
|
||||
return models.call(&context, &call, &method, params, payload).await;
|
||||
}
|
||||
if method == host::AFFINITY_LOOKUP {
|
||||
return affinity.call(&context, params, &payload).await;
|
||||
}
|
||||
let scope = Some(scope)
|
||||
.filter(|scope| scope.authorizes(&context))
|
||||
.ok_or_else(denied)?;
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
gateway_plugin_sdk::call::data::ACCOUNTS_LIST
|
||||
| gateway_plugin_sdk::call::data::KEYS_GET
|
||||
| gateway_plugin_sdk::call::data::QUOTA_GET
|
||||
| gateway_plugin_sdk::call::data::QUOTA_REFRESH
|
||||
| gateway_plugin_sdk::call::data::QUOTA_DETAILS
|
||||
) {
|
||||
return data.call(&method, params, &payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
host::AUTH_LIST | host::AUTH_GET | host::AUTH_GET_RUNTIME | host::AUTH_SAVE
|
||||
) {
|
||||
return accounts
|
||||
.call(&context, scope, &method, params, &payload)
|
||||
.await;
|
||||
}
|
||||
let http = http::HttpCallbacks {
|
||||
client: &http,
|
||||
network: &network,
|
||||
scope,
|
||||
call: &call,
|
||||
maximum_payload,
|
||||
request_limits: &request_limits,
|
||||
};
|
||||
if method == host::ACCOUNT_HTTP {
|
||||
return http.account(&accounts, params, payload).await;
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
upstream_wire::HTTP_DO
|
||||
| upstream_wire::HTTP_DO_STREAM
|
||||
| upstream_wire::HTTP_STREAM_READ
|
||||
| upstream_wire::HTTP_STREAM_CLOSE
|
||||
| upstream_wire::WEBSOCKET_OPEN
|
||||
| upstream_wire::WEBSOCKET_SEND
|
||||
| upstream_wire::WEBSOCKET_READ
|
||||
| upstream_wire::WEBSOCKET_CLOSE
|
||||
) {
|
||||
return upstream::dispatch(&http, &method, params, payload)
|
||||
.await
|
||||
.inspect_err(|_| {
|
||||
if let Some(managed) = &scope.upstream {
|
||||
managed.replay.invalidate();
|
||||
}
|
||||
});
|
||||
}
|
||||
if matches!(
|
||||
method.as_str(),
|
||||
host::HTTP_DO
|
||||
| host::HTTP_DO_STREAM
|
||||
| host::HTTP_STREAM_READ
|
||||
| host::HTTP_STREAM_CLOSE
|
||||
) {
|
||||
return http.dispatch(&method, params, payload).await;
|
||||
}
|
||||
Err(PluginFault::new(
|
||||
ErrorCode::Unsupported,
|
||||
"host callback method is not supported",
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,6 @@ fn observation(value: &ProviderResponseObservation) -> facts::ResponseObservatio
|
||||
status_code: value.status_code(),
|
||||
request_id: value.request_id().map(|value| value.as_str().to_owned()),
|
||||
service_tier: value.service_tier().map(str::to_owned),
|
||||
upstream_response_model: value.upstream_response_model().map(str::to_owned),
|
||||
timings: facts::ResponseTimings {
|
||||
transport_decision_wait_ms: timings.transport_decision_wait_ms,
|
||||
connect_ms: timings.connect_ms,
|
||||
|
||||
@@ -29,16 +29,42 @@ impl HttpCallbacks<'_> {
|
||||
self.request_limits.body_bytes(self.call)
|
||||
}
|
||||
|
||||
pub(super) async fn account(
|
||||
&self,
|
||||
accounts: &super::accounts::PluginAccounts,
|
||||
params: serde_json::Value,
|
||||
payload: Vec<u8>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
let request: wire::AccountHttpRequest = decode(params)?;
|
||||
let maximum_body_bytes = self.body_limit(&payload)?;
|
||||
let proxy = accounts
|
||||
.http_proxy(request.account_id, request.credential_revision)
|
||||
.await?;
|
||||
self.send(
|
||||
HttpRequest {
|
||||
method: request.request.method,
|
||||
url: request.request.url,
|
||||
headers: request.request.headers,
|
||||
body: payload,
|
||||
maximum_body_bytes,
|
||||
},
|
||||
true,
|
||||
None,
|
||||
proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(super) async fn send(
|
||||
&self,
|
||||
request: HttpRequest,
|
||||
streaming: bool,
|
||||
upstream_purpose: Option<gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose>,
|
||||
proxy: Option<&gateway_core::account::OutboundProxy>,
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
let Self {
|
||||
client,
|
||||
network,
|
||||
scope,
|
||||
call,
|
||||
maximum_payload,
|
||||
..
|
||||
@@ -48,13 +74,12 @@ impl HttpCallbacks<'_> {
|
||||
}
|
||||
let timeout = call.timeout()?;
|
||||
let attempt = call.start_upstream(upstream_purpose)?;
|
||||
let response = client
|
||||
.open(request, scope.proxy.as_ref(), network, timeout)
|
||||
.await;
|
||||
attempt.finish(
|
||||
let response = client.open(request, proxy, network, timeout).await;
|
||||
attempt.finish_http(
|
||||
response
|
||||
.as_ref()
|
||||
.map_or_else(|error| error.send_state, |_| UpstreamSendState::Sent),
|
||||
response.as_ref().ok().map(|response| response.status),
|
||||
);
|
||||
let response = response.map_err(http_error)?;
|
||||
let mut body = response.body;
|
||||
@@ -115,7 +140,7 @@ impl HttpCallbacks<'_> {
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
let call = self.call;
|
||||
match method {
|
||||
"host.http.do" | "host.http.do_stream" => {
|
||||
wire::HTTP_DO | wire::HTTP_DO_STREAM => {
|
||||
let request: wire::HttpRequest = decode(params)?;
|
||||
self.send(
|
||||
HttpRequest {
|
||||
@@ -125,12 +150,13 @@ impl HttpCallbacks<'_> {
|
||||
maximum_body_bytes: self.body_limit(&payload)?,
|
||||
body: payload,
|
||||
},
|
||||
method == "host.http.do_stream",
|
||||
method == wire::HTTP_DO_STREAM,
|
||||
None,
|
||||
self.scope.proxy.as_ref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
"host.http.stream_read" => {
|
||||
wire::HTTP_STREAM_READ => {
|
||||
if !payload.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
@@ -174,7 +200,7 @@ impl HttpCallbacks<'_> {
|
||||
payload: chunk.map_or_else(Vec::new, |chunk| chunk.to_vec()),
|
||||
})
|
||||
}
|
||||
"host.http.stream_close" => {
|
||||
wire::HTTP_STREAM_CLOSE => {
|
||||
if !payload.is_empty() {
|
||||
return Err(invalid());
|
||||
}
|
||||
|
||||
@@ -36,25 +36,23 @@ pub(super) async fn call(
|
||||
request.timeout_ms,
|
||||
&context.instance_id,
|
||||
)?;
|
||||
let parent = call.scope.origin.as_ref();
|
||||
call.scope.check_child_depth(&context.instance_id)?;
|
||||
let origin = call.child_origin(context)?;
|
||||
let child = core::Context {
|
||||
plugin_instance_id: Some(context.instance_id.clone()),
|
||||
request_id: context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone()),
|
||||
request_id: origin.request_id,
|
||||
call_id: uuid::Uuid::now_v7().to_string(),
|
||||
parent_call_id: Some(parent.map_or_else(
|
||||
|| format!("{}:{}", context.resource_scope_id, context.call_id),
|
||||
|parent| parent.call_id.clone(),
|
||||
)),
|
||||
extensions: call.scope.child_extensions(&context.instance_id)?,
|
||||
// 句柄可以在父 RPC End 后交给 HTTP 发送端;生命周期属于父调用和响应正文,不能在 RPC End 提前取消
|
||||
cancellation: parent.map_or_else(
|
||||
|| call.cancellation.child_token(),
|
||||
|parent| parent.cancellation.child_token(),
|
||||
),
|
||||
plan: parent.map(|parent| parent.plan.clone()),
|
||||
parent_call_id: Some(origin.call_id),
|
||||
extensions: origin.extensions,
|
||||
// 转交正文后由消费端持有原请求寿命,父 RPC End 只撤销新业务分派
|
||||
cancellation: call
|
||||
.scope
|
||||
.origin
|
||||
.as_ref()
|
||||
.map_or(origin.cancellation, |parent| {
|
||||
parent.cancellation.child_token()
|
||||
}),
|
||||
plan: origin.plan,
|
||||
};
|
||||
let body = call.http_resources.body(request.body, payload).await?;
|
||||
let mut http = core::Request::new(body);
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! 为具体入口登记回调作用域和一次性中间件绑定,不持有 RPC 调度状态
|
||||
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use gateway_core::{account::OutboundProxy, lifecycle::CancellationToken};
|
||||
use gateway_plugin_sdk::{CallContext, PluginFault};
|
||||
|
||||
use super::{
|
||||
CallbackScope, InvocationContext, MiddlewareBinding, MiddlewareCallback, PluginCallbacks,
|
||||
denied, upstream,
|
||||
};
|
||||
|
||||
impl PluginCallbacks {
|
||||
pub(crate) fn bind_middleware(
|
||||
self: &Arc<Self>,
|
||||
resource_scope_id: String,
|
||||
invocation: Arc<dyn MiddlewareCallback>,
|
||||
scope: Arc<CallbackScope>,
|
||||
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
|
||||
let mut pending = self
|
||||
.pending_middleware
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if pending
|
||||
.insert(resource_scope_id.clone(), Arc::clone(&invocation))
|
||||
.is_some()
|
||||
{
|
||||
return Err(gateway_core::engine::middleware::MiddlewareError::InvalidState);
|
||||
}
|
||||
Ok(MiddlewareBinding::new(
|
||||
Arc::clone(self),
|
||||
resource_scope_id,
|
||||
invocation,
|
||||
scope,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn unbind_middleware(
|
||||
&self,
|
||||
resource_scope_id: &str,
|
||||
invocation: &Arc<dyn MiddlewareCallback>,
|
||||
) {
|
||||
let mut pending = self
|
||||
.pending_middleware
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if pending
|
||||
.get(resource_scope_id)
|
||||
.is_some_and(|pending| Arc::ptr_eq(pending, invocation))
|
||||
{
|
||||
pending.remove(resource_scope_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_data_plane(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
effects: Option<Arc<gateway_core::engine::nested::ExecutionEffects>>,
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
plan: Option<gateway_core::engine::middleware::FrozenMiddlewarePlan>,
|
||||
cancellation: CancellationToken,
|
||||
request_body_limit: Option<usize>,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if !matches!(
|
||||
context.stage,
|
||||
gateway_plugin_sdk::Stage::Request
|
||||
| gateway_plugin_sdk::Stage::Attempt
|
||||
| gateway_plugin_sdk::Stage::Outbound
|
||||
| gateway_plugin_sdk::Stage::Routing
|
||||
| gateway_plugin_sdk::Stage::Scheduling
|
||||
| gateway_plugin_sdk::Stage::Retry
|
||||
) {
|
||||
return Err(denied());
|
||||
}
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
if let Some(effects) = effects {
|
||||
scope = scope.with_execution_effects(effects);
|
||||
}
|
||||
scope.extension_scope = extensions;
|
||||
scope.request_body_limit = request_body_limit;
|
||||
scope.origin = plan.map(|plan| InvocationContext {
|
||||
request_id: context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone()),
|
||||
call_id: context
|
||||
.request_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.resource_scope_id.clone()),
|
||||
cancellation,
|
||||
extensions: scope.extension_scope.clone(),
|
||||
effects: scope.execution_effects.clone(),
|
||||
plan: Some(plan),
|
||||
});
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) fn request_body_limit(&self) -> Result<usize, PluginFault> {
|
||||
self.request_limits.current_body_bytes()
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_upstream(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
managed: Arc<upstream::ManagedUpstream>,
|
||||
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
request_body_limit: usize,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::Upstream
|
||||
|| context.account_id.as_deref() != Some(managed.account.account_id().as_str())
|
||||
|| context.credential_revision != Some(managed.account.credential_revision().get())
|
||||
{
|
||||
return Err(denied());
|
||||
}
|
||||
let mut scope = CallbackScope::new(context, managed.account.outbound_proxy().cloned())
|
||||
.with_upstream(managed);
|
||||
scope.extension_scope = extension_scope;
|
||||
scope.request_body_limit = Some(request_body_limit);
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_management(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
proxy: Option<OutboundProxy>,
|
||||
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if !matches!(
|
||||
context.stage,
|
||||
gateway_plugin_sdk::Stage::Management | gateway_plugin_sdk::Stage::PublicManagement
|
||||
) {
|
||||
return Err(denied());
|
||||
}
|
||||
self.prepare_scope(context, proxy, request)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_command_line(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::CommandLine {
|
||||
return Err(denied());
|
||||
}
|
||||
self.prepare_scope(context, None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_frontend_authentication(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
request: &gateway_core::engine::authentication::ClientAuthenticationRequest,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::Authentication
|
||||
|| context.account_id.is_some()
|
||||
|| context.credential_revision.is_some()
|
||||
|| context.attempt_id.is_some()
|
||||
{
|
||||
return Err(denied());
|
||||
}
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
scope.settings = request.settings().cloned();
|
||||
if let Some(origin) = request.invocation() {
|
||||
scope.extension_scope = origin.extensions.clone();
|
||||
scope.execution_effects = origin.effects.clone();
|
||||
scope.origin = Some(origin.clone());
|
||||
} else if let Some(request) = request.shared_request_context() {
|
||||
scope.extension_scope = scope.extension_scope.with_request(request);
|
||||
}
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) async fn save_command_account(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
scope: &CallbackScope,
|
||||
request: gateway_plugin_sdk::call::host::AuthSaveRequest,
|
||||
) -> Result<gateway_plugin_sdk::call::host::AuthSaveResult, PluginFault> {
|
||||
if context.stage != gateway_plugin_sdk::Stage::CommandLine || !scope.authorizes(context) {
|
||||
return Err(denied());
|
||||
}
|
||||
self.accounts.save(context, scope, request).await
|
||||
}
|
||||
|
||||
fn prepare_scope(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
proxy: Option<OutboundProxy>,
|
||||
request: Option<Arc<gateway_core::engine::extensions::RequestContext>>,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
let mut scope = CallbackScope::new(context, proxy);
|
||||
if let Some(request) = request {
|
||||
scope.extension_scope = scope.extension_scope.with_request(request);
|
||||
}
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_extensions(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
extension_scope: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
let mut scope = CallbackScope::for_call(context);
|
||||
scope.extension_scope = extension_scope;
|
||||
self.register_scope(context, scope)
|
||||
}
|
||||
|
||||
pub(crate) fn bind_invocation(
|
||||
self: &Arc<Self>,
|
||||
call: &CallContext,
|
||||
origin: Option<InvocationContext>,
|
||||
extensions: gateway_core::engine::extensions::ExtensionCallScope,
|
||||
invocation: Arc<dyn MiddlewareCallback>,
|
||||
) -> Result<MiddlewareBinding, gateway_core::engine::middleware::MiddlewareError> {
|
||||
let mut scope = CallbackScope::for_call(call);
|
||||
scope.extension_scope = extensions;
|
||||
scope.origin = origin;
|
||||
let scope = self
|
||||
.register_scope(call, scope)
|
||||
.map_err(|_| gateway_core::engine::middleware::MiddlewareError::Fault)?;
|
||||
self.bind_middleware(call.resource_scope_id.clone(), invocation, scope)
|
||||
}
|
||||
|
||||
fn register_scope(
|
||||
&self,
|
||||
context: &CallContext,
|
||||
scope: CallbackScope,
|
||||
) -> Result<Arc<CallbackScope>, PluginFault> {
|
||||
let scope = Arc::new(scope);
|
||||
let mut scopes = self
|
||||
.scopes
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
scopes.retain(|_, scope| scope.strong_count() != 0);
|
||||
if scopes
|
||||
.get(&context.resource_scope_id)
|
||||
.and_then(Weak::upgrade)
|
||||
.is_some()
|
||||
{
|
||||
return Err(denied());
|
||||
}
|
||||
scopes.insert(context.resource_scope_id.clone(), Arc::downgrade(&scope));
|
||||
Ok(scope)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ impl PluginClientKeys {
|
||||
params: serde_json::Value,
|
||||
payload: &[u8],
|
||||
) -> Result<RpcReply, PluginFault> {
|
||||
if method == "host.keys.list" {
|
||||
if method == gateway_plugin_sdk::call::host::KEYS_LIST {
|
||||
return self.ports.list(params, payload).await;
|
||||
}
|
||||
if params != serde_json::json!({}) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user