Compare commits

...
19 Commits
Author SHA1 Message Date
ZyyandCodex 6a5300a7b5 chore(release): v3.32.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:34:39 +08:00
2c5b8b5c1a feat(openai): configure residency and account routing headers (#515)
Accept optional deployment strings for residency and account routing,
discard downstream values when unset, and isolate WebSocket connections
by the configured handshake headers.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 23:19:11 +08:00
0a87ac7f76 fix(plugins)!: honor declared stage call budgets (#513)
Resolve scenario defaults and plugin declarations consistently across the SDK
and runtime. Preserve maintenance across unrelated publications and cancel
long preparation when the runtime shuts down.

BREAKING CHANGE: Plugin RPC requires protocol v6. Rebuild plugins with the
updated SDK; previous protocol versions are not supported.

Co-authored-by: Zyy <zyycn@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:39:23 +08:00
ZyyandCodex 3ac9d5f6f8 ci: keep repository checks independent
Remove automatic cross-repository source integration from host CI while
preserving host checks against locked dependencies. Keep source integration
available for explicit local version combinations and align the documentation.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 22:34:48 +08:00
ZyyandCodex e7e5b659cd chore(release): v3.31.3
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:31:19 +08:00
ZyyandCodex b225b3732d fix(accounts): unify Codex labels and tighten plan badge spacing
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 17:28:06 +08:00
ZyyandCodex 9dca763ea4 fix(ui): polish plugin management and API key labels
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 16:57:42 +08:00
ZyyandCodex d26bc36db5 chore(release): v3.31.2
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:42 +08:00
ZyyandCodex 96d5ab674a feat(ui): refine forms, navigation and theme loading
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 15:59:34 +08:00
ZyyandCodex 84c0db2613 fix(ui): simplify token label and center reasoning effort
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:58:06 +08:00
ZyyandCodex d819315d8b fix(ci): allow independent UI dependency versions
Keep frozen installs, source compatibility builds, and lockfile checks
without requiring every consumer to pin the UI submodule revision.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:48:28 +08:00
ZyyandCodex 06e1d8f0ac chore(release): v3.31.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:40:32 +08:00
ZyyandCodex bdc8274b36 chore(ui): upgrade component library to v0.6.1
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:38:49 +08:00
ZyyandCodex 3f6607209b refactor(usage): remove reported model tracking and polish UI
Remove upstream response model tracking across providers, storage, APIs,
and plugin facts without migrating historical records.

Refine sidebar hints, locale loading indicators, model columns, and usage
page labels and filters.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 04:03:27 +08:00
ZyyandCodex 3c05bc8f07 refactor(plugins): remove provider-specific request parameters
Use request, attempt, and outbound middleware for plugin-owned identity
selection and complete request rewriting. Preserve native account identity
and partition WebSocket connections through existing outbound opening changes.

Remove the construction parameter channel and its obsolete tests and docs.
Keep middleware at v6 without a compatibility shim.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 03:30:52 +08:00
ZyyandCodex 9ccd84f1a0 test(store): fix pg_dump lifecycle probe on Ubuntu 26.04
Redirect the mock process stdout to /dev/null explicitly so the wait-stage probe works when sleep reopens closed standard descriptors. Preserve cancellation, process reaping, and partial archive cleanup assertions.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:35:59 +08:00
ZyyandCodex a8f183707e chore(build): refresh base images and pin Ubuntu runners
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 01:02:46 +08:00
ZyyandCodex 3f20af61ff chore(plugins): update workbench to v0.7.0
Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:23:55 +08:00
ZyyandCodex efaed4c3a6 fix(web): refine health loading and quota labels
Hide request health granularity until analytics are loaded and remove the placeholder daily granularity. Use compact uppercase quota badges and localized five-hour labels.

Co-authored-by: Codex <noreply@openai.com>
2026-10-25 00:08:42 +08:00
231 changed files with 2423 additions and 2122 deletions
+1 -1
View File
@@ -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 }}
+8 -5
View File
@@ -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: >-
@@ -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
+2 -77
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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
+1 -1
View File
@@ -18,7 +18,7 @@ concurrency:
jobs:
guard:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 5
steps:
# 只查询 PR 元数据,禁止检出或执行贡献分支中的代码。
+2 -2
View File
@@ -19,7 +19,7 @@ concurrency:
jobs:
backend-security:
runs-on: ubuntu-latest
runs-on: ubuntu-26.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -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
View File
@@ -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#验证命令)
+21 -3
View File
@@ -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>,
@@ -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),
@@ -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,
@@ -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",
@@ -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),
@@ -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),
@@ -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
@@ -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
@@ -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 与重试保留
@@ -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 {
-17
View File
@@ -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;
@@ -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();
@@ -2282,7 +2282,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 +2614,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(),
@@ -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);
@@ -1,7 +1,7 @@
{
"schema_version": 2,
"manifest_schema_versions": [2],
"protocol_versions": [5],
"protocol_versions": [6],
"capabilities": [
{ "capability": "frontend_authentication", "versions": [2] },
{ "capability": "scheduler", "versions": [3] },
@@ -1,7 +1,5 @@
//! 编译插件模型目录贡献并校验能力绑定
use std::time::Duration;
use gateway_admin::model::{
AdminError,
plugins::instances::{PluginCapabilityBinding, PluginInstance},
@@ -23,7 +21,7 @@ pub(crate) async fn prepare(
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(),
)
@@ -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,7 +192,8 @@ 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());
let _scope = callbacks
.prepare_frontend_authentication(&context, request.shared_request_context())
@@ -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;
@@ -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
@@ -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
@@ -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!(
@@ -167,7 +167,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,
@@ -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![],
)
@@ -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,
@@ -135,15 +135,6 @@ pub(crate) enum MiddlewareCompletionBody {
}
impl MiddlewareInvocation {
pub(crate) fn provider_parameters(&self) -> Option<serde_json::Map<String, serde_json::Value>> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.original_request
.provider_parameters()
.cloned()
}
pub(crate) fn settings(&self) -> Result<serde_json::Value, PluginFault> {
let state = self
.state
@@ -343,12 +334,6 @@ impl MiddlewareInvocation {
request: MiddlewareNextRequest,
payload: Vec<u8>,
) -> Result<MiddlewareRequest, PluginFault> {
if let Some(parameters) = request.provider_parameters {
if original.provider_parameters().is_none() {
return Err(invalid());
}
original = original.with_provider_parameters(parameters);
}
if let Some(settings) = request.settings {
let current = original.settings().ok_or_else(|| {
PluginFault::new(
@@ -16,18 +16,22 @@ use gateway_admin::{
use gateway_plugin_sdk::{Handshake, Stage, call::registration::Registration};
use std::{sync::Arc, time::Duration};
const PROCESS_PREPARATION_TIMEOUT: Duration = Duration::from_secs(30);
struct StartedInstance {
instance: PluginInstance,
package: Arc<crate::PreparedPackage>,
callbacks: Arc<PluginCallbacks>,
prepared: PreparedInstance,
}
impl PluginRuntime {
pub(super) async fn prepare_instance(
async fn start_instance(
&self,
instance: PluginInstance,
target_revision: Revision,
request_body_limit: Option<usize>,
) -> Result<Arc<PreparedInstance>, AdminError> {
let mut observer_entries = Vec::new();
let mut commands = Vec::new();
let mut management = Vec::new();
let mut policy_entries = Vec::new();
let mut authentication_entries = Vec::new();
) -> Result<StartedInstance, AdminError> {
let authorization_binding = instance_fingerprint(&instance)?;
if self.restart_circuit_is_open(&RestartIdentity::new(
instance.id.clone(),
@@ -109,7 +113,6 @@ impl PluginRuntime {
crate::compatibility::report(&manifest, &instance_id)?;
let restart_identity =
RestartIdentity::new(instance_id.clone(), authorization_binding.clone());
let bindings = instance.bindings.clone();
let owner = self
.state
.load_owner(
@@ -181,23 +184,69 @@ impl PluginRuntime {
lifecycle,
)
.await
.map_err(|_| AdminError::unavailable("插件进程握手失败"))?,
.map_err(|error| match error {
crate::RpcError::Budget(gateway_plugin_sdk::CallBudgetError::Invalid {
stage,
milliseconds,
}) => AdminError::invalid(format!(
"插件阶段 {stage:?} 的调用预算无效:{milliseconds} 毫秒"
)),
_ => AdminError::unavailable("插件进程握手失败"),
})?,
);
let mut prepared = PreparedInstance {
let prepared = PreparedInstance {
instance_id: instance_id.clone(),
artifact_sha256: instance.artifact_sha256.clone(),
revision: instance.revision,
session: session.clone(),
session,
private_state: private_state.clone(),
maintenance: manifest
.contributes
.contains_key(&gateway_plugin_sdk::Capability::Maintenance),
contributions: PreparedContributions::default(),
};
Ok(StartedInstance {
instance,
package,
callbacks,
prepared,
})
}
pub(super) async fn prepare_instance(
&self,
instance: PluginInstance,
target_revision: Revision,
request_body_limit: Option<usize>,
) -> Result<Arc<PreparedInstance>, AdminError> {
// 启动控制期限只包住制品准备与握手,Ready 后每次注册使用协商预算
let StartedInstance {
instance,
package,
callbacks,
mut prepared,
} = tokio::time::timeout(
PROCESS_PREPARATION_TIMEOUT,
self.start_instance(instance, target_revision, request_body_limit),
)
.await
.map_err(|_| AdminError::unavailable("插件启动超时"))??;
let session = &prepared.session;
let manifest = package.package().manifest();
let plugin_id = manifest
.plugin_id()
.map_err(|_| AdminError::invalid("插件身份无效"))?;
let instance_id = &instance.id;
let bindings = &instance.bindings;
let mut observer_entries = Vec::new();
let mut commands = Vec::new();
let mut management = Vec::new();
let mut policy_entries = Vec::new();
let mut authentication_entries = Vec::new();
let registration = session
.call(
"plugin.register",
session.context(Stage::Registration, Duration::from_secs(5)),
session.context(Stage::Registration, session.budget(Stage::Registration)),
serde_json::json!({}),
vec![],
)
@@ -211,7 +260,7 @@ impl PluginRuntime {
commands.extend(
crate::adapter::command_line::prepare(
&instance,
&manifest,
manifest,
session.clone(),
callbacks.clone(),
)
@@ -228,8 +277,8 @@ impl PluginRuntime {
management.push(entry);
}
if let Some(entry) = crate::adapter::frontend_authentication::prepare_entry(
&manifest,
&bindings,
manifest,
bindings,
session.clone(),
callbacks.clone(),
)
@@ -238,35 +287,34 @@ impl PluginRuntime {
authentication_entries.push(entry);
}
if let Some(entry) = crate::adapter::observer::compile_entry(
&manifest,
manifest,
&plugin_id,
&instance_id,
&bindings,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)? {
observer_entries.push(entry);
}
policy_entries.extend(crate::adapter::policy::compile_entries(
&manifest,
&instance_id,
&bindings,
manifest,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)?);
let middleware_entries = crate::adapter::middleware::compile_entries(
&manifest,
&instance_id,
&bindings,
manifest,
instance_id,
bindings,
session.clone(),
callbacks.clone(),
)?;
let model_aliases =
crate::adapter::catalog::prepare(&manifest, &instance, &session).await?;
let model_aliases = crate::adapter::catalog::prepare(manifest, &instance, session).await?;
let upstream_entries = crate::adapter::upstream_adapter::prepare(
&manifest,
manifest,
&instance,
Arc::clone(&session),
Arc::clone(session),
Arc::clone(&callbacks),
)
.await?;
@@ -1,8 +1,11 @@
//! 对账只从已发布集合开始;通知合并,实例串行,失败和停用不阻塞其他实例
use super::{PluginRuntime, set::PreparedSet};
use super::{
PluginRuntime,
set::{PreparedInstance, PreparedSet},
};
use crate::RpcSession;
use futures::{StreamExt as _, future::BoxFuture};
use futures::{StreamExt as _, future::BoxFuture, stream::FuturesUnordered};
use gateway_core::{
lifecycle::CancellationToken,
runtime::RuntimeSnapshotHandle,
@@ -13,12 +16,51 @@ use gateway_core::{
},
};
use gateway_plugin_sdk::Stage;
use std::{sync::Arc, time::Duration};
use tokio::{sync::Notify, task::JoinSet};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
use tokio::{sync::Notify, task::JoinHandle};
const RECONCILE_INTERVAL: Duration = Duration::from_secs(30);
const RETRY_DELAY: Duration = Duration::from_secs(5);
const CALL_TIMEOUT: Duration = Duration::from_secs(30);
struct InstanceTask {
instance: Arc<PreparedInstance>,
dirty: Arc<Notify>,
cancellation: CancellationToken,
task: JoinHandle<()>,
}
impl InstanceTask {
fn start(instance: Arc<PreparedInstance>) -> Self {
let dirty = Arc::new(Notify::new());
dirty.notify_one();
let cancellation = CancellationToken::new();
let task = tokio::spawn(reconcile(
Arc::clone(&instance.session),
Arc::clone(&dirty),
cancellation.clone(),
));
Self {
instance,
dirty,
cancellation,
task,
}
}
async fn stop(mut self) {
self.cancellation.cancel();
let _ = (&mut self.task).await;
// 宿主资源在取消时同步撤销,替换任务还须等对端确认或进程退出
self.instance.session.settle_cancellations().await;
}
}
impl Drop for InstanceTask {
fn drop(&mut self) {
self.cancellation.cancel();
self.task.abort();
}
}
struct MaintenanceTask {
runtime: Arc<PluginRuntime>,
@@ -59,18 +101,12 @@ impl DaemonTask for MaintenanceTask {
let mut publications = self.snapshots.subscribe_publications();
let mut active: Option<Arc<PreparedSet>> = None;
let mut revision = None;
let mut stop = CancellationToken::new();
let mut tasks = JoinSet::new();
let mut notifications = Vec::new();
let mut tasks = BTreeMap::<String, InstanceTask>::new();
loop {
let snapshot = self.snapshots.acquire().ok();
let reference = snapshot.as_ref().and_then(|s| s.extensions());
let same_set = active.as_ref().map(|s| &s.id) == reference.map(|s| s.id());
if !same_set {
stop.cancel();
while tasks.join_next().await.is_some() {}
notifications.clear();
stop = CancellationToken::new();
active = match reference {
Some(reference) => {
Some(self.runtime.prepared_set(reference).await.map_err(|_| {
@@ -79,17 +115,37 @@ impl DaemonTask for MaintenanceTask {
}
None => None,
};
if let Some(set) = &active {
for instance in set.sessions.iter().filter(|i| i.maintenance) {
let dirty = Arc::new(Notify::new());
dirty.notify_one();
notifications.push(dirty.clone());
tasks.spawn(reconcile(instance.session.clone(), dirty, stop.clone()));
}
let desired = active
.iter()
.flat_map(|set| &set.sessions)
.filter(|instance| instance.maintenance)
.map(|instance| (instance.instance_id.clone(), Arc::clone(instance)))
.collect::<BTreeMap<_, _>>();
let retired = tasks
.extract_if(.., |id, task| {
// 发布层只复用完全相同且就绪的实例,Arc 身份也包含会话 incarnation
!desired
.get(id)
.is_some_and(|instance| Arc::ptr_eq(instance, &task.instance))
})
.map(|(_, task)| task)
.collect::<Vec<_>>();
for task in &retired {
task.cancellation.cancel();
}
} else if revision != snapshot.as_ref().map(|s| s.revision()) {
for dirty in &notifications {
dirty.notify_one();
futures::future::join_all(retired.into_iter().map(InstanceTask::stop)).await;
for task in tasks.values() {
task.dirty.notify_one();
}
for (id, instance) in desired {
tasks
.entry(id)
.or_insert_with(|| InstanceTask::start(instance));
}
}
if same_set && revision != snapshot.as_ref().map(|s| s.revision()) {
for task in tasks.values() {
task.dirty.notify_one();
}
}
revision = snapshot.as_ref().map(|s| s.revision());
@@ -101,22 +157,39 @@ impl DaemonTask for MaintenanceTask {
biased;
() = cancellation.cancelled() => break,
changed = publications.next() => if changed.is_none() { break; },
result = tasks.join_next(), if !tasks.is_empty() => {
if result.is_some() {
stop.cancel();
while tasks.join_next().await.is_some() {}
result = next_finished(&mut tasks), if !tasks.is_empty() => {
if let Some(instance_id) = result {
tasks.remove(&instance_id);
stop_all(tasks).await;
return Err(WorkerTaskError::safe("plugin maintenance task stopped unexpectedly"));
}
}
}
}
stop.cancel();
while tasks.join_next().await.is_some() {}
stop_all(tasks).await;
Ok(())
})
}
}
async fn next_finished(tasks: &mut BTreeMap<String, InstanceTask>) -> Option<String> {
let mut pending = tasks
.iter_mut()
.map(|(id, task)| async move {
let _ = (&mut task.task).await;
id.clone()
})
.collect::<FuturesUnordered<_>>();
pending.next().await
}
async fn stop_all(tasks: BTreeMap<String, InstanceTask>) {
for task in tasks.values() {
task.cancellation.cancel();
}
futures::future::join_all(tasks.into_values().map(InstanceTask::stop)).await;
}
async fn reconcile(session: Arc<RpcSession>, dirty: Arc<Notify>, cancellation: CancellationToken) {
loop {
tokio::select! {
@@ -125,21 +198,38 @@ async fn reconcile(session: Arc<RpcSession>, dirty: Arc<Notify>, cancellation: C
() = dirty.notified() => {},
() = tokio::time::sleep(RECONCILE_INTERVAL) => {},
}
let context = session.context(Stage::Maintenance, CALL_TIMEOUT);
let context = session.context(Stage::Maintenance, session.budget(Stage::Maintenance));
let instance_id = context.instance_id.clone();
let resource_scope_id = context.resource_scope_id.clone();
let budget_ms = context.timeout_ms;
let started = tokio::time::Instant::now();
let result = tokio::select! {
biased;
() = cancellation.cancelled() => return,
() = cancellation.cancelled() => {
tracing::info!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = "cancelled", "plugin maintenance ended");
return;
},
result = session.call("plugin.reconcile", context, serde_json::json!({}), Vec::new()) => result,
};
if result.is_err() {
tracing::warn!(%instance_id, "plugin maintenance failed; retrying after bounded delay");
if let Err(error) = result {
tracing::warn!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = error.diagnostic().0, "plugin maintenance failed; retrying after bounded delay");
tokio::select! {
biased;
() = cancellation.cancelled() => return,
() = tokio::time::sleep(RETRY_DELAY) => {},
}
dirty.notify_one();
} else {
tracing::info!(target: "plugin.maintenance", %instance_id, %resource_scope_id, budget_ms,
budget_source = ?session.budget_source(Stage::Maintenance),
elapsed_ms = started.elapsed().as_millis() as u64,
reason = "completed", "plugin maintenance ended");
}
}
}
@@ -27,15 +27,13 @@ use gateway_core::{
observation::RequestObserverExtensionIndex,
policy::RequestPolicyExtensionIndex,
},
lifecycle::CancellationToken,
provider_ports::OAuthPendingFlowPort,
};
use std::{
collections::BTreeMap,
path::PathBuf,
sync::{
Arc, Mutex as SyncMutex, Weak,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, Mutex as SyncMutex, Weak},
time::Duration,
};
use tokio::sync::{Mutex, Semaphore};
@@ -62,7 +60,7 @@ pub struct PluginRuntime {
pub(super) prepared_instances: Mutex<BTreeMap<String, Weak<PreparedInstance>>>,
pub(super) preparation_diagnostics: SyncMutex<BTreeMap<u64, PreparationDiagnostic>>,
pub(super) restart_circuits: Arc<RestartCircuits>,
pub(super) shutting_down: Arc<AtomicBool>,
pub(super) shutting_down: CancellationToken,
shutdown_lock: Mutex<()>,
pub(super) processes: Arc<gateway_host::process::ProcessSupervisor>,
pub(super) validators: Arc<Semaphore>,
@@ -105,7 +103,7 @@ impl PluginRuntime {
prepared_instances: Mutex::new(BTreeMap::new()),
preparation_diagnostics: SyncMutex::new(BTreeMap::new()),
restart_circuits: RestartCircuits::new(restart_circuit),
shutting_down: Arc::new(AtomicBool::new(false)),
shutting_down: CancellationToken::new(),
shutdown_lock: Mutex::new(()),
processes,
validators: Arc::new(Semaphore::new(MAXIMUM_PARALLEL_PREPARATIONS)),
@@ -182,7 +180,7 @@ impl PluginRuntime {
/// 调用方必须先停止接收 HTTP 与 Worker;这里拒绝新候选并等待全部插件 I/O 退出
pub async fn shutdown(&self) {
self.shutting_down.store(true, Ordering::Release);
self.shutting_down.cancel();
let _shutdown = self.shutdown_lock.lock().await;
let _prepare = self.prepare_lock.lock().await;
let sets = {
@@ -10,18 +10,12 @@ use gateway_core::{
extensions::ExecutionExtensionPlans, observation::RequestObserverPlan,
policy::RequestPolicyPlan,
},
lifecycle::CancellationToken,
routing::extensions::{ExtensionSetId, ExtensionSetLease},
};
use secrecy::ExposeSecret as _;
use sha2::{Digest as _, Sha256};
use std::{
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use std::{collections::BTreeMap, sync::Arc, time::Duration};
pub(super) struct PreparedSet {
pub(super) id: ExtensionSetId,
@@ -32,7 +26,7 @@ pub(super) struct PreparedSet {
Option<Arc<dyn gateway_core::engine::authentication::FrontendAuthenticationPlan>>,
pub(super) sessions: Vec<Arc<PreparedInstance>>,
pub(super) failures: BTreeMap<String, PluginInstanceRuntimeFailure>,
pub(super) shutting_down: Arc<AtomicBool>,
pub(super) shutting_down: CancellationToken,
pub(super) commands: Vec<Arc<crate::adapter::command_line::PluginCommand>>,
pub(super) management: Vec<crate::adapter::management::ManagementEntry>,
pub(super) model_aliases: Vec<gateway_core::routing::ContributedModelAlias>,
@@ -94,7 +88,7 @@ impl ExtensionSetLease for PreparedSet {
fn can_serve(&self) -> bool {
// 发布的是可用能力和故障绑定组成的完整计划;单个进程退出不能使原生转发失效
!self.shutting_down.load(Ordering::Acquire)
!self.shutting_down.is_cancelled()
}
}
@@ -26,8 +26,7 @@ use gateway_core::{
};
use std::{
collections::{BTreeMap, BTreeSet},
sync::{Arc, Weak, atomic::Ordering},
time::Duration,
sync::{Arc, Weak},
};
impl PluginRuntime {
@@ -39,9 +38,11 @@ impl PluginRuntime {
) -> Result<ExtensionSetReference, AdminError> {
let target_revision = snapshot.config_revision.get();
self.record_preparation(target_revision, PreparationDiagnostic::Preparing);
let result = self
.prepare_inner(snapshot, required_revision, request_body_limit)
.await;
let result = tokio::select! {
biased;
() = self.shutting_down.cancelled() => Err(AdminError::unavailable("插件运行时正在关闭")),
result = self.prepare_inner(snapshot, required_revision, request_body_limit) => result,
};
match &result {
Ok(reference) => self.record_preparation(
target_revision,
@@ -63,7 +64,7 @@ impl PluginRuntime {
required_revision: Option<Revision>,
request_body_limit: Option<usize>,
) -> Result<ExtensionSetReference, AdminError> {
if self.shutting_down.load(Ordering::Acquire) {
if self.shutting_down.is_cancelled() {
return Err(AdminError::unavailable("插件运行时正在关闭"));
}
if snapshot.instances.len() > 64 {
@@ -72,7 +73,7 @@ impl PluginRuntime {
let fingerprint = fingerprint(&snapshot)?;
// 准备串行化与弱索引分锁;诊断和已发布请求不会等待外部 I/O
let _prepare = self.prepare_lock.lock().await;
if self.shutting_down.load(Ordering::Acquire) {
if self.shutting_down.is_cancelled() {
return Err(AdminError::unavailable("插件运行时正在关闭"));
}
let existing = {
@@ -125,17 +126,12 @@ impl PluginRuntime {
let result = if let Some(instance) = reusable {
Ok(instance)
} else {
tokio::time::timeout(
// 沿用准备阶段原有的 30 秒预算;打包校验与能力恢复也在预算内
Duration::from_secs(30),
self.prepare_instance(
instance.clone(),
snapshot.config_revision,
request_body_limit,
),
self.prepare_instance(
instance.clone(),
snapshot.config_revision,
request_body_limit,
)
.await
.unwrap_or_else(|_| Err(AdminError::unavailable("插件启动超时")))
};
(instance, identity, result)
},
@@ -208,7 +204,6 @@ impl PluginRuntime {
.rpc_limits
.maximum_calls
.min(self.config.rpc_limits.maximum_executing_calls),
self.config.rpc_limits.maximum_call_timeout,
self.config.rpc_limits.maximum_buffered_body_bytes,
)
.map(|plan| {
@@ -217,21 +212,16 @@ impl PluginRuntime {
.map_err(|_| AdminError::invalid("插件观察计划注册冲突"))
})
.transpose()?;
let policies = crate::adapter::policy::PluginRequestPolicyPlan::compile(
policy_entries,
self.config.rpc_limits.maximum_call_timeout,
)?
.map(|plan| {
self.policies
.register(id.clone(), plan)
.map_err(|_| AdminError::invalid("插件请求策略计划注册冲突"))
})
.transpose()?;
let middleware = crate::adapter::middleware::PluginMiddlewarePlan::compile(
middleware_entries,
self.config.rpc_limits.maximum_call_timeout,
)
.map(|plan| plan as Arc<dyn gateway_core::engine::middleware::MiddlewarePlan>);
let policies = crate::adapter::policy::PluginRequestPolicyPlan::compile(policy_entries)?
.map(|plan| {
self.policies
.register(id.clone(), plan)
.map_err(|_| AdminError::invalid("插件请求策略计划注册冲突"))
})
.transpose()?;
let middleware =
crate::adapter::middleware::PluginMiddlewarePlan::compile(middleware_entries)
.map(|plan| plan as Arc<dyn gateway_core::engine::middleware::MiddlewarePlan>);
let upstream_adapters =
crate::adapter::upstream_adapter::PluginUpstreamAdapterPlan::compile(upstream_entries);
let execution = if middleware.is_some() || upstream_adapters.is_some() {
@@ -252,7 +242,6 @@ impl PluginRuntime {
let authentication =
crate::adapter::frontend_authentication::PluginFrontendAuthenticationPlan::compile(
authentication_entries,
self.config.rpc_limits.maximum_call_timeout,
)?
.map(|plan| {
self.authentications
@@ -90,9 +90,10 @@ impl PluginRuntime {
.session
.call(
"plugin.state.migrate",
instance
.session
.context(Stage::Configuration, Duration::from_secs(30)),
instance.session.context(
Stage::Configuration,
instance.session.budget(Stage::Configuration),
),
serde_json::json!({}),
serde_json::to_vec(&request)
.map_err(|_| AdminError::internal("状态迁移批次无法编码"))?,
@@ -207,7 +208,11 @@ impl gateway_admin::ports::plugins::PluginStateLifecycle for PluginRuntime {
prepared: &ExtensionSetReference,
transition: PluginStateTransition,
) -> Result<(), AdminError> {
self.run_state_migration(prepared, transition).await
tokio::select! {
biased;
() = self.shutting_down.cancelled() => Err(AdminError::unavailable("插件运行时正在关闭")),
result = self.run_state_migration(prepared, transition) => result,
}
}
}
pub(super) fn map_state_admin_error(
@@ -12,10 +12,13 @@ use std::{
use futures::future::BoxFuture;
use gateway_host::process::{ProcessControl, ProcessSpec, ProcessStartError, ProcessSupervisor};
use gateway_plugin_sdk::{
CallContext, Frame, Handshake, Message, PROTOCOL_VERSION, PluginFault, Stage,
CallBudgetError, CallBudgetSource, CallContext, Frame, Handshake, Message, PROTOCOL_VERSION,
PluginFault, ResolvedCallBudgets, Stage,
client::{CallDeadline, CallDeadlinePause, EncodedFrame, read_frame, write_frame},
};
use tokio::sync::{Mutex as AsyncMutex, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch};
use tokio::sync::{
Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore, mpsc, oneshot, watch,
};
use crate::{PreparedPackage, RpcStream, stream::StreamIngress};
@@ -32,7 +35,6 @@ pub struct RpcLimits {
pub maximum_queue_timeout: Duration,
pub maximum_callbacks: usize,
pub handshake_timeout: Duration,
pub maximum_call_timeout: Duration,
pub maximum_stderr_bytes: usize,
}
@@ -46,7 +48,6 @@ impl Default for RpcLimits {
maximum_queue_timeout: Duration::from_secs(30),
maximum_callbacks: 64,
handshake_timeout: Duration::from_secs(5),
maximum_call_timeout: Duration::from_secs(120),
maximum_stderr_bytes: 64 * 1024,
}
}
@@ -58,6 +59,8 @@ pub enum RpcError {
Start(#[source] ProcessStartError),
#[error("plugin handshake does not match the prepared instance")]
Handshake(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin call budget negotiation failed: {0}")]
Budget(#[from] CallBudgetError),
#[error("plugin protocol is invalid")]
Protocol(#[source] Option<gateway_core::error::ErrorSource>),
#[error("plugin returned an invalid response at {0:?}")]
@@ -89,6 +92,7 @@ impl RpcError {
match self {
RpcError::Start(_) => ("process_start", "插件进程启动失败"),
RpcError::Handshake(_) => ("handshake", "插件握手失败"),
RpcError::Budget(_) => ("call_budget", "插件调用预算协商失败"),
RpcError::Protocol(_) => ("protocol", "插件协议错误"),
RpcError::InvalidResponse(stage, _) => (
"invalid_response",
@@ -191,6 +195,7 @@ pub trait CallbackHandler: Send + Sync {
pub struct RpcSession {
shared: Arc<Shared>,
handshake: Handshake,
budgets: ResolvedCallBudgets,
data: mpsc::Sender<EncodedFrame>,
slots: Arc<Semaphore>,
next_call: AsyncMutex<u64>,
@@ -222,6 +227,7 @@ pub(crate) struct Shared {
callback_slots: Arc<Semaphore>,
next_slots: Arc<Semaphore>,
execution: ExecutionCapacity,
cancellations_changed: Notify,
}
struct State {
@@ -295,6 +301,7 @@ impl Shared {
}
self.stopped.send_replace(true);
self.process.stop();
self.cancellations_changed.notify_waiters();
}
fn expect_stop(&self) {
@@ -512,6 +519,8 @@ impl Shared {
return Err(RpcError::Protocol(None));
}
state.calls.remove(&id);
drop(state);
self.cancellations_changed.notify_waiters();
Ok(())
}
@@ -568,8 +577,36 @@ impl Shared {
}
impl RpcSession {
pub(crate) const fn maximum_call_timeout(&self) -> Duration {
self.limits.maximum_call_timeout
pub fn budget(&self, stage: Stage) -> Duration {
self.budgets.budget(stage)
}
#[must_use]
pub(crate) fn budget_source(&self, stage: Stage) -> CallBudgetSource {
self.budgets.source(stage)
}
/// 替换维护任务前等待已取消的调用确认;未确认仍沿用会话的有界故障回收
pub(crate) async fn settle_cancellations(&self) {
let ids: Vec<_> = self
.shared
.state()
.calls
.iter()
.filter_map(|(id, call)| call.cancellation.is_some().then_some(*id))
.collect();
loop {
let changed = self.shared.cancellations_changed.notified();
tokio::pin!(changed);
changed.as_mut().enable();
if !ids.iter().any(|id| self.shared.is_cancelling(*id)) {
break;
}
changed.await;
}
if !self.is_ready() {
self.shared.process.exited().await;
}
}
pub async fn start(
@@ -620,7 +657,6 @@ impl RpcSession {
|| limits.maximum_buffered_body_bytes < 1024
|| limits.maximum_buffered_body_bytes > 16 * 1024 * 1024
|| limits.handshake_timeout.is_zero()
|| limits.maximum_call_timeout.is_zero()
{
return Err(RpcError::Context(None));
}
@@ -691,11 +727,12 @@ impl RpcSession {
Message::Ready {
protocol_version,
incarnation,
call_budgets,
} if protocol_version == handshake.protocol_version
&& incarnation == handshake.incarnation
&& reply.payload.is_empty() =>
{
Ok(())
ResolvedCallBudgets::resolve(&call_budgets).map_err(RpcError::Budget)
}
_ => Err(RpcError::Handshake(None)),
}
@@ -703,15 +740,18 @@ impl RpcSession {
.await
.map_err(|_| RpcError::Timeout)
.and_then(|result| result);
if let Err(error) = ready {
if let Some(lifecycle) = &lifecycle {
// 只有 Ready 之后的存活才可复位预算;长时间卡在握手不等于稳定运行
lifecycle.fail(Duration::ZERO);
let budgets = match ready {
Ok(budgets) => budgets,
Err(error) => {
if let Some(lifecycle) = &lifecycle {
// 只有 Ready 之后的存活才可复位预算;长时间卡在握手不等于稳定运行
lifecycle.fail(Duration::ZERO);
}
process.stop();
process.exited().await;
return Err(error);
}
process.stop();
process.exited().await;
return Err(error);
}
};
let (data, data_receiver) = mpsc::channel(limits.maximum_calls);
let (control, control_receiver) =
mpsc::channel(limits.maximum_callbacks + 2 * limits.maximum_calls + 8);
@@ -737,6 +777,7 @@ impl RpcSession {
limits.maximum_queued_calls,
limits.maximum_queue_timeout,
),
cancellations_changed: Notify::new(),
});
dispatch::start(
input,
@@ -760,6 +801,7 @@ impl RpcSession {
Ok(Self {
shared,
handshake,
budgets,
data,
slots: Arc::new(Semaphore::new(limits.maximum_calls)),
next_call: AsyncMutex::new(1),
@@ -799,8 +841,8 @@ impl RpcSession {
#[must_use]
pub fn context(&self, stage: gateway_plugin_sdk::Stage, timeout: Duration) -> CallContext {
// 宿主生成的上下文受本会话预算约束;固定注册期限不能使较小的总预算无法启动
let timeout = timeout.min(self.limits.maximum_call_timeout);
// 调用方可以传入更短的外部剩余期限,不能扩大实例已协商的阶段预算
let timeout = timeout.min(self.budget(stage));
CallContext {
request: None,
call_id: 0,
@@ -823,7 +865,7 @@ impl RpcSession {
|| context.generation != self.handshake.generation
|| context.incarnation != self.handshake.incarnation
|| context.timeout_ms == 0
|| Duration::from_millis(context.timeout_ms) > self.limits.maximum_call_timeout
|| Duration::from_millis(context.timeout_ms) > self.budget(context.stage)
|| method.is_empty()
|| method.len() > 128
{
@@ -6,6 +6,120 @@ use serde_json::json;
use crate::support::environment::Environment;
#[derive(Default)]
struct BudgetAccountAccess {
calls: std::sync::atomic::AtomicUsize,
first_started: tokio::sync::Notify,
second_started: tokio::sync::Notify,
}
#[async_trait::async_trait]
impl gateway_admin::ports::plugin_accounts::PluginAccountAccess for BudgetAccountAccess {
async fn list(
&self,
_: gateway_admin::model::provider_credentials::PluginAccountListQuery,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountPage,
gateway_admin::model::AdminError,
> {
panic!("账号提交不应读取账号列表")
}
async fn get_runtime(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> Result<gateway_admin::model::accounts::AccountRecord, gateway_admin::model::AdminError>
{
panic!("创建账号不应读取已有账号")
}
async fn get_credential(
&self,
_: &gateway_core::account::ProviderAccountId,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountCredential,
gateway_admin::model::AdminError,
> {
panic!("账号提交不应读取凭据")
}
async fn save(
&self,
_: gateway_admin::model::provider_credentials::PreparedPluginAccountSave,
_: &gateway_admin::model::MutationContext,
) -> Result<
gateway_admin::model::provider_credentials::PluginAccountSaveResult,
gateway_admin::model::AdminError,
> {
if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
self.first_started.notify_one();
tokio::time::sleep(std::time::Duration::from_secs(70)).await;
Ok(
gateway_admin::model::provider_credentials::PluginAccountSaveResult {
config_revision: gateway_admin::model::Revision::new(2).unwrap(),
account_id: gateway_core::account::ProviderAccountId::new(
"acct_command_budget",
)
.unwrap(),
credential_revision: gateway_admin::model::Revision::new(1).unwrap(),
},
)
} else {
self.second_started.notify_one();
std::future::pending().await
}
}
}
#[tokio::test]
async fn account_commits_share_the_commands_declared_deadline_and_report_partial_completion() {
use gateway_admin::ports::plugin_accounts::PluginAccountAccess;
use std::{sync::Arc, time::Duration};
let account = json!({"action":"create","provider_id":"openai","facts":{
"name":"预算测试","authentication_kind":"api_key","material":{"key":"fixture-only"}
}});
let config = json!({
"call_budgets_ms":{"command_line":180_000},
"command_registration":{"commands":[{"name":"save","description":"提交账号"}]},
"command_result":{"stdout":"","stderr":"","exit_code":0,"accounts":[account, account]},
});
let (_directory, _store, runtime) = super::setup(config).await;
let probe = Arc::new(BudgetAccountAccess::default());
let accounts: Arc<dyn PluginAccountAccess> = probe.clone();
runtime.bind_account_ports(&accounts).unwrap();
let commands = runtime.prepare_command_line().await.unwrap();
let task = tokio::spawn(async move {
let result = commands.execute("commands", "save", &[]).await;
(commands, result)
});
tokio::time::timeout(Duration::from_secs(5), probe.first_started.notified())
.await
.unwrap();
tokio::time::pause();
tokio::time::advance(Duration::from_secs(121)).await;
probe.second_started.notified().await;
assert!(
!task.is_finished(),
"账号提交沿用声明预算,不能被默认期限截断"
);
tokio::time::advance(Duration::from_secs(60)).await;
let (commands, result) = task.await.unwrap();
tokio::time::resume();
assert!(
matches!(
result,
Err(PluginCommandError::Incomplete {
saved_accounts: 1,
reason: "超过命令总期限",
})
),
"后续账号提交不能重新获得完整预算"
);
commands.shutdown().await;
}
#[tokio::test]
async fn model_requests_have_a_body_limit_independent_of_the_response_buffer() {
use gateway_plugin_sdk::call::model::request_payload_limit;
@@ -174,8 +174,7 @@ async fn failed_or_cancelled_commands_are_not_replayed_and_children_are_reaped()
} else {
// 正常完成注册并确认命令已进入子进程后,只推进执行期限
tokio::time::pause();
tokio::time::advance(limits.maximum_call_timeout + Duration::from_millis(1))
.await;
tokio::time::advance(Duration::from_secs(120) + Duration::from_millis(1)).await;
}
}
};
@@ -209,3 +208,46 @@ async fn failed_or_cancelled_commands_are_not_replayed_and_children_are_reaped()
}
}
}
#[tokio::test]
async fn command_declared_budget_survives_the_default_and_expires_at_its_own_deadline() {
use std::time::Duration;
let mut config = configuration(json!([]));
config["command_wait"] = json!(true);
config["call_budgets_ms"] = json!({"command_line":180_000});
let (directory, store, runtime) = setup(config).await;
let marker = directory.path().join("command-budget.jsonl");
store.snapshot.lock().unwrap().instances[0].configuration["command_marker"] = json!(marker);
let commands = runtime.prepare_command_line().await.unwrap();
let task = tokio::spawn(async move {
let result = commands.execute("commands", "inspect", &[]).await;
(commands, result)
});
tokio::time::timeout(Duration::from_secs(5), async {
while !marker.exists() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
tokio::time::pause();
tokio::time::advance(Duration::from_secs(121)).await;
tokio::task::yield_now().await;
assert!(!task.is_finished(), "声明的命令预算不受默认 120 秒截断");
tokio::time::advance(Duration::from_secs(60)).await;
let (commands, result) = task.await.unwrap();
tokio::time::resume();
let error = result.err().unwrap();
assert!(matches!(
error,
gateway_plugin_runtime::PluginCommandError::Incomplete {
saved_accounts: 0,
..
}
));
assert!(error.to_string().contains("超时") || error.to_string().contains("总期限"));
commands.shutdown().await;
assert_eq!(std::fs::read_to_string(marker).unwrap().lines().count(), 1);
}
@@ -71,8 +71,7 @@ async fn reading_websocket_facts_preserves_original_text_and_opaque_messages() {
attempt_middleware_context_for_transport(Arc::default(), ClientTransport::WebSocket),
operation(),
ClientTransport::WebSocket,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
ProviderCallMetadata::new(
@@ -190,7 +189,6 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
.with_status_code(200)
.with_request_id(OpaqueUpstreamValue::new("response-request-id"))
.with_service_tier_if_valid("priority")
.with_upstream_response_model_if_valid("returned-model")
.with_client_headers(vec![ProviderResponseHeader::new(
"set-cookie",
Bytes::from_static(b"fixture=secret"),
@@ -228,8 +226,7 @@ async fn facts_are_readable_without_replacing_or_duplicating_the_original_execut
attempt_middleware_context(Arc::default()),
operation(),
ClientTransport::HttpJson,
None,
Box::new(move |_, _, _| {
Box::new(move |_, _| {
Box::pin(async move {
Ok(ProviderStream::new(
metadata,
@@ -41,6 +41,24 @@ async fn http_plugin_with_policy(
PluginRuntime,
ExtensionSetReference,
gateway_core::engine::middleware::FrozenMiddlewarePlan,
) {
http_plugin_with_configuration(
serde_json::json!({"http":true,"mode":mode}),
limits,
failure_policy,
)
.await
}
async fn http_plugin_with_configuration(
configuration: serde_json::Value,
limits: RpcLimits,
failure_policy: PluginFailurePolicy,
) -> (
tempfile::TempDir,
PluginRuntime,
ExtensionSetReference,
gateway_core::engine::middleware::FrozenMiddlewarePlan,
) {
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
@@ -55,7 +73,7 @@ async fn http_plugin_with_policy(
let (cache, runtime) = setup_package_with_limits(
vec![InstanceFixture {
id: "http-plugin",
configuration: serde_json::json!({"http":true,"mode":mode}),
configuration,
bindings: vec![binding(MIDDLEWARE_CONTRIBUTION, "http", 0, failure_policy)],
}],
package,
@@ -64,14 +64,11 @@ async fn session_model_and_network_callbacks_outlive_handshake_and_each_executio
let key = format!("key_{}", uuid::Uuid::new_v4().simple());
environment.client_key(&key, "sk-session-fixture").await;
environment
.install_plugin(serde_json::json!({"http":true,"mode":"callbacks"}))
.await;
let (runtime, bundle) = environment
.runtime_with_limits(RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
})
.install_plugin(
serde_json::json!({"http":true,"mode":"callbacks", "call_budgets_ms":{"http":500}}),
)
.await;
let (runtime, bundle) = environment.runtime_with_limits(RpcLimits::default()).await;
environment.store.start_command_line_writes().unwrap();
let snapshot = bundle.snapshots().snapshot_for_diagnostics().unwrap();
let plan = runtime
@@ -185,12 +182,10 @@ async fn session_model_and_network_callbacks_outlive_handshake_and_each_executio
#[tokio::test]
async fn real_plugin_controls_upgrade_and_concurrent_duplex_with_large_binary_frames() {
let (_cache, runtime, generation, plan) = http_plugin_with_limits(
"upgrade",
RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
},
let (_cache, runtime, generation, plan) = http_plugin_with_configuration(
serde_json::json!({"http":true,"mode":"upgrade", "call_budgets_ms":{"http":500}}),
RpcLimits::default(),
PluginFailurePolicy::Reject,
)
.await;
let (send, incoming) = tokio::sync::mpsc::channel(1);
@@ -3,72 +3,6 @@
use super::*;
use gateway_admin::ports::plugins::PluginPreparation;
#[tokio::test]
async fn provider_parameters_compose_through_sdk_without_merging_deleted_fields() {
let worker = std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap();
let package = crate::support::package_with_contributions(
&worker,
Contributions::from([crate::support::contribution(
Capability::Middleware,
vec![Stage::Attempt],
vec!["openai".into()],
vec!["openai".into()],
)]),
);
let original = serde_json::json!({"installation_id":"account-default", "future":{"keep":true}});
let first = serde_json::json!({"installation_id":"plugin-first"});
let last = serde_json::json!({});
let instances = [
("parameters-first", original.clone(), first.clone()),
("parameters-last", first, serde_json::Value::Null),
]
.into_iter()
.enumerate()
.map(|(index, (id, expected, parameters))| InstanceFixture {
id,
configuration: serde_json::json!({"mode":"parameters", "attempt":true,
"expected_parameters":expected, "parameters":parameters}),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
"attempt",
index as i32,
PluginFailurePolicy::Reject,
)],
})
.collect();
let (cache, runtime) = setup_package(instances, package).await;
let generation = prepare(&runtime).await;
let plan = runtime
.execution_registry()
.middleware(&generation)
.unwrap();
let downstream = Downstream {
calls: Arc::default(),
closes: Arc::default(),
reads: Arc::default(),
error: false,
};
let response = plan
.handle(
attempt_middleware_context(Arc::default()),
budget_request().with_provider_parameters(original.as_object().unwrap().clone()),
gateway_core::middleware::compose(Vec::new(), move |request| {
assert_eq!(
request.provider_parameters().unwrap(),
last.as_object().unwrap()
);
downstream.into_next().run(request)
}),
)
.await
.unwrap();
let (_, _, _, body, _) = response.into_parts();
body.close().await;
drop(plan);
drop(generation);
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn partial_update_preserves_inflight_middleware_and_uses_new_configuration_for_new_calls() {
let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([
@@ -213,7 +147,8 @@ async fn sdk_budget_runtime(stage: Stage, count: usize) -> (tempfile::TempDir, P
.enumerate()
.map(|(order, id)| InstanceFixture {
id,
configuration: serde_json::json!({"mode":"map", "attempt":stage == Stage::Attempt}),
configuration: serde_json::json!({"mode":"map", "attempt":stage == Stage::Attempt,
"call_budgets_ms":{"request":300,"attempt":300}}),
bindings: vec![binding(
MIDDLEWARE_CONTRIBUTION,
mount,
@@ -222,15 +157,7 @@ async fn sdk_budget_runtime(stage: Stage, count: usize) -> (tempfile::TempDir, P
)],
})
.collect();
setup_package_with_limits(
instances,
package,
RpcLimits {
maximum_call_timeout: Duration::from_millis(300),
..RpcLimits::default()
},
)
.await
setup_package_with_limits(instances, package, RpcLimits::default()).await
}
fn budget_request() -> MiddlewareRequest {
@@ -369,54 +369,85 @@ async fn retry_policy_preserves_decisions_and_leaves_replay_safety_to_core() {
}
#[tokio::test]
async fn retry_policy_timeout_exhausts_the_chain_budget_and_delegates() {
async fn retry_policy_combines_instance_budgets_with_one_request_bounded_chain() {
use gateway_core::{
engine::policy::{RetryDecision, RetryFacts},
error::ProviderErrorKind,
upstream::UpstreamSendState,
};
let records = tempfile::tempdir().unwrap();
let second_marker = records.path().join("second.jsonl");
let (_cache, runtime) = setup(
vec![
for (
first_budget,
first_delay,
second_budget,
second_delay,
request_budget,
expected,
reached_second,
) in [
(100, 500, 1000, 100, 2000, RetryDecision::Stop, true),
(1000, 100, 50, 300, 2000, RetryDecision::Delegate, true),
(1000, 300, 1000, 800, 2000, RetryDecision::Delegate, true),
(1000, 500, 1000, 0, 100, RetryDecision::Delegate, false),
(3000, 2100, 3000, 0, 5000, RetryDecision::Stop, true),
] {
let records = tempfile::tempdir().unwrap();
let second_marker = records.path().join("second.jsonl");
let (_cache, runtime) = setup(vec![
InstanceFixture {
id: "retry-delayed",
configuration: serde_json::json!({"retry_delay_ms":500,"retry_decision":{"decision":"stop"}}),
bindings: vec![binding("test.example.retryPolicy", "retry", 0, PluginFailurePolicy::Delegate)],
configuration: serde_json::json!({
"call_budgets_ms":{"retry":first_budget},
"retry_delay_ms":first_delay,
"retry_decision":{"decision":"delegate"},
}),
bindings: vec![binding(
"test.example.retryPolicy",
"retry",
0,
PluginFailurePolicy::Delegate,
)],
},
InstanceFixture {
id: "retry-next",
configuration: serde_json::json!({"retry_marker":second_marker,"retry_decision":{"decision":"stop"}}),
bindings: vec![binding("test.example.retryPolicy", "retry", 1, PluginFailurePolicy::Delegate)],
configuration: serde_json::json!({
"call_budgets_ms":{"retry":second_budget},
"retry_delay_ms":second_delay,
"retry_marker":second_marker,
"retry_decision":{"decision":"stop"},
}),
bindings: vec![binding(
"test.example.retryPolicy",
"retry",
1,
PluginFailurePolicy::Delegate,
)],
},
],
).await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout");
let decision = tokio::time::timeout(
Duration::from_secs(2),
context.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_millis(100),
retry_allowed: true,
}),
)
.await
.expect("the shared deadline bounds the whole retry chain");
assert_eq!(decision, RetryDecision::Delegate);
assert!(
!second_marker.exists(),
"later policies cannot restart an exhausted budget"
);
runtime.shutdown().await;
])
.await;
let context = policy_context(&runtime, prepare(&runtime).await, "req_retry_timeout");
let decision = tokio::time::timeout(
Duration::from_secs(6),
context.retry_decision(RetryFacts {
attempt_index: NonZeroU32::MIN,
provider: ProviderKind::new("openai").unwrap(),
model: Some("gpt-test".into()),
error_kind: ProviderErrorKind::RateLimited,
upstream_status: Some(429),
send_state: UpstreamSendState::NotSent,
remaining_routing_attempts: 1,
remaining_deadline: Duration::from_millis(request_budget),
retry_allowed: true,
}),
)
.await
.expect("重试链不能重置总预算");
assert_eq!(
decision, expected,
"{first_budget}/{second_budget}/{request_budget}"
);
assert_eq!(second_marker.exists(), reached_second);
runtime.shutdown().await;
}
}
async fn marker_lines(path: &std::path::Path, count: usize) -> Vec<serde_json::Value> {
@@ -268,13 +268,18 @@ async fn outbound_next_wait_pauses_plugin_budget_and_cancel_releases_the_continu
for cancel in [false, true] {
let (cache, runtime) = runtime(
vec![
instance("outer", 0, serde_json::json!({})),
instance("inner", 1, serde_json::json!({})),
instance(
"outer",
0,
serde_json::json!({"call_budgets_ms":{"outbound":300}}),
),
instance(
"inner",
1,
serde_json::json!({"call_budgets_ms":{"outbound":300}}),
),
],
RpcLimits {
maximum_call_timeout: Duration::from_millis(300),
..RpcLimits::default()
},
RpcLimits::default(),
)
.await;
let generation = prepare(&runtime).await;
@@ -136,6 +136,7 @@ async fn retry_callback_effects_survive_success_failure_and_cancellation() {
"retry_http_return_marker":(outcome == "early_result").then_some(marker),
"retry_service_request":is_service.then_some(service_request),
"retry_service_marker":service_marker,
"call_budgets_ms":{"retry":500},
}))
.await;
let key = "sk-retry-effects-fixture";
@@ -146,10 +147,7 @@ async fn retry_callback_effects_survive_success_failure_and_cancellation() {
});
let (runtime, core) = environment
.runtime_with_providers(
RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..RpcLimits::default()
},
RpcLimits::default(),
ProviderRegistry::new([provider.clone() as Arc<dyn Provider>]).unwrap(),
)
.await;
@@ -552,3 +552,55 @@ async fn a_host_version_mismatch_still_restores_the_enabled_plugin() {
runtime.shutdown().await;
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn declared_registration_budget_is_not_cut_short_by_process_preparation_timeout() {
let (cache, store, runtime) = super::super::setup().await;
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"registration_delay_ms":31_000,
"call_budgets_ms":{"registration":40_000},
});
let candidate = tokio::time::timeout(
Duration::from_secs(45),
PluginPreparation::prepare(&runtime, snapshot),
)
.await
.expect("registration stays bounded by its declared budget")
.expect("ready process may register beyond the startup control deadline");
assert!(candidate.is_ready());
drop(candidate);
super::super::wait_until_empty(cache.path()).await;
}
#[tokio::test]
async fn shutdown_cancels_long_registration_and_reclaims_the_unpublished_candidate() {
let (cache, store, runtime) = super::super::setup().await;
let runtime = std::sync::Arc::new(runtime);
let markers = tempfile::tempdir().unwrap();
let started = markers.path().join("registration");
let mut snapshot = store.snapshot.lock().unwrap().clone();
snapshot.instances[0].configuration = serde_json::json!({
"registration_started_marker":started,
"registration_delay_ms":600_000,
"call_budgets_ms":{"registration":600_000},
});
let preparing = tokio::spawn({
let runtime = std::sync::Arc::clone(&runtime);
async move { PluginPreparation::prepare(runtime.as_ref(), snapshot).await }
});
tokio::time::timeout(Duration::from_secs(5), async {
while !started.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(3), runtime.shutdown())
.await
.expect(
"shutdown must cancel candidate preparation instead of waiting for its long budget",
);
assert!(preparing.await.unwrap().is_err());
super::super::wait_until_empty(cache.path()).await;
}
@@ -0,0 +1,157 @@
//! 验证维护任务跨发布保活、通知合并及实例替换后的取消顺序
use gateway_admin::{
model::{Revision, plugins::instances::PluginInstanceSnapshot},
ports::plugins::PluginPreparation,
};
use gateway_core::{
lifecycle::CancellationToken,
routing::{ConfigRevision, RuntimeSnapshot},
runtime::RuntimeSnapshotHandle,
task::{WorkerContribution, WorkerDaemonContext, WorkerRunnable},
};
use gateway_plugin_runtime::PluginRuntime;
use gateway_plugin_sdk::{Capability, Contributions, Stage};
use serde_json::{Value, json};
use std::{path::Path, sync::Arc, time::Duration};
async fn publish(
runtime: &PluginRuntime,
snapshots: &RuntimeSnapshotHandle,
instances: PluginInstanceSnapshot,
) {
let revision = ConfigRevision::new(instances.config_revision.get()).unwrap();
let prepared = PluginPreparation::prepare(runtime, instances)
.await
.unwrap();
snapshots.publish(
RuntimeSnapshot::new(
revision,
gateway_core::settings::SettingsValues::new(1, "smart", Default::default(), None, None),
vec![],
vec![],
vec![],
)
.unwrap()
.with_extensions(Some(prepared)),
);
}
fn records(path: &Path) -> Vec<Value> {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_records(path: &Path, count: usize) -> Vec<Value> {
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let entries = records(path);
if entries.len() >= count {
return entries;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap_or_else(|error| panic!("missing maintenance records: {error}; {:?}", records(path)))
}
#[tokio::test]
async fn unrelated_publications_preserve_inflight_maintenance_and_replacement_waits_for_cancel() {
let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([
crate::support::contribution(
Capability::Maintenance,
vec![Stage::Maintenance],
vec![],
vec![],
),
]))
.await;
let runtime = Arc::new(runtime);
let markers = tempfile::tempdir().unwrap();
let mut instances = store.snapshot.lock().unwrap().clone();
let template = instances.instances[0].clone();
instances.instances = ["a", "b"]
.into_iter()
.map(|id| {
let mut instance = template.clone();
instance.id = id.into();
instance.configuration = json!({
"maintenance_marker":markers.path().join(id),
"execution_cancelled_marker":markers.path().join(id),
"maintenance_wait_marker":markers.path().join(format!("release-{id}")),
"call_context_marker":markers.path().join(format!("context-{id}")),
"call_budgets_ms":{"maintenance":600_000},
});
instance
})
.collect();
let snapshots = RuntimeSnapshotHandle::default();
publish(&runtime, &snapshots, instances.clone()).await;
let WorkerContribution::Registration(registration) =
runtime.maintenance_worker(snapshots.clone()).unwrap()
else {
panic!("maintenance worker registration")
};
let WorkerRunnable::Daemon { task, .. } = registration.runnable else {
panic!("maintenance daemon")
};
let stop = CancellationToken::new();
let running = tokio::spawn({
let stop = stop.clone();
async move { task.run(WorkerDaemonContext::new(stop)).await.unwrap() }
});
let a = markers.path().join("a");
let b = markers.path().join("b");
assert_eq!(wait_records(&a, 1).await[0]["phase"], "start");
assert_eq!(wait_records(&b, 1).await[0]["phase"], "start");
let contexts = records(&markers.path().join("context-a"));
let maintenance = contexts
.iter()
.find(|entry| entry["method"] == "plugin.reconcile")
.expect("maintenance call context");
assert_eq!(maintenance["context"]["timeout_ms"], 600_000);
instances.config_revision = Revision::new(2).unwrap();
instances.instances[1].revision = instances.config_revision;
publish(&runtime, &snapshots, instances.clone()).await;
let changed = wait_records(&b, 3).await;
assert!(
changed[1].get("call_id").is_some(),
"旧维护必须先完成取消确认"
);
assert_eq!(changed[2]["phase"], "start");
assert_eq!(records(&a).len(), 1, "无关实例发布不得重启 A 的在途维护");
instances.config_revision = Revision::new(3).unwrap();
instances.instances[1].revision = instances.config_revision;
instances.instances[1].enabled = false;
publish(&runtime, &snapshots, instances.clone()).await;
assert!(wait_records(&b, 4).await[3].get("call_id").is_some());
instances.config_revision = Revision::new(4).unwrap();
instances.instances[1].revision = instances.config_revision;
instances.instances[1].enabled = true;
publish(&runtime, &snapshots, instances.clone()).await;
assert_eq!(wait_records(&b, 5).await[4]["phase"], "start");
assert_eq!(records(&a).len(), 1, "停用与恢复 B 不得取消 A");
// 多次发布通知合并为一次后续对账,首轮仍使用原调用 ID 完成
std::fs::write(markers.path().join("release-a"), []).unwrap();
let completed = wait_records(&a, 4).await;
assert_eq!(completed[1]["phase"], "done");
assert_eq!(completed[1]["id"], completed[0]["id"]);
assert_eq!(completed[2]["phase"], "start");
assert_eq!(completed[3]["phase"], "done");
assert_ne!(completed[2]["id"], completed[0]["id"]);
assert_eq!(completed.len(), 4);
stop.cancel();
running.await.unwrap();
assert!(wait_records(&b, 6).await[5].get("call_id").is_some());
snapshots.suspend();
runtime.shutdown().await;
super::super::wait_until_empty(cache.path()).await;
}
@@ -2,6 +2,7 @@
mod diagnostics;
mod instance;
mod maintenance;
mod management;
mod runtime;
mod set;
@@ -533,14 +533,8 @@ async fn adapter_sse_outlives_rpc_budget_and_cancellation_closes_its_resources()
.as_object_mut()
.unwrap()
.remove("wire");
let (cache, _, runtime) = setup_with_limits(
config,
gateway_plugin_runtime::RpcLimits {
maximum_call_timeout: Duration::from_millis(500),
..Default::default()
},
)
.await;
config["call_budgets_ms"] = json!({"upstream":500});
let (cache, _, runtime) = setup(config).await;
let generation =
ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap())
.await
@@ -667,7 +661,7 @@ async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
{"event":{"facts":[{"type":"started","id":"response-one","model":"native-model"},
{"type":"content_added","index":0,"kind":"reasoning"}]},
"observation":{"transport":"http_sse","http_version":"HTTP/2","status_code":200,
"request_id":"upstream-494","upstream_response_model":"real-model",
"request_id":"upstream-494",
"client_headers":[{"name":"x-bytes","value":[255]},{"name":"x-bytes","value":[254]}],
"provider_metadata":"{ \"future\": 1e3 }","timings":{"connect_ms":5,"headers_ms":8}}},
{"event":{"facts":[{"type":"reasoning_delta","index":0,"text":""}]}},
@@ -711,7 +705,6 @@ async fn adapter_preserves_observation_updates_and_measures_only_real_output() {
assert_eq!(last.status_code(), Some(200));
assert_eq!(last.http_version().unwrap().as_str(), "HTTP/2");
assert_eq!(last.request_id().unwrap().as_str(), "upstream-494");
assert_eq!(last.upstream_response_model(), Some("real-model"));
assert_eq!(last.service_tier(), Some("priority"));
assert_eq!(
last.provider_metadata().unwrap().as_json(),
@@ -1359,7 +1352,7 @@ async fn cancelling_adapter_body_closes_pending_websocket_and_releases_selected_
}
#[tokio::test]
async fn usage_without_response_model_still_uses_native_account_pricing() {
async fn upstream_adapter_usage_uses_native_account_pricing() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
@@ -36,6 +36,33 @@ pub(super) struct LifecycleCallbacks {
struct OversizedReplyCallbacks;
#[derive(Default)]
struct BudgetCallbacks {
started: Arc<Notify>,
release: Arc<Notify>,
}
impl CallbackHandler for BudgetCallbacks {
fn call(
&self,
context: CallContext,
_method: String,
_params: serde_json::Value,
payload: Vec<u8>,
) -> BoxFuture<'static, Result<RpcReply, PluginFault>> {
let started = Arc::clone(&self.started);
let release = Arc::clone(&self.release);
Box::pin(async move {
started.notify_one();
release.notified().await;
Ok(RpcReply {
result: json!({"budget_ms": context.timeout_ms}),
payload,
})
})
}
}
impl CallbackHandler for OversizedReplyCallbacks {
fn call(
&self,
@@ -336,7 +363,7 @@ async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() {
session
.context(Stage::Registration, Duration::MAX)
.timeout_ms,
u64::try_from(RpcLimits::default().maximum_call_timeout.as_millis()).unwrap()
5_000
);
*callbacks.nested.lock().unwrap() = Some(Arc::downgrade(&session));
let context = session.context(Stage::Management, Duration::from_secs(2));
@@ -355,6 +382,106 @@ async fn real_process_round_trips_raw_bytes_and_reentrant_callbacks() {
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn declared_stage_budget_covers_long_callback_without_affecting_other_calls() {
let (_cache, prepared, mut handshake, processes) = prepared_session();
handshake.configuration = json!({"call_budgets_ms":{"maintenance":600_000,"management":40}});
let callbacks = Arc::new(BudgetCallbacks::default());
let session = Arc::new(
RpcSession::start(
prepared,
handshake,
RpcLimits::default(),
&processes,
callbacks.clone(),
)
.await
.unwrap(),
);
let calling = {
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.call(
"callback",
session.context(Stage::Maintenance, session.budget(Stage::Maintenance)),
json!({}),
vec![],
)
.await
})
};
callbacks.started.notified().await;
tokio::time::pause();
tokio::time::advance(Duration::from_secs(125)).await;
assert!(!calling.is_finished());
tokio::time::resume();
let short = session
.call(
"hang",
session.context(Stage::Management, session.budget(Stage::Management)),
json!({}),
vec![],
)
.await;
assert!(matches!(short, Err(RpcError::Timeout)));
callbacks.release.notify_one();
let reply = calling.await.unwrap().unwrap();
assert_eq!(reply.result, json!({"budget_ms":600_000}));
assert!(session.is_ready());
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn invalid_budget_rejects_candidate_before_business_and_releases_process_capacity() {
let (_cache, prepared, mut handshake, _) = prepared_session();
let processes =
gateway_host::process::ProcessSupervisor::new(std::num::NonZeroUsize::new(1).unwrap());
handshake.configuration = json!({"call_budgets_ms":{"maintenance":0}});
let callbacks = Arc::new(LifecycleCallbacks::default());
let result = RpcSession::start(
prepared.clone(),
handshake.clone(),
RpcLimits::default(),
&processes,
callbacks.clone(),
)
.await;
assert!(matches!(
result,
Err(RpcError::Budget(
gateway_plugin_sdk::CallBudgetError::Invalid {
stage: Stage::Maintenance,
milliseconds: 0,
}
))
));
assert_eq!(callbacks.begun.load(Ordering::Relaxed), 0);
handshake.configuration = json!({});
handshake.incarnation = uuid::Uuid::new_v4().to_string();
let session = RpcSession::start(
prepared,
handshake,
RpcLimits::default(),
&processes,
callbacks,
)
.await
.unwrap();
assert!(
session
.call(
"echo",
session.context(Stage::Maintenance, session.budget(Stage::Maintenance)),
json!({}),
vec![]
)
.await
.is_ok()
);
session.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn slow_management_call_does_not_block_an_independent_call() {
let (_cache, session) = session(Arc::new(Callbacks::default())).await;
@@ -11,7 +11,7 @@ use gateway_plugin_sdk::{
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let session = PluginSession::accept(
let mut session = PluginSession::accept(
tokio::io::stdin(),
tokio::io::stdout(),
SessionConfig::default(),
@@ -22,6 +22,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.unwrap_or("map")
.to_owned();
let configuration = session.handshake().configuration.clone();
if let Some(budgets) = configuration.get("call_budgets_ms") {
session.set_call_budgets(serde_json::from_value(budgets.clone())?)?;
}
if mode == "mixed_mounts" {
let contributes = Contributions::from([(
Capability::Middleware,
@@ -545,19 +548,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
call.request.head.settings = settings.clone();
}
}
if mode == "parameters" {
assert_eq!(
serde_json::to_value(&call.request.head.provider_parameters).unwrap(),
configuration["expected_parameters"],
);
if let Some(parameters) = configuration.get("parameters") {
call.request.head.provider_parameters = if parameters.is_null() {
None
} else {
Some(parameters.as_object().unwrap().clone())
};
}
}
if mode == "inspect" && !call.request.body.is_empty() {
// 读取和解析只能作用于副本,不能把解析结果写回未改写的正文
assert!(serde_json::from_slice::<serde_json::Value>(&call.request.body).is_ok());
@@ -528,15 +528,29 @@ impl Peer {
return;
}
"plugin.reconcile" => {
let result = if let Some(results) = self.data_queries(id).await {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "results":results}),
);
Ok(())
} else {
self.reconcile_fixture(id).await
};
let result =
if let Some(path) = self.configuration["maintenance_wait_marker"].as_str() {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"start", "id":id}),
);
while !std::path::Path::new(path).exists() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "id":id}),
);
Ok(())
} else if let Some(results) = self.data_queries(id).await {
self.append_observation_marker(
"maintenance_marker",
&json!({"phase":"done", "results":results}),
);
Ok(())
} else {
self.reconcile_fixture(id).await
};
match result {
Ok(()) => {
self.send(
@@ -1046,7 +1060,6 @@ impl Peer {
id,
NEXT_METHOD,
serde_json::to_value(MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: None,
header_mutations: Vec::new(),
@@ -1375,6 +1388,10 @@ impl Peer {
return;
}
"plugin.register" => {
self.append_observation_marker("registration_started_marker", &json!({"id":id}));
if let Some(delay) = self.configuration["registration_delay_ms"].as_u64() {
tokio::time::sleep(Duration::from_millis(delay)).await;
}
if let Some(request) = self.configuration.get("registration_http") {
let result = self
.callback_payload(
@@ -1490,6 +1507,13 @@ async fn main() {
Message::Ready {
protocol_version: gateway_plugin_sdk::PROTOCOL_VERSION,
incarnation: handshake.incarnation,
call_budgets: serde_json::from_value(
peer.configuration
.get("call_budgets_ms")
.cloned()
.unwrap_or_else(|| json!({})),
)
.unwrap(),
},
vec![],
)
@@ -1520,6 +1544,10 @@ async fn main() {
} => {
assert!(id > last_call && !id.is_multiple_of(2) && context.call_id == id);
last_call = id;
peer.append_observation_marker(
"call_context_marker",
&json!({"method":method, "context":context}),
);
if matches!(
method.as_str(),
"malformed_truncated_frame" | "malformed_frame_length" | "malformed_metadata"
+50 -3
View File
@@ -64,7 +64,7 @@ sequenceDiagram
participant H as 宿主 Runtime
participant P as 插件会话
H->>P: Hello
P-->>H: Ready(相同 incarnation)
P-->>H: Ready(相同 incarnation、所需预算声明)
H->>P: 注册能力,核对清单
P-->>H: 注册结果
H->>P: Call(ID、上下文、载荷)
@@ -78,7 +78,7 @@ sequenceDiagram
### 并发与期限
每个宿主插件会话默认允许 64 个执行中的 RPC 调用、64 个初次排队调用和 256 个总在途上下文。
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身默认上限 120 秒的处理预算。
初次排队和等待 `next` 后的恢复排队各限 30 秒,不扣插件自身的处理预算。
恢复等待占总在途名额,不占初次排队名额。
等待 `next` 时释放执行名额并暂停处理预算,返回后重新取得名额,继续同一次调用;
已返回响应头的流继续占用在途预算,不占首结果执行名额。具体组合行为见[洋葱中间件](docs/capabilities.md#洋葱中间件)
@@ -91,13 +91,60 @@ sequenceDiagram
达到容量或等待上限时,中间件返回过载错误;HTTP 响应为 503(`middleware_capacity_unavailable`),
不跳过插件或重放请求。调整 SDK 会话配置需重新构建插件包,容量应结合插件耗时、正文和响应流的资源占用评估
### 声明调用预算
插件在 `PluginSession::accept` 后、`run` 前通过 `set_call_budgets` 声明各阶段所需的毫秒预算,可读取
`session.handshake().configuration`,由自身配置决定模型、批量任务等场景的时长
```rust,no_run
use gateway_plugin_sdk::{CallBudgets, Stage};
use gateway_plugin_sdk::client::{PluginHandler, PluginSession, SessionConfig, SessionError};
# async fn configure<R, W, H>(reader: R, writer: W, handler: H) -> Result<(), SessionError>
# where R: tokio::io::AsyncRead + Unpin + Send + 'static,
# W: tokio::io::AsyncWrite + Unpin + Send + 'static, H: PluginHandler {
let mut session = PluginSession::accept(reader, writer, SessionConfig::default()).await?;
let maintenance_ms = session.handshake().configuration["maintenanceBudgetMs"]
.as_u64()
.unwrap_or(600_000);
session.set_call_budgets(CallBudgets::from([
(Stage::Maintenance, maintenance_ms),
(Stage::CommandLine, 1_800_000),
]))?;
session.run(handler).await?;
# Ok(())
# }
```
未声明的阶段使用协议默认值,默认表由 SDK 集中维护,宿主与插件使用同一份合同。
默认值不限制插件显式声明的最大时长
| 阶段 | 默认预算 |
| --- | ---: |
| `registration` | 5 秒 |
| `configuration`、`authentication` | 30 秒 |
| `routing`、`scheduling`、`retry`、`observation` | 2 秒 |
| `maintenance`、`management`、`public_management`、`command_line` | 120 秒 |
| `http`、`service`、`websocket`、`request`、`attempt`、`outbound`、`upstream` | 120 秒 |
`Ready.call_budgets` 携带显式声明,双方按相同规则解析并冻结该会话预算。预算必须为正的有限毫秒值,
不接受零值、数值溢出和平台无法表示的期限。修改声明需要创建新会话,不支持调用中无限续期
每次 `CallContext.timeout_ms` 是该调用的初始处理预算,外部剩余期限较短时可以小于会话预算。
`call.host.remaining_budget()` 读取共享期限中的剩余值,正确反映 `next` 暂停;资源流 owner 接管寿命后返回 `None`。
普通模型和网络回调消耗父调用剩余预算,不会重置预算。请求总期限、网络操作期限、停用和取消仍独立生效,
声明长预算不保证模型必然完成,也不提供跨宿主重启恢复;检查点和业务幂等由插件维护
宿主关闭使用独立宽限并取消未完成调用,不等待插件处理预算耗尽。
注册与配置迁移位于实例准备链路,其实际耗时仍会影响宿主启动和实例就绪
### 帧与会话
`CallContext.timeout_ms` 限定初始处理期限。宿主对由流或连接 owner 管理的调用设置 `resource_stream`:
首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。
普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束
协议 v5 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
协议 v6 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。
元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块,
不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付
@@ -138,8 +138,8 @@ Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input`
### 自有资源与维护
声明 `maintenance` 并注册 `methods::RECONCILE`,宿主会在实例启用发布、进程恢复及配置变更后调用
`plugin.reconcile`,并每 30 秒补偿一次。处理器必须幂等;调用可能重复,配置通知会合并,不表示逐条账号事件。
每个实例串行执行,不同实例相互独立;调用限时 30 秒,返回值不消费,调用失败后等待 5 秒重试。维护失败不回滚已经提交的资源,
`plugin.reconcile`,每轮完成后等待 30 秒补偿。处理器必须幂等;调用可能重复,配置通知会合并,不表示逐条账号事件。
每个实例串行执行,不同实例相互独立;处理预算按[会话声明](../README.md#声明调用预算)生效,返回值不消费,调用失败后等待 5 秒重试。维护失败不回滚已经提交的资源,
下一次对账继续补齐。只读校验、准备候选与 CLI 帮助不会启动维护;停用、替换和宿主关闭时取消旧任务
维护与其他处理器共用以下资源方法:
@@ -402,7 +402,12 @@ Ping 的自动 Pong 与关闭握手属于底层 WebSocket 协议事实;插件
所有外层处理结束后,Provider 才把结果交给 transport 发送。插件错误、非法传输值或正文超限在原生发送前结束,
不自动跳过、重放或切换传输;取消、请求截止与回调资源沿用当前 attempt
身份策略可在 `request` / `attempt` 读取原始输入,以真实 Key 与账号标识调用私有状态服务维护映射,
再在 `outbound` 自行改写完整请求中的身份及关联字段。UUID 生成、并发创建、持久化、会话与缓存标识派生均由插件负责;
通用中间件层不解释 installation ID、device ID 等策略字段,也不在插件输出后重新派生业务字段
WebSocket 的 `body` 是准备发送的一条完整 UTF-8 文本消息,`headers` 和 `uri` 是候选 opening。
新响应链按出站改写产生的 URI / 握手头差异分区,精确续接仍使用持有原响应链的连接。
复用连接时不能改写已发生的握手;宿主不为插件主动重连、重放消息或重建续接。
插件需要握手身份与消息内容一致时,应结合实际连接与续接语义选择改写
@@ -429,17 +434,6 @@ request 与 attempt 阶段通过 `append_header` 添加的请求头会进入上
OpenAI 与 xAI 的上游 headers 先由账号和画像生成,插件同名字段覆盖基线,同名多值按插件顺序保留;不存在按认证、Cookie 或会话名称划分的保护列表。
实际账号租约、凭据版本与计费归属仍记录真实选中事实,不随插件修改 header 名称而伪造
attempt 的 `request.head.provider_parameters` 携带 Provider 提供的本次构造参数;没有该能力的执行不提供此对象。
OpenAI 原生 Responses、Images、Search 与 Live 创建请求提供 `installation_id` 字符串,默认取自选中账号,
用于原生安装身份、已有 turn metadata 身份字段及会话、缓存关联标识的派生。独立 `upstream_adapter` 自行构造其请求。
插件直接修改此对象,SDK 在值改变时提交完整替换;空对象明确删除全部参数,将原本存在的对象设为 `None` 同样提交空对象。
线协议的 `next.provider_parameters` 省略时保留原值,显式 `null` 或非对象类型拒绝。
后续插件读取前层结果,Core / Runtime 不解释业务字段、不深合并;缺失必需字段或类型错误在发送前失败且禁止重试,不补回账号默认身份。
身份选择、Key 与账号配对、UUID 生成与持久化由插件负责,可复用已有状态服务;参数不写回账号凭据、全局设置或真实计费事实。
WS 新响应链按构造身份区分连接,精确续接仍使用实际持有该响应链的原连接;Live sideband / hangup 不继承创建请求的临时参数
request 阶段 `request.head.settings` 是本次调用的有效设置对象,包含 `runtime`、`fast_mode`、`client_limits` 和 `timeout_ms`。
`fast_mode` 使用 `default`、`enabled`、`disabled` 三态,具体档位语义见[账号分组](../../../../../docs/api.md#6-账号分组)。
`runtime` 使用宿主快照的设置字段,画像已合并 Client Key 设置;可以修改映射、画像、调度、位置及计价等执行参数。
@@ -26,7 +26,7 @@
| 版本字段 | 当前值 | 对应合同 |
| --- | --- | --- |
| `manifestVersion` / `manifest_schema_versions` | `2` | 插件清单格式 |
| `package.protocolVersion` / `protocol_versions` | `5` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `package.protocolVersion` / `protocol_versions` | `6` | 插件进程 RPC 封装,不是 OpenAI 或 xAI 的业务协议 |
| `contributes.<capability>.version` / `capabilities[].versions` | 见[扩展项简写](#扩展项简写) | 指定扩展能力的行为合同 |
| 宿主声明的 `schema_version` | `2` | 宿主兼容声明自身的格式 |
@@ -34,7 +34,7 @@
清单不接受 `permissions` 字段;插件按[完整信任](#完整信任)运行
RPC v5 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
RPC v6 的帧布局、编码与会话生命周期见[通信边界](../README.md#通信边界)。插件使用 SDK 构建,并由 CLI 生成包含协议版本的安装包
安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本;
数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。
@@ -0,0 +1,119 @@
//! 按调用阶段解析有限处理预算,集中维护协议默认值与插件声明
use std::{
collections::BTreeMap,
time::{Duration, Instant},
};
use crate::Stage;
/// 按阶段声明的毫秒预算;未声明的阶段采用协议默认值
pub type CallBudgets = BTreeMap<Stage, u64>;
const STAGES: [Stage; 18] = [
Stage::Registration,
Stage::Configuration,
Stage::Authentication,
Stage::Routing,
Stage::Scheduling,
Stage::Retry,
Stage::Http,
Stage::Service,
Stage::WebSocket,
Stage::Request,
Stage::Attempt,
Stage::Outbound,
Stage::Upstream,
Stage::Observation,
Stage::Management,
Stage::CommandLine,
Stage::PublicManagement,
Stage::Maintenance,
];
/// 实际预算来自协议默认值或插件显式声明
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CallBudgetSource {
Default,
Plugin,
}
/// 已校验且不可变的会话预算,不允许构造缺少阶段的解析结果
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedCallBudgets(BTreeMap<Stage, (Duration, CallBudgetSource)>);
impl ResolvedCallBudgets {
/// 按协议默认值合并插件声明并冻结所有阶段的有效预算
///
/// # Errors
///
/// 声明值为零或平台计时器无法表示时失败
pub fn resolve(declarations: &CallBudgets) -> Result<Self, CallBudgetError> {
validate_values(declarations)?;
let resolved = STAGES
.into_iter()
.map(|stage| {
let (milliseconds, source) = match declarations.get(&stage) {
Some(&requested) => (requested, CallBudgetSource::Plugin),
None => (default_budget_ms(stage), CallBudgetSource::Default),
};
(stage, (Duration::from_millis(milliseconds), source))
})
.collect();
Ok(Self(resolved))
}
/// 读取当前会话在指定阶段的处理预算
#[must_use]
pub fn budget(&self, stage: Stage) -> Duration {
self.0[&stage].0
}
/// 读取预算来源,用于调用诊断
#[must_use]
pub fn source(&self, stage: Stage) -> CallBudgetSource {
self.0[&stage].1
}
}
/// 预算声明错误只暴露阶段和数值,不包含插件配置或业务数据
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CallBudgetError {
#[error("plugin call budget for {stage:?} is invalid: {milliseconds} ms")]
Invalid { stage: Stage, milliseconds: u64 },
}
pub(crate) fn validate_values(values: &CallBudgets) -> Result<(), CallBudgetError> {
for (&stage, &milliseconds) in values {
if milliseconds == 0
|| Instant::now()
.checked_add(Duration::from_millis(milliseconds))
.is_none()
{
return Err(CallBudgetError::Invalid {
stage,
milliseconds,
});
}
}
Ok(())
}
const fn default_budget_ms(stage: Stage) -> u64 {
match stage {
Stage::Registration => 5_000,
Stage::Configuration | Stage::Authentication => 30_000,
Stage::Routing | Stage::Scheduling | Stage::Retry | Stage::Observation => 2_000,
Stage::Http
| Stage::Service
| Stage::WebSocket
| Stage::Request
| Stage::Attempt
| Stage::Outbound
| Stage::Upstream
| Stage::Management
| Stage::CommandLine
| Stage::PublicManagement
| Stage::Maintenance => 120_000,
}
}
@@ -70,9 +70,6 @@ pub struct MiddlewareRequestHead {
/// 当前请求的完整有效执行设置;尚未建立可改写设置的边界使用 null
#[serde(default)]
pub settings: serde_json::Value,
/// 本次 Provider 构造参数;仅原生 attempt 提供
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
pub request_id: String,
pub client_key_id: String,
pub account_group_ids: Vec<String>,
@@ -131,13 +128,6 @@ pub struct MiddlewareNextRequest {
deserialize_with = "deserialize_replacement"
)]
pub settings: Option<serde_json::Value>,
/// 完整替换构造参数;空对象仍表示显式替换,不隐式合并或补回删除字段
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_replacement"
)]
pub provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(default)]
@@ -72,7 +72,6 @@ pub struct ResponseObservation {
pub status_code: Option<u16>,
pub request_id: Option<String>,
pub service_tier: Option<String>,
pub upstream_response_model: Option<String>,
#[serde(default)]
pub timings: ResponseTimings,
#[serde(default)]
@@ -119,7 +118,6 @@ impl ResponseObservation {
status_code: None,
request_id: None,
service_tier: None,
upstream_response_model: None,
timings: ResponseTimings::default(),
client_headers: Vec::new(),
provider_metadata: None,
@@ -187,9 +187,6 @@ pub struct RequestCompleted {
/// 本次实际选中账号使用的上游模型,未进入上游时可能不存在
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_model: Option<String>,
/// 上游响应报告的模型,与请求中的模型名称分开保存
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -39,7 +39,6 @@ pub struct MiddlewareRequest {
pub body: Vec<u8>,
original_protocol: String,
original_settings: serde_json::Value,
original_provider_parameters: Option<serde_json::Map<String, serde_json::Value>>,
original_headers: Vec<MiddlewareHeader>,
original_body: Vec<u8>,
body_replaced: bool,
@@ -99,9 +98,6 @@ impl MiddlewareRequest {
};
(
MiddlewareNextRequest {
provider_parameters: (self.head.provider_parameters
!= self.original_provider_parameters)
.then(|| self.head.provider_parameters.unwrap_or_default()),
settings: (self.head.settings != self.original_settings)
.then_some(self.head.settings),
protocol,
@@ -345,7 +341,6 @@ impl RequestCall {
context: call.context,
request: MiddlewareRequest {
original_settings: head.settings.clone(),
original_provider_parameters: head.provider_parameters.clone(),
body: call.payload,
original_body,
body_replaced: false,
@@ -17,7 +17,6 @@ pub struct SessionConfig {
pub maximum_callbacks: usize,
pub maximum_buffered_stream_chunks: usize,
pub handshake_timeout: Duration,
pub maximum_call_timeout: Duration,
}
impl Default for SessionConfig {
@@ -29,7 +28,6 @@ impl Default for SessionConfig {
maximum_callbacks: 64,
maximum_buffered_stream_chunks: 1_024,
handshake_timeout: Duration::from_secs(5),
maximum_call_timeout: Duration::from_secs(120),
}
}
}
@@ -45,7 +43,6 @@ impl SessionConfig {
|| self.maximum_buffered_stream_chunks == 0
|| self.maximum_buffered_stream_chunks > MAXIMUM_BUFFERED_STREAM_CHUNKS
|| self.handshake_timeout.is_zero()
|| self.maximum_call_timeout.is_zero()
{
return Err(SessionError::Configuration);
}
@@ -11,7 +11,8 @@ use tokio::{
};
use crate::{
CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION, PluginFault,
CallBudgets, CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION,
PluginFault, ResolvedCallBudgets,
};
use super::super::frame::{EncodedFrame, read_frame, write_frame};
@@ -31,6 +32,7 @@ pub struct PluginSession<R, W> {
writer: W,
handshake: Handshake,
config: SessionConfig,
call_budgets: CallBudgets,
}
impl<R, W> PluginSession<R, W>
@@ -70,6 +72,7 @@ where
writer,
handshake,
config,
call_budgets: CallBudgets::new(),
})
}
@@ -78,6 +81,18 @@ where
&self.handshake
}
/// 在 Ready 前声明所需预算,可根据握手中的实例配置计算
/// 未声明的阶段采用协议默认值,同一会话的预算在 run 时冻结
///
/// # Errors
///
/// 预算为零或平台计时器无法表示时失败,原声明保持不变
pub fn set_call_budgets(&mut self, budgets: CallBudgets) -> Result<(), SessionError> {
crate::budget::validate_values(&budgets)?;
self.call_budgets = budgets;
Ok(())
}
/// 发送 Ready 并持续处理调用、回调、流控、取消与关闭
///
/// # Errors
@@ -87,6 +102,7 @@ where
where
H: PluginHandler,
{
let call_budgets = ResolvedCallBudgets::resolve(&self.call_budgets)?;
let data_capacity = self
.config
.maximum_calls
@@ -117,6 +133,7 @@ where
&Frame::control(Message::Ready {
protocol_version: PROTOCOL_VERSION,
incarnation: self.handshake.incarnation.clone(),
call_budgets: self.call_budgets,
}),
)
.await?;
@@ -124,6 +141,7 @@ where
let (completed, completions) = mpsc::channel(self.config.maximum_calls);
let mut driver = SessionDriver {
config: self.config,
call_budgets,
instance_id: self.handshake.instance_id,
generation: self.handshake.generation,
incarnation: self.handshake.incarnation,
@@ -200,6 +218,7 @@ impl Drop for CompletionGuard {
struct SessionDriver {
config: SessionConfig,
call_budgets: ResolvedCallBudgets,
instance_id: String,
generation: u64,
incarnation: String,
@@ -323,7 +342,7 @@ impl SessionDriver {
|| context.generation != self.generation
|| context.incarnation != self.incarnation
|| context.timeout_ms == 0
|| Duration::from_millis(context.timeout_ms) > self.config.maximum_call_timeout
|| Duration::from_millis(context.timeout_ms) > self.call_budgets.budget(context.stage)
|| method.is_empty()
|| method.len() > 128
{
@@ -1,12 +1,14 @@
//! 插件侧传输和会话错误及稳定业务错误投影
use crate::{ErrorCode, FrameError, PluginFault};
use crate::{CallBudgetError, ErrorCode, FrameError, PluginFault};
/// 插件侧传输和会话错误
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("plugin session configuration is invalid")]
Configuration,
#[error(transparent)]
CallBudget(#[from] CallBudgetError),
#[error("plugin handshake is invalid")]
Handshake,
#[error("plugin protocol is invalid")]
@@ -36,7 +38,11 @@ impl SessionError {
Self::Timeout => PluginFault::new(ErrorCode::Timeout, "host callback timed out"),
Self::Cancelled => PluginFault::new(ErrorCode::Cancelled, "parent call was cancelled"),
Self::Capacity => PluginFault::new(ErrorCode::Capacity, "plugin capacity is exhausted"),
Self::Configuration | Self::Protocol | Self::Handshake | Self::Frame(_) => {
Self::Configuration
| Self::CallBudget(_)
| Self::Protocol
| Self::Handshake
| Self::Frame(_) => {
PluginFault::new(ErrorCode::InvalidInput, "plugin session input is invalid")
}
Self::Closed | Self::HandlerStopped => {
@@ -35,6 +35,13 @@ pub struct HostClient {
}
impl HostClient {
/// 读取父调用剩余处理预算,包含 next 暂停;资源流接管寿命后返回 None
/// 外部请求期限、网络操作期限和取消信号仍独立生效
#[must_use]
pub fn remaining_budget(&self) -> Option<std::time::Duration> {
self.deadline.remaining()
}
pub(crate) fn maximum_stream_chunk_bytes(&self) -> usize {
let window = self.credits.maximum_bytes();
if window == 0 {
@@ -1,5 +1,6 @@
//! 实验性插件线协议;不依赖网关领域或基础设施类型
mod budget;
pub mod call;
mod capability;
#[cfg(feature = "io")]
@@ -9,6 +10,7 @@ mod error;
mod manifest;
mod message;
pub use budget::{CallBudgetError, CallBudgetSource, CallBudgets, ResolvedCallBudgets};
pub use capability::{Capability, ContributionDeclaration, Contributions, FailurePolicy, Stage};
pub use context::{CallContext, Handshake, RequestContext};
pub use error::{ErrorCode, PluginFault, SendState};
@@ -2,9 +2,9 @@
use serde::{Deserialize, Serialize};
use crate::{CallContext, Handshake, PluginFault};
use crate::{CallBudgets, CallContext, Handshake, PluginFault};
pub const PROTOCOL_VERSION: u32 = 5;
pub const PROTOCOL_VERSION: u32 = 6;
/// 元数据与二进制载荷分开,流分块不经过 JSON/base64
#[derive(Clone, PartialEq, Serialize, Deserialize)]
@@ -16,6 +16,7 @@ pub enum Message {
Ready {
protocol_version: u32,
incarnation: String,
call_budgets: CallBudgets,
},
Call {
id: u64,
@@ -0,0 +1,49 @@
//! 验证场景默认预算、插件声明隔离与无效输入边界
use std::time::Duration;
use gateway_plugin_sdk::{
CallBudgetError, CallBudgetSource, CallBudgets, ResolvedCallBudgets, Stage,
};
#[test]
fn explicit_budgets_override_only_their_stage_without_a_global_ceiling() {
let resolved = ResolvedCallBudgets::resolve(&CallBudgets::from([
(Stage::Maintenance, 1_800_000),
(Stage::CommandLine, 600_000),
(Stage::Management, 250),
]))
.unwrap();
for (stage, millis, source) in [
(Stage::Maintenance, 1_800_000, CallBudgetSource::Plugin),
(Stage::CommandLine, 600_000, CallBudgetSource::Plugin),
(Stage::Management, 250, CallBudgetSource::Plugin),
(Stage::Registration, 5_000, CallBudgetSource::Default),
(Stage::Authentication, 30_000, CallBudgetSource::Default),
(Stage::Retry, 2_000, CallBudgetSource::Default),
(Stage::Request, 120_000, CallBudgetSource::Default),
] {
assert_eq!(resolved.budget(stage), Duration::from_millis(millis));
assert_eq!(resolved.source(stage), source);
}
assert_eq!(
ResolvedCallBudgets::resolve(&CallBudgets::new())
.unwrap()
.budget(Stage::Maintenance),
Duration::from_secs(120)
);
}
#[test]
fn invalid_budget_values_fail_before_use() {
assert!(matches!(
ResolvedCallBudgets::resolve(&CallBudgets::from([(Stage::Maintenance, 0)])),
Err(CallBudgetError::Invalid {
stage: Stage::Maintenance,
milliseconds: 0
})
));
assert!(
serde_json::from_str::<CallBudgets>(r#"{"maintenance":18446744073709551616}"#).is_err()
);
}
@@ -13,37 +13,6 @@ use gateway_plugin_sdk::{
};
use serde_json::json;
#[test]
fn provider_parameters_distinguish_preserve_empty_and_invalid_replacement() {
let preserved: MiddlewareNextRequest =
serde_json::from_value(json!({"body":"preserve"})).unwrap();
assert!(preserved.provider_parameters.is_none());
for replacement in [json!({}), json!({"installation_id":null})] {
let request: MiddlewareNextRequest = serde_json::from_value(json!({
"body":"preserve", "provider_parameters":replacement,
}))
.unwrap();
assert_eq!(
request.provider_parameters.as_ref(),
replacement.as_object()
);
}
for invalid in [
json!(null),
json!([]),
json!("invalid"),
json!(42),
json!(false),
] {
assert!(
serde_json::from_value::<MiddlewareNextRequest>(json!({
"body":"preserve", "provider_parameters":invalid,
}))
.is_err()
);
}
}
#[test]
fn settings_replacement_preserves_explicit_null_for_host_validation() {
let preserved: MiddlewareNextRequest =
@@ -75,7 +44,6 @@ fn middleware_capability_and_mounts_have_one_stable_wire_vocabulary() {
#[test]
fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() {
let request = MiddlewareRequestHead {
provider_parameters: None,
settings_sources: serde_json::Value::Null,
settings: serde_json::Value::Null,
client_key_id: "fixture-key".into(),
@@ -113,7 +81,6 @@ fn middleware_request_keeps_body_out_of_json_and_rejects_unknown_fields() {
#[test]
fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers() {
let preserve = MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: None,
header_mutations: Vec::new(),
@@ -126,7 +93,6 @@ fn next_distinguishes_preserve_from_replace_empty_and_uses_incremental_headers()
);
let replace = MiddlewareNextRequest {
provider_parameters: None,
settings: None,
protocol: Some("xai".into()),
header_mutations: vec![
@@ -64,7 +64,6 @@ fn observation() -> RequestCompleted {
client_key_id: Some("key_fixture".into()),
account_id: Some("acct_fixture".into()),
upstream_model: Some("upstream-model".into()),
response_model: Some("reported-model".into()),
service_tier: Some("default".into()),
provider: None,
completed_at_ms: 1234,
@@ -114,19 +113,12 @@ fn observation_exposes_selected_and_reported_facts_without_inventing_values() {
assert_eq!(wire["client_key_id"], "key_fixture");
assert_eq!(wire["account_id"], "acct_fixture");
assert_eq!(wire["upstream_model"], "upstream-model");
assert_eq!(wire["response_model"], "reported-model");
assert_eq!(wire["service_tier"], "default");
request.account_id = None;
request.upstream_model = None;
request.response_model = None;
request.service_tier = None;
let wire = serde_json::to_value(request).unwrap();
for field in [
"account_id",
"upstream_model",
"response_model",
"service_tier",
] {
for field in ["account_id", "upstream_model", "service_tier"] {
assert!(wire.get(field).is_none(), "unknown {field} must be omitted");
}
}
@@ -440,7 +440,6 @@ async fn start_session<H: PluginHandler + 'static>(
maximum_callbacks: 4,
maximum_buffered_stream_chunks: 16,
handshake_timeout: Duration::from_secs(1),
maximum_call_timeout: Duration::from_secs(2),
},
)
.await?
@@ -473,6 +472,7 @@ async fn start_session<H: PluginHandler + 'static>(
message: Message::Ready {
protocol_version: PROTOCOL_VERSION,
ref incarnation,
..
},
ref payload,
} if incarnation == "test-incarnation" && payload.is_empty()
@@ -1,6 +1,86 @@
//! 会话调用分派、并发帧读取与处理器响应边界测试
use super::*;
use gateway_plugin_sdk::CallBudgets;
#[tokio::test(start_paused = true)]
async fn declared_long_budgets_survive_callbacks_and_expose_remaining_time() {
for budget_ms in [600_000, 1_800_000] {
let (host, plugin) = tokio::io::duplex(16_384);
let (plugin_reader, plugin_writer) = tokio::io::split(plugin);
let task = tokio::spawn(async move {
let mut session =
PluginSession::accept(plugin_reader, plugin_writer, SessionConfig::default())
.await?;
let declared = session.handshake().configuration["maintenance_ms"]
.as_u64()
.unwrap();
session.set_call_budgets(CallBudgets::from([(Stage::Maintenance, declared)]))?;
session.run(TestHandler::default()).await
});
let (reader, writer) = tokio::io::split(host);
let mut host = HostPeer { reader, writer };
let mut hello = handshake();
hello.configuration = json!({"maintenance_ms": budget_ms});
send_control(&mut host, Message::Hello { handshake: hello }).await;
let Message::Ready { call_budgets, .. } = receive(&mut host).await.message else {
panic!("expected Ready")
};
assert_eq!(call_budgets[&Stage::Maintenance], budget_ms);
let mut call_context = context(1, Duration::from_millis(budget_ms));
call_context.stage = Stage::Maintenance;
send_control(
&mut host,
Message::Call {
id: 1,
method: "budget".into(),
context: call_context,
params: json!({}),
},
)
.await;
let Message::Callback {
id, parent_id: 1, ..
} = receive(&mut host).await.message
else {
panic!("expected callback")
};
tokio::time::advance(Duration::from_secs(125)).await;
send_control(
&mut host,
Message::Result {
id,
result: json!({}),
},
)
.await;
let Message::Result { id: 1, result } = receive(&mut host).await.message else {
panic!("long call must remain active")
};
assert_eq!(result["initial_ms"], budget_ms);
let remaining = result["remaining_ms"].as_u64().unwrap();
assert!(remaining <= budget_ms - 125_000 && remaining >= budget_ms - 126_000);
shutdown(&mut host, task).await;
}
}
#[tokio::test]
async fn calls_cannot_exceed_the_budget_negotiated_for_their_stage() {
let (mut host, task) = start_session(TestHandler::default()).await;
let mut context = context(1, Duration::from_secs(3));
context.stage = Stage::Retry;
send_control(
&mut host,
Message::Call {
id: 1,
method: "echo".into(),
context,
params: json!({}),
},
)
.await;
assert!(matches!(task.await.unwrap(), Err(SessionError::Protocol)));
}
#[test]
fn middleware_plugin_rejects_declarations_without_matching_handlers() {
@@ -58,6 +58,19 @@ impl PluginHandler for TestHandler {
Box::pin(async move {
match call.method.as_str() {
"echo" => Ok(CallReply::unary(call.params, call.payload)),
"budget" => {
call.host
.call("host.log", json!({}), vec![])
.await
.map_err(SessionError::into_plugin_fault)?;
Ok(CallReply::unary(
json!({
"initial_ms": call.context.timeout_ms,
"remaining_ms": call.host.remaining_budget().map(|value| value.as_millis()),
}),
vec![],
))
}
"key_facts" => {
let result = call
.host
@@ -263,7 +276,6 @@ async fn start_session_with_capacity<H: PluginHandler>(
maximum_callbacks: 4,
maximum_buffered_stream_chunks: 16,
handshake_timeout: Duration::from_secs(1),
maximum_call_timeout: Duration::from_secs(2),
},
)
.await
@@ -296,6 +308,7 @@ async fn start_configured_session<H: PluginHandler>(
message: Message::Ready {
protocol_version: PROTOCOL_VERSION,
ref incarnation,
..
},
ref payload,
} if incarnation == "test-incarnation" && payload.is_empty()
@@ -391,7 +404,6 @@ fn middleware_context(id: u64) -> CallContext {
pub(super) fn middleware_request() -> MiddlewareRequestHead {
MiddlewareRequestHead {
provider_parameters: None,
settings_sources: serde_json::Value::Null,
settings: serde_json::Value::Null,
client_key_id: "fixture-key".into(),
@@ -1,5 +1,6 @@
//! 插件 SDK 线协议、清单与客户端的测试入口
mod budget;
mod call;
#[cfg(feature = "io")]
mod client;
@@ -1,4 +1,4 @@
//! 从 OpenAI 事件与响应头提取模型、用量、额度和重试等待事实
//! 从 OpenAI 事件与响应头提取用量、额度和重试等待事实
use std::{collections::BTreeMap, time::Duration};
@@ -7,56 +7,6 @@ use serde_json::Value;
use super::sse::{SseError, parse_sse_events};
/// 单次 Responses 流明确声明的模型;终态优先,缺失时不使用请求模型补齐
#[derive(Debug, Default)]
pub struct ResponseModelObservation {
model: Option<String>,
terminal: bool,
}
impl ResponseModelObservation {
/// 从原始事件读取响应模型,避免工具转换或 canonical 兜底改写观测事实
pub fn observe(&mut self, event_type: Option<&str>, value: &Value) {
let terminal = matches!(
event_type,
Some(
"response.completed"
| "response.done"
| "response.incomplete"
| "response.failed"
| "response.cancelled"
| "response.canceled"
)
);
if self.terminal || (self.model.is_some() && !terminal) {
return;
}
let Some(model) = value
.pointer("/response/model")
.and_then(Value::as_str)
.and_then(observed_model_name)
else {
return;
};
self.model = Some(model.to_owned());
self.terminal = terminal;
}
/// 返回原始响应声明,未声明时保持未知
#[must_use]
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
}
/// 规范化可展示的上游模型名,非法值不影响响应交付
#[must_use]
pub fn observed_model_name(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty() && value.len() <= 256 && !value.chars().any(char::is_control))
.then_some(value)
}
/// 从 Codex/OpenAI usage 结构中提取出的标准化 token 用量
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -7,50 +7,6 @@ use gateway_protocol::openai::events::{
};
use serde_json::json;
#[test]
fn response_model_observation_keeps_first_declaration_until_terminal() {
use gateway_protocol::openai::events::ResponseModelObservation;
let mut observed = ResponseModelObservation::default();
observed.observe(
Some("response.created"),
&json!({"response": {"model": " first "}}),
);
observed.observe(
Some("response.in_progress"),
&json!({"response": {"model": "intermediate"}}),
);
assert_eq!(observed.model(), Some("first"));
observed.observe(
Some("response.completed"),
&json!({"response": {"model": "final"}}),
);
observed.observe(
Some("response.in_progress"),
&json!({"response": {"model": "late"}}),
);
assert_eq!(observed.model(), Some("final"));
assert_eq!(ResponseModelObservation::default().model(), None);
}
#[test]
fn response_model_observation_ignores_missing_and_invalid_values() {
use gateway_protocol::openai::events::ResponseModelObservation;
for response in [
json!({}),
json!({"model": null}),
json!({"model": 42}),
json!({"model": " "}),
json!({"model": "a\nb"}),
json!({"model": "a".repeat(257)}),
] {
let mut observed = ResponseModelObservation::default();
observed.observe(Some("response.completed"), &json!({"response": response}));
assert_eq!(observed.model(), None);
}
}
#[test]
fn billable_usage_should_validate_cache_writes_totals_and_overflow() {
for (raw, expected) in [
@@ -248,7 +248,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>,
pub provider_metadata_json: Option<Value>,
pub diagnostic_trace_json: Option<Value>,
pub error_kind: Option<String>,
@@ -314,12 +313,6 @@ impl ModelRequestFinalization {
return Err(invalid("service tier is invalid"));
}
}
if let Some(model) = self.upstream_response_model.as_deref() {
require_nonempty(ENTITY, "upstream_response_model", model)?;
if model.len() > 256 || model.chars().any(char::is_control) {
return Err(invalid("upstream response model is invalid"));
}
}
if self
.provider_metadata_json
.as_ref()
@@ -1180,7 +1173,6 @@ impl ExecutionStore for PgExecutionStore {
http_version: finalization.http_version,
websocket_pool: finalization.websocket_pool,
service_tier: finalization.service_tier,
upstream_response_model: finalization.upstream_response_model,
provider_metadata_json,
diagnostic_trace_json: finalization
.diagnostic_trace_json
@@ -21,8 +21,6 @@ pub(super) struct RequestObservation {
#[serde(skip_serializing_if = "Option::is_none")]
transport: Option<TransportObservation>,
#[serde(skip_serializing_if = "Option::is_none")]
response: Option<ResponseObservation>,
#[serde(skip_serializing_if = "Option::is_none")]
continuation: Option<ContinuationObservation>,
}
@@ -146,13 +144,6 @@ struct ConnectionObservation {
idle_ms: Option<u64>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ResponseObservation {
#[serde(skip_serializing_if = "Option::is_none")]
response_model: Option<String>,
}
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct ContinuationObservation {
@@ -263,9 +254,6 @@ impl RequestObservation {
idle_ms: value.upstream_connection_idle_ms,
}),
}),
response: Some(ResponseObservation {
response_model: value.upstream_response_model.clone(),
}),
error: Some(ErrorObservation {
provider_error_code: value.provider_error_code.clone(),
message: value.error_message.clone(),
@@ -849,7 +849,6 @@ impl ExecutionObservationWrite {
finalization.http_version.as_deref(),
finalization.websocket_pool.as_deref(),
finalization.service_tier.as_deref(),
finalization.upstream_response_model.as_deref(),
finalization.provider_metadata_json.as_deref(),
finalization.diagnostic_trace_json.as_deref(),
finalization.provider_error_code.as_deref(),
@@ -329,7 +329,6 @@ pub(crate) fn usage_list_record_from_row(
provider_account_authentication_kind: get(row, "provider_account_authentication_kind")?,
upstream_model_id: get(row, "upstream_model_id")?,
upstream_transport: get(row, "upstream_transport")?,
upstream_response_model: get(row, "upstream_response_model")?,
service_tier: get(row, "service_tier")?,
input_tokens: optional_unsigned(row, "input_tokens")?,
output_tokens: optional_unsigned(row, "output_tokens")?,
@@ -404,7 +403,6 @@ pub(crate) fn usage_record_from_row(row: &sqlx::postgres::PgRow) -> StoreResult<
upstream_transport: get(row, "upstream_transport")?,
http_version: get(row, "http_version")?,
websocket_pool: get(row, "websocket_pool")?,
upstream_response_model: get(row, "upstream_response_model")?,
service_tier: get(row, "service_tier")?,
provider_metadata_json: get::<Option<serde_json::Value>>(row, "provider_observation_json")?
.map(|value| serde_json::to_string(&value))

Some files were not shown because too many files have changed in this diff Show More