From a0e708071a5c032075be81bb42a0cd5292f386a7 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 17 Oct 2026 20:54:41 +0800 Subject: [PATCH] perf(plugins)!: optimize RPC and simplify host boundaries Move business JSON out of control metadata, encode request snapshot headers compactly, and reuse encoded frames. Cache compiled configuration schemas and prepare plugin instances with bounded concurrency. Ignore unused plugin result fields and remove redundant business validation while preserving host authorization, resource ownership, and capacity limits. Keep response decoding failures local to their calls. Seal callback side effects before completing or cancelling RPC calls, and recover abandoned state migrations without allowing old owners to promote. Validation: SDK 112 tests and its doctest, Runtime 203 tests, Core 542 tests, 40 PostgreSQL plugin tests, and 16 architecture checks passed. Strict Clippy, formatting, and diff checks passed. BREAKING CHANGE: RPC protocol v4 changes frame payload layout and request snapshot header encoding. Plugins must update their SDK and protocol manifest version and be rebuilt. Co-authored-by: Codex --- backend/Cargo.lock | 1 + .../gateway-admin/src/model/plugins/state.rs | 4 +- .../tests/use_case/plugins/instances.rs | 1 + .../crates/gateway-core/src/engine/policy.rs | 9 + backend/crates/gateway-core/src/lib.rs | 5 +- .../gateway-core/src/routing/snapshot.rs | 42 --- .../tests/engine/execution/mod.rs | 52 +++- .../gateway-core/tests/routing/snapshot.rs | 68 ++++- .../runtime/plugin-host-compatibility.json | 2 +- .../runtime/src/adapter/catalog.rs | 13 +- .../src/adapter/command_line/execution.rs | 4 +- .../src/adapter/command_line/parameters.rs | 57 +---- .../src/adapter/command_line/registration.rs | 14 +- .../src/adapter/frontend_authentication.rs | 7 - .../src/adapter/management/registration.rs | 2 +- .../runtime/src/adapter/middleware/request.rs | 9 +- .../runtime/src/adapter/middleware/service.rs | 4 - .../src/adapter/middleware/websocket.rs | 3 +- .../runtime/src/adapter/observer.rs | 22 +- .../runtime/src/adapter/policy/retry.rs | 21 +- .../src/adapter/policy/route_schedule.rs | 49 +--- .../src/adapter/upstream_adapter/execution.rs | 9 +- .../src/adapter/upstream_adapter/mod.rs | 36 +-- .../adapter/upstream_adapter/registration.rs | 39 +-- .../src/adapter/upstream_adapter/target.rs | 54 +--- .../runtime/src/callback/call_resources.rs | 42 ++- .../runtime/src/callback/http.rs | 2 +- .../runtime/src/callback/http_dispatch.rs | 2 +- .../runtime/src/callback/mod.rs | 10 +- .../runtime/src/callback/scope.rs | 43 ++-- .../runtime/src/callback/services.rs | 15 +- .../runtime/src/generation/configuration.rs | 65 ++++- .../src/generation/prepare/instance.rs | 14 +- .../src/generation/prepare/maintenance.rs | 8 +- .../runtime/src/generation/prepare/runtime.rs | 5 +- .../src/generation/prepare/snapshot.rs | 100 +++++--- .../runtime/src/rpc/dispatch.rs | 12 +- .../gateway-plugin/runtime/src/rpc/session.rs | 25 +- .../runtime/tests/adapter/command_line/mod.rs | 5 +- .../tests/adapter/command_line/parameters.rs | 18 +- .../runtime/tests/adapter/management/mod.rs | 11 +- .../tests/generation/authentication.rs | 14 +- .../runtime/tests/generation/maintenance.rs | 22 +- .../runtime/tests/generation/observer.rs | 12 +- .../tests/generation/policy/compatibility.rs | 10 +- .../tests/generation/policy/http/mod.rs | 54 +++- .../runtime/tests/generation/policy/mod.rs | 11 +- .../runtime/tests/generation/policy/retry.rs | 229 +++++++++++++++++ .../tests/generation/prepare/instance.rs | 44 ++++ .../tests/generation/prepare/snapshot.rs | 23 +- .../tests/generation/upstream_adapter.rs | 47 +++- .../runtime/tests/rpc/session.rs | 132 +++++----- .../runtime/tests/support/environment.rs | 33 ++- .../runtime/tests/support/worker/mod.rs | 61 ++++- backend/crates/gateway-plugin/sdk/Cargo.toml | 1 + backend/crates/gateway-plugin/sdk/README.md | 17 +- .../gateway-plugin/sdk/docs/capabilities.md | 18 +- .../gateway-plugin/sdk/docs/manifest.md | 4 +- .../sdk/docs/upstream-adapters.md | 12 +- .../gateway-plugin/sdk/src/call/catalog.rs | 2 - .../sdk/src/call/frontend_authentication.rs | 3 +- .../gateway-plugin/sdk/src/call/management.rs | 17 +- .../sdk/src/call/middleware/http.rs | 3 +- .../sdk/src/call/middleware/mod.rs | 7 +- .../sdk/src/call/middleware/websocket.rs | 5 +- .../gateway-plugin/sdk/src/call/policy.rs | 6 +- .../sdk/src/call/registration.rs | 1 - .../sdk/src/call/services/mod.rs | 1 - .../sdk/src/call/upstream_adapter/codec.rs | 1 - .../sdk/src/call/upstream_adapter/mod.rs | 7 +- .../gateway-plugin/sdk/src/client/frame.rs | 110 ++++++-- .../sdk/src/client/middleware/plugin.rs | 2 +- .../sdk/src/client/session/driver.rs | 77 +++--- .../sdk/src/client/session/output.rs | 10 +- .../sdk/src/client/session/registry.rs | 8 +- .../gateway-plugin/sdk/src/context/headers.rs | 74 ++++++ .../sdk/src/{context.rs => context/mod.rs} | 6 + .../crates/gateway-plugin/sdk/src/message.rs | 27 +- .../sdk/tests/call/frontend_authentication.rs | 9 +- .../sdk/tests/call/management.rs | 35 ++- .../sdk/tests/call/middleware/mod.rs | 5 + .../gateway-plugin/sdk/tests/call/policy.rs | 22 +- .../gateway-plugin/sdk/tests/client/frame.rs | 174 ++++++++++++- .../sdk/tests/client/session/driver.rs | 13 +- .../sdk/tests/client/session/mod.rs | 2 +- .../gateway-plugin/sdk/tests/manifest.rs | 3 +- backend/crates/gateway-store/src/bundle.rs | 3 + .../src/postgres/plugins/artifacts.rs | 9 +- .../src/postgres/plugins/state.rs | 91 ++++++- .../tests/postgres/plugins/state.rs | 242 +++++++++++++++++- docs/architecture.md | 16 +- docs/plugins.md | 4 +- 92 files changed, 1854 insertions(+), 789 deletions(-) create mode 100644 backend/crates/gateway-plugin/runtime/tests/generation/policy/retry.rs create mode 100644 backend/crates/gateway-plugin/sdk/src/context/headers.rs rename backend/crates/gateway-plugin/sdk/src/{context.rs => context/mod.rs} (93%) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e307c015d..61f701932 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1878,6 +1878,7 @@ dependencies = [ name = "gateway-plugin-sdk" version = "0.1.0" dependencies = [ + "base64 0.23.1", "semver", "serde", "serde_json", diff --git a/backend/crates/gateway-admin/src/model/plugins/state.rs b/backend/crates/gateway-admin/src/model/plugins/state.rs index f4c8b2bbb..31dc1c2c4 100644 --- a/backend/crates/gateway-admin/src/model/plugins/state.rs +++ b/backend/crates/gateway-admin/src/model/plugins/state.rs @@ -1,6 +1,6 @@ //! 插件私有状态的归属、代次隔离与迁移数据合同 -use std::{collections::BTreeMap, fmt}; +use std::{collections::BTreeMap, fmt, sync::Arc}; use serde::{Deserialize, Serialize}; @@ -173,6 +173,8 @@ pub struct PluginStateTransition { pub instance_id: String, pub artifact_sha256: String, pub namespaces: Vec, + /// Store 持有的迁移所有权;最后一个调用持有者释放后允许回收遗留 staging + pub guard: Arc, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs index c29ad1b9a..bec8de7fd 100644 --- a/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs +++ b/backend/crates/gateway-admin/tests/use_case/plugins/instances.rs @@ -434,6 +434,7 @@ impl PluginStateStore for LifecycleFixture { from_schema_version: 1, to_schema_version: 2, }], + guard: Arc::new(()), }) } diff --git a/backend/crates/gateway-core/src/engine/policy.rs b/backend/crates/gateway-core/src/engine/policy.rs index cabb2cb34..70170e2d9 100644 --- a/backend/crates/gateway-core/src/engine/policy.rs +++ b/backend/crates/gateway-core/src/engine/policy.rs @@ -281,6 +281,14 @@ pub struct RetryInput { pub account_group_ids: Arc<[AccountGroupId]>, pub extension_scope: ExtensionCallScope, pub facts: RetryFacts, + execution_effects: Arc, +} + +impl RetryInput { + #[must_use] + pub fn execution_effects(&self) -> Arc { + Arc::clone(&self.execution_effects) + } } /// 策略调用失败不携带插件原始消息,避免跨层泄漏不可信诊断 @@ -353,6 +361,7 @@ impl RequestPolicyContext { account_group_ids: self.account_group_ids.clone(), extension_scope: self.extension_scope.clone(), facts, + execution_effects: Arc::clone(&self.execution_effects), }) .await .unwrap_or_else(|_| { diff --git a/backend/crates/gateway-core/src/lib.rs b/backend/crates/gateway-core/src/lib.rs index 60a27d7c0..75607fc63 100644 --- a/backend/crates/gateway-core/src/lib.rs +++ b/backend/crates/gateway-core/src/lib.rs @@ -359,10 +359,7 @@ impl CoreControlPlaneStartup { match self.publisher.refresh().await { Ok(_) => {} // 故障插件不能封锁管理修复入口;没有可用快照时数据面仍拒绝新请求 - Err( - routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable - | routing::snapshot::RuntimeSnapshotCompileError::InvalidExtensionModels, - ) => {} + Err(routing::snapshot::RuntimeSnapshotCompileError::ExtensionsUnavailable) => {} Err(_) => return Err(CoreError::SnapshotUnavailable), } Ok(CoreControlPlaneBundle { diff --git a/backend/crates/gateway-core/src/routing/snapshot.rs b/backend/crates/gateway-core/src/routing/snapshot.rs index 985f93ecd..0e080981f 100644 --- a/backend/crates/gateway-core/src/routing/snapshot.rs +++ b/backend/crates/gateway-core/src/routing/snapshot.rs @@ -225,8 +225,6 @@ pub enum RuntimeSnapshotCompileError { RevisionChanged, #[error("runtime snapshot contains invalid frozen data")] InvalidData, - #[error("extension model aliases conflict with the provider catalog or model mappings")] - InvalidExtensionModels, #[error("provider model catalog changed while the snapshot was compiling")] CatalogChanged, } @@ -378,7 +376,6 @@ impl RuntimeSnapshotCompiler { observed_generations }) .with_extensions(extensions); - snapshot.validate_extension_models()?; return Ok(snapshot); } } @@ -655,45 +652,6 @@ impl RuntimeSnapshot { .find(|alias| alias.id.as_str() == model) } - fn validate_extension_models(&self) -> Result<(), RuntimeSnapshotCompileError> { - let mut ids = BTreeSet::new(); - for alias in self.model_aliases() { - let valid = ids.insert(&alias.id) - && self.providers.contains(&alias.provider) - && !self - .settings - .values - .model_mappings - .contains_key(alias.id.as_str()) - && !self - .settings - .values - .model_mappings - .contains_key(alias.target.as_str()) - && !self - .settings - .values - .model_mappings - .values() - .any(|target| target == alias.id.as_str()) - && self.model_alias(alias.target.as_str()).is_none() - && !self.provider_models.values().any(|models| { - models - .keys() - .any(|model| model.as_str() == alias.id.as_str()) - }) - && self - .provider_models - .get(&alias.provider) - .is_some_and(|models| models.contains_key(&alias.target)); - if !valid { - tracing::warn!(owner = %alias.owner, model = %alias.id, "插件模型别名与宿主目录或映射冲突,拒绝发布候选快照"); - return Err(RuntimeSnapshotCompileError::InvalidExtensionModels); - } - } - Ok(()) - } - #[must_use] pub fn responses_max_decompressed_body_bytes(&self) -> usize { self.settings.responses_max_decompressed_body_bytes.get() diff --git a/backend/crates/gateway-core/tests/engine/execution/mod.rs b/backend/crates/gateway-core/tests/engine/execution/mod.rs index 1a2169f5e..3355865b4 100644 --- a/backend/crates/gateway-core/tests/engine/execution/mod.rs +++ b/backend/crates/gateway-core/tests/engine/execution/mod.rs @@ -780,6 +780,8 @@ struct FixedRoutingPolicy { struct RetryTestPolicy { decision: gateway_core::engine::policy::RetryDecision, facts: Arc>>, + observe_effect: bool, + fail: bool, } impl RequestPolicyPlan for RetryTestPolicy { @@ -800,9 +802,19 @@ impl RequestPolicyPlan for RetryTestPolicy { input: gateway_core::engine::policy::RetryInput, ) -> BoxFuture<'static, Result> { + if self.observe_effect { + input.execution_effects().observe(); + } self.facts.lock().unwrap().push(input.facts); let decision = self.decision; - Box::pin(async move { Ok(decision) }) + let fail = self.fail; + Box::pin(async move { + if fail { + Err(RequestPolicyFault) + } else { + Ok(decision) + } + }) } } @@ -853,11 +865,21 @@ impl Provider for RetryTestProvider { #[test] fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { use gateway_core::engine::policy::RetryDecision; - for (decision, send_state, replay_safe, expected_allowed, expected_calls) in [ + for ( + decision, + send_state, + replay_safe, + observe_effect, + fail, + expected_allowed, + expected_calls, + ) in [ ( RetryDecision::Stop, UpstreamSendState::NotSent, true, + false, + false, true, 1, ), @@ -865,6 +887,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { RetryDecision::Retry, UpstreamSendState::NotSent, true, + false, + false, true, 2, ), @@ -873,6 +897,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { UpstreamSendState::Ambiguous, true, false, + false, + false, 1, ), ( @@ -880,6 +906,26 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { UpstreamSendState::Sent, false, false, + false, + false, + 1, + ), + ( + RetryDecision::Retry, + UpstreamSendState::NotSent, + true, + true, + false, + true, + 1, + ), + ( + RetryDecision::Delegate, + UpstreamSendState::NotSent, + true, + true, + true, + true, 1, ), ] { @@ -903,6 +949,8 @@ fn retry_policy_can_stop_but_cannot_bypass_replay_safety() { Arc::new(RetryTestPolicy { decision, facts: facts.clone(), + observe_effect, + fail, }), ) .unwrap(); diff --git a/backend/crates/gateway-core/tests/routing/snapshot.rs b/backend/crates/gateway-core/tests/routing/snapshot.rs index 26b7ddbe8..f19ca164f 100644 --- a/backend/crates/gateway-core/tests/routing/snapshot.rs +++ b/backend/crates/gateway-core/tests/routing/snapshot.rs @@ -202,12 +202,16 @@ fn contributed_alias_uses_the_same_target_for_listing_profiles_and_routing() { } #[test] -fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_publication() { +fn contributed_alias_business_data_does_not_block_publication_or_native_routing() { + let mut unknown_provider = alias("plugin-model", "upstream-model"); + unknown_provider.provider = ProviderKind::new("unregistered").unwrap(); for aliases in [ vec![alias("upstream-model", "upstream-model")], vec![alias("configured", "upstream-model")], vec![alias("plugin-model", "configured")], vec![alias("plugin-model", "missing")], + vec![alias("plugin-model", "plugin-model")], + vec![unknown_provider], vec![ alias("plugin-model", "other"), alias("other", "plugin-model"), @@ -217,13 +221,71 @@ fn contributed_alias_rejects_collisions_chains_and_missing_targets_before_public alias("plugin-model", "upstream-model"), ], ] { + let count = aliases.len(); + let handle = + gateway_core::runtime::RuntimeSnapshotHandle::new(compile_aliases(vec![]).unwrap()); + handle.publish( + compile_aliases(aliases).expect("plugin business data must not prevent publication"), + ); + let snapshot = handle.acquire().unwrap(); + assert_eq!(snapshot.extensions().unwrap().model_aliases().len(), count); + let native = PublicModelId::new("upstream-model").unwrap(); + assert!(snapshot.contains_public_model_for_scope(&native, &snapshot.all_account_scope())); + let plan = snapshot + .plan( + &native, + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .unwrap(); + assert_eq!(plan.candidates()[0].provider().as_str(), "alpha"); assert_eq!( - compile_aliases(aliases).unwrap_err(), - RuntimeSnapshotCompileError::InvalidExtensionModels + plan.candidates()[0].upstream_model().unwrap().as_str(), + "upstream-model" ); } } +#[test] +fn contributed_alias_is_resolved_once_and_preserves_the_first_matching_entry() { + let snapshot = compile_aliases(vec![ + alias("duplicate", "first-target"), + alias("duplicate", "upstream-model"), + alias("self", "self"), + alias("left", "right"), + alias("right", "left"), + alias("configured", "plugin-target"), + alias("to-configured", "configured"), + ]) + .unwrap(); + assert_eq!(snapshot.mapped_model("duplicate"), "first-target"); + assert_eq!(snapshot.mapped_model("self"), "self"); + assert_eq!(snapshot.mapped_model("left"), "right"); + assert_eq!(snapshot.mapped_model("right"), "left"); + assert_eq!(snapshot.mapped_model("configured"), "plugin-target"); + assert_eq!(snapshot.mapped_model("to-configured"), "configured"); +} + +#[test] +fn contributed_alias_unknown_provider_is_a_request_routing_error() { + let mut unknown_provider = alias("plugin-model", "upstream-model"); + unknown_provider.provider = ProviderKind::new("unregistered").unwrap(); + let snapshot = compile_aliases(vec![unknown_provider]).unwrap(); + let model = PublicModelId::new("plugin-model").unwrap(); + assert!(!snapshot.contains_public_model_for_scope(&model, &snapshot.all_account_scope())); + assert!( + snapshot + .plan( + &model, + &super::operation(), + snapshot.all_account_scope(), + &Default::default(), + ) + .is_err() + ); +} + impl ExtensionPreparationPort for CountingExtensionPreparation { fn prepare( &self, diff --git a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json index b908fdd2e..aa469fbb2 100644 --- a/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json +++ b/backend/crates/gateway-plugin/runtime/plugin-host-compatibility.json @@ -1,7 +1,7 @@ { "schema_version": 2, "manifest_schema_versions": [2], - "protocol_versions": [3], + "protocol_versions": [4], "capabilities": [ { "capability": "frontend_authentication", "versions": [2] }, { "capability": "scheduler", "versions": [3] }, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs index 614b84137..926af5bcd 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/catalog.rs @@ -1,6 +1,6 @@ //! 编译插件模型目录贡献并校验能力绑定 -use std::{collections::BTreeSet, time::Duration}; +use std::time::Duration; use gateway_admin::model::{ AdminError, @@ -29,7 +29,7 @@ pub(crate) async fn prepare( ) .await .map_err(|_| AdminError::invalid("插件模型目录注册失败"))?; - if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 { + if reply.payload.len() > 64 * 1024 { return Err(AdminError::invalid("插件模型目录信封无效或过大")); } let registration: ModelCatalogRegistration = serde_json::from_slice(&reply.payload) @@ -37,21 +37,12 @@ pub(crate) async fn prepare( if registration.models.len() > 256 { return Err(AdminError::invalid("单个插件最多贡献 256 个模型别名")); } - let mut ids = BTreeSet::new(); registration .models .into_iter() .map(|model| { - if !matches!(model.provider.as_str(), "openai" | "xai") || model.id == model.model { - return Err(AdminError::invalid( - "模型别名必须指向内置 Provider 的不同上游模型", - )); - } let id = PublicModelId::new(model.id) .map_err(|_| AdminError::invalid("模型别名 ID 无效"))?; - if !ids.insert(id.clone()) { - return Err(AdminError::invalid(format!("插件模型别名重复:{id}"))); - } Ok(ContributedModelAlias { owner: instance.id.clone(), id, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/execution.rs b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/execution.rs index 8c6151e9c..6a02c9ce5 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/execution.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/execution.rs @@ -96,9 +96,7 @@ impl PluginCommand { ) .await .map_err(rpc_failure)?; - if reply.result != serde_json::json!({}) - || reply.payload.len() > limits.maximum_buffered_body_bytes - { + if reply.payload.len() > limits.maximum_buffered_body_bytes { self.session.invalid_response(Stage::CommandLine); return Err("结果信封无效"); } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/parameters.rs b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/parameters.rs index 6675911b9..9629ce7ea 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/parameters.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/parameters.rs @@ -1,6 +1,6 @@ -//! 插件命令行参数定义校验、输入解析与帮助文本生成 +//! 按插件声明解析命令行输入并生成帮助文本 -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use gateway_admin::model::AdminError; use gateway_plugin_sdk::call::management::{ @@ -9,35 +9,6 @@ use gateway_plugin_sdk::call::management::{ const MAXIMUM_ARGUMENT_BYTES: usize = 64 * 1024; -pub(super) fn validate(command: &CommandDescriptor) -> Result<(), AdminError> { - let mut names = BTreeSet::new(); - if !identifier(&command.name) - || matches!( - command.name.as_str(), - "help" | "version" | "serve" | "plugin" - ) - || !description(&command.description) - || command.parameters.len() > 64 - { - return Err(AdminError::invalid("插件命令描述不合法")); - } - for parameter in &command.parameters { - if !identifier(¶meter.name) - || matches!(parameter.name.as_str(), "help" | "version") - || !description(¶meter.description) - || !names.insert(¶meter.name) - || parameter.default.as_ref().is_some_and(|value| { - !matches_type(value, parameter.value_type) - || parameter.required - || matches!(value, CommandValue::String(value) if value.len() > MAXIMUM_ARGUMENT_BYTES) - }) - { - return Err(AdminError::invalid("插件命令参数、默认值或名称冲突")); - } - } - Ok(()) -} - pub(super) fn parse( command: &CommandDescriptor, arguments: &[String], @@ -150,30 +121,6 @@ fn parse_value(value: &str, kind: CommandParameterType) -> Option }) } -fn matches_type(value: &CommandValue, kind: CommandParameterType) -> bool { - match (value, kind) { - (CommandValue::Bool(_), CommandParameterType::Bool) - | (CommandValue::String(_), CommandParameterType::String) - | (CommandValue::Int(_), CommandParameterType::Int) - | (CommandValue::Int64(_), CommandParameterType::Int64) - | (CommandValue::Duration(_), CommandParameterType::Duration) => true, - (CommandValue::Float64(value), CommandParameterType::Float64) => value.is_finite(), - _ => false, - } -} - -fn identifier(value: &str) -> bool { - (1..=64).contains(&value.len()) - && value.as_bytes()[0].is_ascii_lowercase() - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') - }) -} - -fn description(value: &str) -> bool { - !value.trim().is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) -} - fn invalid_argument() -> AdminError { // 不回显用户参数;即使未声明 sensitive,参数也可能包含凭据 AdminError::invalid("插件命令参数未知、重复、缺值或类型不正确;请查看 --help") diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/registration.rs b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/registration.rs index 4e91431d7..573cee3d3 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/command_line/registration.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/command_line/registration.rs @@ -1,6 +1,6 @@ //! 校验插件命令行注册声明并准备可执行命令 -use super::{PluginCommand, parameters}; +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}; @@ -29,22 +29,20 @@ pub(crate) async fn prepare( ) .await .map_err(|_| AdminError::invalid("插件命令注册失败"))?; - if reply.result != serde_json::json!({}) || reply.payload.len() > MAXIMUM_REGISTRATION_BYTES { + if reply.payload.len() > MAXIMUM_REGISTRATION_BYTES { return Err(AdminError::invalid("插件命令注册信封无效或过大")); } let registration: CommandRegistration = serde_json::from_slice(&reply.payload) .map_err(|_| AdminError::invalid("插件命令注册数据无效"))?; - if registration.commands.is_empty() || registration.commands.len() > 32 { - return Err(AdminError::invalid("每个插件须注册 1 至 32 个命令")); + if registration.commands.len() > 32 { + return Err(AdminError::invalid("每个插件最多注册 32 个命令")); } - let mut names = std::collections::BTreeSet::new(); registration .commands .into_iter() .map(|descriptor| { - parameters::validate(&descriptor)?; - if !names.insert(descriptor.name.clone()) { - return Err(AdminError::invalid("插件命令名重复")); + if descriptor.parameters.len() > 64 { + return Err(AdminError::invalid("插件命令参数超过容量限制")); } Ok(Arc::new(PluginCommand { instance_id: instance.id.clone(), diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/frontend_authentication.rs b/backend/crates/gateway-plugin/runtime/src/adapter/frontend_authentication.rs index 03ab4a677..3bdfaa88b 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/frontend_authentication.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/frontend_authentication.rs @@ -53,9 +53,6 @@ pub(crate) async fn prepare_entry( ) .await .map_err(|_| AdminError::invalid("插件入口认证器注册失败"))?; - if !reply.payload.is_empty() { - return Err(AdminError::invalid("插件入口认证器注册结果无效")); - } let identifier: FrontendAuthenticationIdentifier = serde_json::from_value(reply.result) .map_err(|_| AdminError::invalid("插件入口认证器注册结果无效"))?; if !valid_identifier(&identifier.identifier) { @@ -217,10 +214,6 @@ impl FrontendAuthenticationPlan for PluginFrontendAuthenticationPlan { ) .await .map_err(|_| FrontendAuthenticationError)?; - if reply.result != serde_json::json!({}) { - session.invalid_response(Stage::Authentication); - return Err(FrontendAuthenticationError); - } let result: FrontendAuthenticationResult = serde_json::from_slice(&reply.payload) .map_err(|_| { session.invalid_response(Stage::Authentication); diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs b/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs index 7bb6e252a..69dbbebd8 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/management/registration.rs @@ -40,7 +40,7 @@ pub(crate) async fn prepare( ) .await .map_err(|_| AdminError::invalid("插件管理扩展注册失败"))?; - if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 { + if reply.payload.len() > 64 * 1024 { return Err(AdminError::invalid("插件管理注册信封无效或过大")); } let registration: ManagementRegistration = serde_json::from_slice(&reply.payload) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs index 89569899c..de4fcb0e5 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/request.rs @@ -129,7 +129,7 @@ pub(super) async fn invoke( headers, }; let stage = call_context.stage; - let stream = tokio::select! { + let mut stream = tokio::select! { biased; () = context.cancellation().cancelled() => return Err(MiddlewareError::Fault), () = context.deadline().wait() => return Err(gateway_core::error::GatewayError::new( @@ -146,12 +146,9 @@ pub(super) async fn invoke( Err(error) => return recover_rpc(&entry, &invocation, error).await, } }; - if !stream.initial.payload.is_empty() { - drop(stream); - return recover_invalid(&entry, &invocation, stage).await; - } + stream.initial.payload = Vec::new(); let response: MiddlewareResponseHead = - match serde_json::from_value(stream.initial.result.clone()) { + match serde_json::from_value(std::mem::take(&mut stream.initial.result)) { Ok(response) => response, Err(_) => { drop(stream); diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/service.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/service.rs index 9fce87036..9a74b1a46 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/service.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/service.rs @@ -58,10 +58,6 @@ pub(super) async fn invoke( core::Error {kind:"unavailable".into(),message:fault.message.clone(),details:Some(serde_json::json!(fault))} })?, }; - if !reply.payload.is_empty() { - ports.session.invalid_response(Stage::Service); - return Err(core::Error::invalid("服务返回了意外的二进制正文")); - } let response: wire::Response = ports .session .decode_response(Stage::Service, reply.result) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/websocket.rs b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/websocket.rs index a87902ed3..f1d5b6568 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/middleware/websocket.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/middleware/websocket.rs @@ -79,7 +79,6 @@ pub(super) async fn invoke( .decode(message, reply.payload) .map(Some) .map_err(|_| invalid()), - None if reply.payload.is_empty() => Ok(None), - None => Err(invalid()), + None => Ok(None), } } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs index 35638b957..d64a06da6 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/observer.rs @@ -74,30 +74,12 @@ impl ObserverEntry { ); return; }; - match self + if let Err(error) = self .session .call("observer.observe", context, params, payload) .await { - Ok(reply) - if reply.payload.is_empty() - && (reply.result.is_null() - || reply - .result - .as_object() - .is_some_and(serde_json::Map::is_empty)) => {} - Ok(_) => { - self.session.invalid_response(Stage::Observation); - tracing::warn!( - plugin_id = self.plugin_id, - instance_id = self.instance_id, - request_id, - "插件观察返回了无效结果" - ); - } - Err(error) => { - tracing::warn!(plugin_id = self.plugin_id, instance_id = self.instance_id, request_id, %error, "插件观察失败") - } + tracing::warn!(plugin_id = self.plugin_id, instance_id = self.instance_id, request_id, %error, "插件观察失败") } } } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs index ebde2abb4..8b44ca61f 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/retry.rs @@ -85,7 +85,13 @@ async fn call( context.attempt_id = Some(format!("{}:{}", request.request_id, request.attempt_index)); let _scope = invocation .callbacks - .prepare_extensions(&context, input.extension_scope.clone()) + .prepare_data_plane( + &context, + Some(input.execution_effects()), + input.extension_scope.clone(), + None, + gateway_core::lifecycle::CancellationToken::new(), + ) .map_err(|_| ())?; let reply = invocation .session @@ -97,15 +103,7 @@ async fn call( ) .await .map_err(|_| ())?; - if !reply.payload.is_empty() - || reply - .result - .as_object() - .is_none_or(|fields| fields.len() != 1) - { - invocation.session.invalid_response(Stage::Retry); - return Err(()); - } + // 重试资格由 Core 在策略返回后裁决,适配器只搬运插件决定 match invocation .session .decode_response::(Stage::Retry, reply.result) @@ -113,7 +111,6 @@ async fn call( { WireDecision::Delegate => Ok(RetryDecision::Delegate), WireDecision::Stop => Ok(RetryDecision::Stop), - WireDecision::Retry if input.facts.retry_allowed => Ok(RetryDecision::Retry), - WireDecision::Retry => Err(()), + WireDecision::Retry => Ok(RetryDecision::Retry), } } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs b/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs index 2c28fd331..62e6cdec7 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/policy/route_schedule.rs @@ -149,10 +149,6 @@ async fn route_with_entry( ) .await .map_err(|_| ())?; - if !reply.payload.is_empty() { - invocation.session.invalid_response(Stage::Routing); - return Err(()); - } let decision: WireModelRouteDecision = invocation .session .decode_response(Stage::Routing, reply.result) @@ -161,18 +157,9 @@ async fn route_with_entry( WireModelRouteDecision::Unhandled => Ok(ModelRouteDecision::Unhandled), WireModelRouteDecision::Reject => Ok(ModelRouteDecision::Reject), WireModelRouteDecision::Route { provider, model } => { - if provider.is_none() && model.is_none() { - return Err(()); - } let provider = provider .map(|provider| ProviderKind::new(provider).map_err(|_| ())) .transpose()?; - if provider - .as_ref() - .is_some_and(|provider| !input.available_providers().contains(provider)) - { - return Err(()); - } let model = model .map(|model| PublicModelId::new(model).map_err(|_| ())) .transpose()?; @@ -237,10 +224,6 @@ async fn schedule_with_entry( ) .await .map_err(|_| ())?; - if !reply.payload.is_empty() { - invocation.session.invalid_response(Stage::Scheduling); - return Err(()); - } let decision: WireAccountScheduleDecision = invocation .session .decode_response(Stage::Scheduling, reply.result) @@ -251,27 +234,15 @@ async fn schedule_with_entry( WireAccountScheduleDecision::DelegateFiltered { exclude_account_ids, } => { - let candidate_ids = candidates - .iter() - .map(|candidate| candidate.account_id()) - .collect::>(); let excluded = exclude_account_ids .into_iter() - .map(|id| { - let id = ProviderAccountId::new(id).map_err(|_| ())?; - candidate_ids.contains(&id).then_some(id).ok_or(()) - }) - .collect::>()?; + .map(ProviderAccountId::new) + .collect::>() + .map_err(|_| ())?; Ok(AccountScheduleDecision::DelegateFiltered(excluded)) } WireAccountScheduleDecision::Pick { account_id } => { let account_id = ProviderAccountId::new(account_id).map_err(|_| ())?; - if !candidates - .iter() - .any(|candidate| candidate.account_id() == &account_id && candidate.available()) - { - return Err(()); - } Ok(AccountScheduleDecision::Pick(account_id)) } } @@ -393,12 +364,7 @@ fn project_headers( let pair = entry.as_array().filter(|pair| pair.len() == 2).ok_or(())?; let name = normalized_header_name(pair[0].as_str().ok_or(())?)?; let value_base64 = pair[1].as_str().ok_or(())?; - if value_base64.len() > MAX_HEADER_VALUE_BYTES - || value_base64.len() % 4 != 0 - || !value_base64 - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')) - { + if value_base64.len() > MAX_HEADER_VALUE_BYTES { return Err(()); } total_bytes = total_bytes @@ -418,12 +384,7 @@ fn project_headers( fn normalized_header_name(name: &str) -> Result { let name = name.to_ascii_lowercase(); - if name.is_empty() - || name.len() > MAX_HEADER_NAME_BYTES - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&byte)) - { + if name.len() > MAX_HEADER_NAME_BYTES { return Err(()); } Ok(name) diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs index 4f86cb700..71a3e7ddb 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/execution.rs @@ -247,15 +247,14 @@ impl Execution { ) .map_err(|source| invalid().with_source(source))?; invocation.context.trace().record("plugin.upstream", serde_json::json!({ "instanceId": self.adapter.instance_id, "adapterId": declaration.id, "generation": context.generation, "transport": declaration.transport.as_str() })); - let stream = tokio::select! { + let mut stream = tokio::select! { biased; () = invocation.context.cancellation().cancelled() => return Err(ProviderError::new(ProviderErrorKind::Cancelled, managed.send_state.get())), result = self.adapter.session.call_stream(EXECUTE_METHOD, context.clone(), serde_json::json!({}), payload) => result.map_err(|error| rpc_error(error, managed.send_state.get()))?, }; - if stream.initial.result != serde_json::json!({}) || !stream.initial.payload.is_empty() { - self.adapter.session.invalid_response(Stage::Upstream); - return Err(super::failure::invalid(managed.send_state.get())); - } + // 后续只消费事件流,初始附带数据不应随长连接保留 + stream.initial.result = serde_json::Value::Null; + stream.initial.payload = Vec::new(); Ok(ActiveCall { context, stream, diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs index 1c287dd9c..57c38833e 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/mod.rs @@ -8,7 +8,6 @@ pub(crate) mod target; use std::{fmt, sync::Arc}; -use gateway_admin::model::AdminError; use gateway_core::{ engine::{ AttemptContext, @@ -71,22 +70,8 @@ pub(crate) struct PluginUpstreamAdapterPlan { } impl PluginUpstreamAdapterPlan { - pub(crate) fn compile( - entries: Vec, - ) -> Result>, AdminError> { - for (index, left) in entries.iter().enumerate() { - for right in &entries[index + 1..] { - if left.scope.overlaps(&right.scope) - && adapters_overlap(left.adapter.as_deref(), right.adapter.as_deref()) - { - return Err(AdminError::invalid( - "上游适配器的 Provider 与模型绑定范围重叠", - )); - } - } - } - Ok((!entries.is_empty()) - .then(|| Arc::new(Self { entries }) as Arc)) + pub(crate) fn compile(entries: Vec) -> Option> { + (!entries.is_empty()).then(|| Arc::new(Self { entries }) as Arc) } } @@ -139,23 +124,6 @@ impl UpstreamAdapterPlan for PluginUpstreamAdapterPlan { } } -fn adapters_overlap( - left: Option<&PluginUpstreamAdapter>, - right: Option<&PluginUpstreamAdapter>, -) -> bool { - let (Some(left), Some(right)) = (left, right) else { - return true; - }; - left.declaration.provider == right.declaration.provider - && (left.declaration.models.is_empty() - || right.declaration.models.is_empty() - || left - .declaration - .models - .iter() - .any(|model| right.declaration.models.contains(model))) -} - fn unavailable() -> ProviderError { ProviderError::new(ProviderErrorKind::Unavailable, UpstreamSendState::NotSent) } diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs index 715be1a65..2c6c00fff 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/registration.rs @@ -1,6 +1,6 @@ //! 校验并准备插件上游适配声明与能力绑定 -use std::{collections::BTreeSet, sync::Arc, time::Duration}; +use std::{sync::Arc, time::Duration}; use gateway_admin::model::{ AdminError, @@ -62,51 +62,20 @@ pub(crate) async fn prepare( ) .await .map_err(|_| AdminError::invalid("上游适配器注册失败"))?; - if reply.result != serde_json::json!({}) || reply.payload.len() > 64 * 1024 { + if reply.payload.len() > 64 * 1024 { return Err(AdminError::invalid("上游适配器注册信封无效或过大")); } let registration: UpstreamAdapterRegistration = serde_json::from_slice(&reply.payload) .map_err(|_| AdminError::invalid("上游适配器声明无效"))?; - if registration.adapters.is_empty() || registration.adapters.len() > 16 { - return Err(AdminError::invalid("单个插件须声明 1 至 16 个上游适配器")); + if registration.adapters.len() > 16 { + return Err(AdminError::invalid("单个插件最多声明 16 个上游适配器")); } - let mut ids = BTreeSet::new(); let binding = instance .bindings .iter() .find(|binding| binding.contribution == contribution.id); let mut entries = Vec::new(); for declaration in registration.adapters { - if declaration.id.is_empty() - || declaration.id.len() > 64 - || !declaration - .id - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) - || !ids.insert(declaration.id.clone()) - || declaration.authentication_kinds.is_empty() - || declaration.authentication_kinds.len() > 8 - || declaration.authentication_kinds.iter().any(|kind| { - kind.is_empty() - || kind.len() > 64 - || !kind - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - }) - || !contribution.input_formats.contains(&declaration.protocol) - || !contribution.output_formats.contains(&declaration.protocol) - || declaration.models.len() > 256 - { - return Err(AdminError::invalid("上游适配器身份、认证或协议范围无效")); - } - let mut models = BTreeSet::new(); - for model in &declaration.models { - gateway_core::routing::PublicModelId::new(model.clone()) - .map_err(|_| AdminError::invalid("上游适配器模型无效"))?; - if !models.insert(model) { - return Err(AdminError::invalid("上游适配器模型重复")); - } - } let target = Arc::new(UpstreamTarget::compile(&declaration)?); if let Some(binding) = binding { entries.push(AdapterEntry { diff --git a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs index ad7b9cb26..91ec9adc5 100644 --- a/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs +++ b/backend/crates/gateway-plugin/runtime/src/adapter/upstream_adapter/target.rs @@ -1,4 +1,4 @@ -//! 编译插件上游目标与路径约束,解析受管请求目标 +//! 保存插件上游基址与路径用途,解析受管请求目标 use std::collections::BTreeMap; @@ -18,33 +18,11 @@ impl UpstreamTarget { pub(super) fn compile(declaration: &UpstreamAdapterDeclaration) -> Result { let base = Url::parse(&declaration.base_url) .map_err(|_| AdminError::invalid("上游 Base URL 无效"))?; - if !matches!(base.scheme(), "https" | "http") - || base.host_str().is_none() - || !base.username().is_empty() - || base.password().is_some() - || base.query().is_some() - || base.fragment().is_some() - || !base.path().ends_with('/') - || declaration.base_url.len() > 2048 - || declaration.paths.is_empty() - || declaration.paths.len() > 16 - { - return Err(AdminError::invalid( - "上游 Base URL 须为无凭据、查询串或片段且以 / 结尾的 HTTP(S) 地址", - )); - } - let mut paths = BTreeMap::new(); - for path in &declaration.paths { - if !valid_path(&path.path) || paths.insert(path.path.clone(), path.purpose).is_some() { - return Err(AdminError::invalid("上游业务路径无效或重复")); - } - } - if !paths - .values() - .any(|purpose| *purpose == UpstreamPathPurpose::Inference) - { - return Err(AdminError::invalid("上游适配器须声明推理路径")); - } + let paths = declaration + .paths + .iter() + .map(|path| (path.path.clone(), path.purpose)) + .collect(); Ok(Self { base, paths }) } @@ -58,13 +36,6 @@ impl UpstreamTarget { .get(path) .copied() .unwrap_or(UpstreamPathPurpose::Inference); - if query.len() > 64 - || query - .iter() - .any(|(key, value)| key.len() > 256 || value.len() > 4096) - { - return Err(invalid()); - } let mut target = self.base.join(path).map_err(|_| invalid())?; if !matches!(target.scheme(), "http" | "https") || target.host_str().is_none() { return Err(invalid()); @@ -78,19 +49,6 @@ impl UpstreamTarget { } } -fn valid_path(path: &str) -> bool { - !path.is_empty() - && path.len() <= 2048 - && path.is_ascii() - && !path.starts_with('/') - && path.bytes().all(|byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b'~') - }) - && path - .split('/') - .all(|segment| !matches!(segment, "" | "." | "..")) -} - fn invalid() -> PluginFault { PluginFault::new(ErrorCode::InvalidInput, "upstream target is invalid") } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/call_resources.rs b/backend/crates/gateway-plugin/runtime/src/callback/call_resources.rs index 9a3cc0657..de188d0b9 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/call_resources.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/call_resources.rs @@ -6,7 +6,7 @@ use gateway_host::outbound::HttpBody; use gateway_plugin_sdk::{CallContext, ErrorCode, PluginFault, client::CallDeadline}; use std::{ collections::BTreeMap, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, Weak}, time::Duration, }; @@ -53,6 +53,14 @@ impl CallResources { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); state.closed = true; + // abort 只安排回调 future 稍后析构;返回 Core 前同步封存仍在发送的请求 + for attempt in state + .http_attempts + .drain(..) + .filter_map(|attempt| attempt.upgrade()) + { + attempt.finish(gateway_core::upstream::UpstreamSendState::Ambiguous); + } // RPC End 可以先于消费者取完已入队正文到达,这里只撤销新 callback // 正文包装器持有 invocation,最后一个 owner 释放时再关闭,保留计量和终态信封 state.middleware.take(); @@ -66,6 +74,37 @@ impl CallResources { state.model_streams.clear(); } + pub(super) fn start_upstream( + &self, + purpose: Option, + ) -> Result { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed { + return Err(denied()); + } + let attempt = self.scope.start_upstream(purpose); + state + .http_attempts + .retain(|attempt| attempt.strong_count() != 0); + state.http_attempts.push(attempt.pending()); + Ok(attempt) + } + + pub(super) fn observe_dispatch(&self) -> Result<(), PluginFault> { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.closed { + return Err(denied()); + } + self.scope.observe_dispatch(); + Ok(()) + } + pub(super) fn request_settings( &self, ) -> Option { @@ -104,6 +143,7 @@ pub(super) struct CallState { pub(super) middleware: Option>, pub(super) streams: BTreeMap>, pub(super) model_streams: BTreeMap>, + http_attempts: Vec>, } pub(super) struct HttpStream { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http.rs b/backend/crates/gateway-plugin/runtime/src/callback/http.rs index 4b115b9ec..979a7a434 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http.rs @@ -34,7 +34,7 @@ impl HttpCallbacks<'_> { maximum_payload, } = *self; let timeout = call.timeout()?; - let attempt = scope.start_upstream(upstream_purpose); + let attempt = call.start_upstream(upstream_purpose)?; let response = client .open(request, scope.proxy.as_ref(), network, timeout) .await; diff --git a/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs b/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs index 1c88e9870..cc045d2f3 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/http_dispatch.rs @@ -63,7 +63,7 @@ pub(super) async fn call( *http.headers_mut() = headers; *http.version_mut() = http_middleware::version(request.version); http.extensions_mut().insert(settings); - call.scope.observe_http_dispatch(); + call.observe_dispatch()?; let response = dispatcher .dispatch(child, http) .await diff --git a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs index 8f0abb7d7..722ea0e85 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/mod.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/mod.rs @@ -188,6 +188,7 @@ impl PluginCallbacks { | gateway_plugin_sdk::Stage::Attempt | gateway_plugin_sdk::Stage::Routing | gateway_plugin_sdk::Stage::Scheduling + | gateway_plugin_sdk::Stage::Retry ) { return Err(denied()); } @@ -449,14 +450,7 @@ impl CallbackHandler for PluginCallbacks { } if method == gateway_plugin_sdk::call::services::CALL_METHOD { return services - .call( - &context, - scope, - call.cancellation.clone(), - service_actor, - params, - &payload, - ) + .call(&context, &call, service_actor, params, &payload) .await; } if is_private_state { diff --git a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs index 5dacacd69..a9dae78e2 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/scope.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/scope.rs @@ -1,6 +1,6 @@ //! 插件调用的权限作用域、子调用上下文与出站副作用观测 -use std::sync::Arc; +use std::sync::{Arc, Mutex, Weak}; use gateway_core::{ account::OutboundProxy, engine::nested::ExecutionEffects, upstream::UpstreamSendState, @@ -29,8 +29,8 @@ pub(crate) struct CallbackScope { } impl CallbackScope { - pub(super) fn observe_http_dispatch(&self) { - // 子路由及其插件可能执行模型或提交事务,父执行不能再按 Provider 的 not_sent 透明重放 + pub(super) fn observe_dispatch(&self) { + // 子路由、公开服务及其插件都可能提交副作用,父执行不能再按 Provider 的 not_sent 透明重放 if let Some(effects) = &self.execution_effects { effects.observe(); } @@ -104,7 +104,7 @@ impl CallbackScope { &self, purpose: Option, ) -> HttpAttempt { - HttpAttempt { + HttpAttempt(Arc::new(HttpAttemptObservation { effects: if purpose == Some(gateway_plugin_sdk::call::upstream_adapter::UpstreamPathPurpose::Inference) { @@ -116,24 +116,38 @@ impl CallbackScope { .upstream .as_ref() .map(|managed| Arc::clone(&managed.send_state)), - completed: false, - } + completed: Mutex::new(false), + })) } } -pub(super) struct HttpAttempt { +pub(super) struct HttpAttempt(Arc); + +pub(super) struct HttpAttemptObservation { effects: Option>, - completed: bool, + completed: Mutex, upstream: Option>, } impl HttpAttempt { - pub(super) fn finish(mut self, observed: UpstreamSendState) { - self.observe(observed); - self.completed = true; + pub(super) fn pending(&self) -> Weak { + Arc::downgrade(&self.0) } - fn observe(&self, observed: UpstreamSendState) { + pub(super) fn finish(self, observed: UpstreamSendState) { + self.0.finish(observed); + } +} + +impl HttpAttemptObservation { + pub(super) fn finish(&self, observed: UpstreamSendState) { + let mut completed = self + .completed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if *completed { + return; + } if let Some(upstream) = &self.upstream { upstream.observe(observed); } @@ -142,14 +156,13 @@ impl HttpAttempt { { effects.observe(); } + *completed = true; } } impl Drop for HttpAttempt { fn drop(&mut self) { // HTTP future 被取消时无法证明上游未接收;通知 Core 收紧重放判断 - if !self.completed { - self.observe(UpstreamSendState::Ambiguous); - } + self.0.finish(UpstreamSendState::Ambiguous); } } diff --git a/backend/crates/gateway-plugin/runtime/src/callback/services.rs b/backend/crates/gateway-plugin/runtime/src/callback/services.rs index 4657e2dc7..5b4529307 100644 --- a/backend/crates/gateway-plugin/runtime/src/callback/services.rs +++ b/backend/crates/gateway-plugin/runtime/src/callback/services.rs @@ -1,6 +1,6 @@ //! 通用公开服务回调;操作目录与类型校验由业务服务登记 -use super::{CallbackScope, MiddlewareCallback, invalid}; +use super::{CallResources, MiddlewareCallback, invalid}; use crate::RpcReply; use futures::future::BoxFuture; use gateway_admin::{model::AdminError, public_service::Registry}; @@ -47,8 +47,7 @@ impl ServicePorts { pub(super) async fn call( &self, context: &CallContext, - scope: &CallbackScope, - cancellation: gateway_core::lifecycle::CancellationToken, + resources: &CallResources, actor: gateway_admin::model::usage_data::UsageActor, params: serde_json::Value, payload: &[u8], @@ -64,6 +63,7 @@ impl ServicePorts { ) })?; let call = registry.call(&request.operation, request.input); + let scope = &resources.scope; let origin = scope.origin.as_ref(); let parent = gateway_admin::public_service::Origin { request_id: origin.map_or_else( @@ -79,12 +79,19 @@ impl ServicePorts { || format!("{}:{}", context.resource_scope_id, context.call_id), |origin| origin.call_id.clone(), ), - cancellation, + cancellation: resources.cancellation.clone(), extensions: scope.child_extensions(&context.instance_id)?, plan: origin.map_or(gateway_admin::public_service::Plan::Current, |origin| { gateway_admin::public_service::Plan::Frozen(Some(origin.plan.clone())) }), }; + // 即使终端只读,服务中间件也可提交写入;不存在的操作不会进入调用链 + if registry + .operations() + .any(|operation| operation == request.operation) + { + resources.observe_dispatch()?; + } let result = gateway_admin::public_service::scope_actor( actor, gateway_admin::public_service::scope(parent, call), diff --git a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs index 99c187ef6..d6b44e68e 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/configuration.rs @@ -1,6 +1,9 @@ //! 校验插件实例配置、敏感字段与能力绑定,生成安全的配置错误 -use std::collections::BTreeSet; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{Arc, Mutex}, +}; use gateway_admin::model::{ AdminError, @@ -9,9 +12,57 @@ use gateway_admin::model::{ use gateway_plugin_sdk::{Capability, Manifest, Stage}; use secrecy::ExposeSecret as _; +/// 制品摘要绑定不可变 schema;缓存不持有实例配置与敏感值 +#[derive(Default)] +pub(crate) struct ConfigurationSchemas { + validators: Mutex>>, +} + +impl ConfigurationSchemas { + fn get( + &self, + artifact: &str, + schema: &serde_json::Value, + ) -> Result, AdminError> { + if let Some(validator) = self + .validators + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(artifact) + .cloned() + { + return Ok(validator); + } + // 编译不占缓存锁;schema 只能引用同一 JSON 文档,不读取网络或文件 + let validator = Arc::new( + jsonschema::options() + .offline() + .with_pattern_options( + jsonschema::PatternOptions::fancy_regex().backtrack_limit(20_000), + ) + .build(schema) + .map_err(|_| AdminError::invalid("插件配置 schema 无效或引用外部资源"))?, + ); + let mut validators = self + .validators + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(existing) = validators.get(artifact) { + return Ok(Arc::clone(existing)); + } + // 与制品兼容性缓存保持同一容量,历史版本不无限延长编译结果寿命 + if validators.len() >= 128 { + validators.pop_first(); + } + validators.insert(artifact.to_owned(), Arc::clone(&validator)); + Ok(validator) + } +} + pub(super) fn validate( instance: &PluginInstance, manifest: &Manifest, + schemas: &ConfigurationSchemas, ) -> Result<(serde_json::Value, PluginStateConfiguration), AdminError> { if instance.enabled && !instance.trusted_process { return Err(AdminError::invalid("插件制品尚未接受安装")); @@ -20,6 +71,7 @@ pub(super) fn validate( instance, &manifest.configuration_schema, &manifest.secret_fields, + schemas, )?; if instance.enabled && !ready { return Err(AdminError::invalid("请填写插件必填配置")); @@ -59,14 +111,16 @@ pub(super) fn configuration_ready( instance: &PluginInstance, schema: &serde_json::Value, secret_fields: &BTreeSet, + schemas: &ConfigurationSchemas, ) -> Result { - prepare_configuration(instance, schema, secret_fields).map(|(_, ready)| ready) + prepare_configuration(instance, schema, secret_fields, schemas).map(|(_, ready)| ready) } fn prepare_configuration( instance: &PluginInstance, schema: &serde_json::Value, secret_fields: &BTreeSet, + schemas: &ConfigurationSchemas, ) -> Result<(serde_json::Value, bool), AdminError> { let mut configuration = instance .configuration @@ -89,12 +143,7 @@ fn prepare_configuration( return Err(AdminError::invalid("普通配置不能包含声明的敏感字段")); } let configuration = serde_json::Value::Object(configuration); - // 禁用网络与文件解析,配置 schema 只能引用包内同一 JSON 文档 - let schema = jsonschema::options() - .offline() - .with_pattern_options(jsonschema::PatternOptions::fancy_regex().backtrack_limit(20_000)) - .build(schema) - .map_err(|_| AdminError::invalid("插件配置 schema 无效或引用外部资源"))?; + let schema = schemas.get(&instance.artifact_sha256, schema)?; let mut ready = true; for error in schema.iter_errors(&configuration) { // 待配置只代表缺少必填值,不能借停用保存类型错误或非法配置 diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs index 6cb795396..cddbe610d 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/instance.rs @@ -36,6 +36,12 @@ impl PluginRuntime { "插件实例连续异常退出,已暂停自动重启;请检查配置后重新启用", )); } + let validation_slot = self + .validators + .clone() + .acquire_owned() + .await + .map_err(|_| AdminError::unavailable("插件校验已停止"))?; let artifact = self .store .load_artifact(&instance.artifact_sha256) @@ -44,11 +50,7 @@ impl PluginRuntime { let limits = self.config.package_limits; let directory = self.config.cache_directory.clone(); let host_version = self.config.host_version.clone(); - let validation_slot = self - .validators - .clone() - .try_acquire_owned() - .map_err(|_| AdminError::unavailable("插件校验繁忙"))?; + let schemas = Arc::clone(&self.configuration_schemas); let (package, configuration, instance, state_configuration) = tokio::task::spawn_blocking(move || { let _validation_slot = validation_slot; @@ -61,7 +63,7 @@ impl PluginRuntime { .map_err(|_| AdminError::invalid("插件恢复包校验失败"))?, ); let (configuration, state_configuration) = - super::super::configuration::validate(&instance, package.manifest())?; + super::super::configuration::validate(&instance, package.manifest(), &schemas)?; crate::adapter::observer::validate_bindings( package.manifest(), &instance.bindings, diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs index edb32f731..637befefb 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/maintenance.rs @@ -127,13 +127,7 @@ async fn reconcile(session: Arc, dirty: Arc, cancellation: C () = cancellation.cancelled() => return, result = session.call("plugin.reconcile", context, serde_json::json!({}), Vec::new()) => result, }; - if !result.is_ok_and(|reply| { - let valid = reply.result == serde_json::json!({}) && reply.payload.is_empty(); - if !valid { - session.invalid_response(Stage::Maintenance); - } - valid - }) { + if result.is_err() { tracing::warn!(%instance_id, "plugin maintenance failed; retrying after bounded delay"); tokio::select! { biased; diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs index 5bff6cacb..1e1187b3c 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/runtime.rs @@ -41,6 +41,7 @@ use std::{ use tokio::sync::{Mutex, Semaphore}; const RUNTIME_SHUTDOWN_GRACE: Duration = Duration::from_secs(2); +pub(super) const MAXIMUM_PARALLEL_PREPARATIONS: usize = 2; pub struct PluginRuntimeConfig { pub cache_directory: PathBuf, pub host_version: semver::Version, @@ -64,6 +65,7 @@ pub struct PluginRuntime { shutdown_lock: Mutex<()>, pub(super) processes: Arc, pub(super) validators: Arc, + pub(super) configuration_schemas: Arc, pub(super) log_slots: Arc, pub(super) account_ports: Arc, pub(super) client_key_ports: Arc, @@ -105,7 +107,8 @@ impl PluginRuntime { shutting_down: Arc::new(AtomicBool::new(false)), shutdown_lock: Mutex::new(()), processes, - validators: Arc::new(Semaphore::new(2)), + validators: Arc::new(Semaphore::new(MAXIMUM_PARALLEL_PREPARATIONS)), + configuration_schemas: Arc::default(), service_ports: Arc::new(crate::callback::services::ServicePorts::default()), account_ports: Arc::new(PluginAccountPortSlot::new()), client_key_ports: Arc::new(PluginClientKeyPortSlot::new()), diff --git a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs index d233a68e3..2dc464a06 100644 --- a/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/src/generation/prepare/snapshot.rs @@ -7,7 +7,7 @@ use super::{ }; use crate::ValidatedPackage; use async_trait::async_trait; -use futures::future::BoxFuture; +use futures::{StreamExt as _, future::BoxFuture}; use gateway_admin::{ model::{ AdminError, Revision, @@ -96,6 +96,7 @@ impl PluginRuntime { let mut sessions = Vec::new(); let mut failures = BTreeMap::new(); let mut identities = BTreeSet::new(); + let mut instances = Vec::new(); for instance in snapshot .instances .iter() @@ -105,24 +106,34 @@ impl PluginRuntime { return Err(AdminError::invalid("插件实例 ID 重复")); } let identity = instance_fingerprint(instance)?; - let reusable = { - let instances = self.prepared_instances.lock().await; - instances - .get(&identity) - .and_then(Weak::upgrade) - .filter(|instance| instance.session.is_ready()) - }; - 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), - ) - .await - .unwrap_or_else(|_| Err(AdminError::unavailable("插件启动超时"))) - }; + instances.push((instance.clone(), identity)); + } + // 沿用包校验的并发容量;按快照顺序消费结果,能力组合顺序不随启动快慢变化 + let mut preparing = futures::stream::iter(instances.into_iter().map( + |(instance, identity)| async move { + let reusable = { + let instances = self.prepared_instances.lock().await; + instances + .get(&identity) + .and_then(Weak::upgrade) + .filter(|instance| instance.session.is_ready()) + }; + 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), + ) + .await + .unwrap_or_else(|_| Err(AdminError::unavailable("插件启动超时"))) + }; + (instance, identity, result) + }, + )) + .buffered(super::runtime::MAXIMUM_PARALLEL_PREPARATIONS); + while let Some((instance, identity, result)) = preparing.next().await { match result { Ok(candidate) => { self.prepared_instances @@ -155,15 +166,15 @@ impl PluginRuntime { // 恢复失败只隔离该实例;绑定的拒绝策略仍保留,不能绕过认证或必需处理 contributions .policy_entries - .extend(crate::adapter::policy::unavailable_entries(instance)?); + .extend(crate::adapter::policy::unavailable_entries(&instance)?); contributions .middleware_entries - .extend(crate::adapter::middleware::unavailable_entries(instance)?); + .extend(crate::adapter::middleware::unavailable_entries(&instance)?); contributions.upstream_entries.extend( - crate::adapter::upstream_adapter::unavailable_entries(instance)?, + crate::adapter::upstream_adapter::unavailable_entries(&instance)?, ); if let Some(entry) = - crate::adapter::frontend_authentication::unavailable_entry(instance) + crate::adapter::frontend_authentication::unavailable_entry(&instance) { contributions.authentication_entries.push(entry); } @@ -181,15 +192,6 @@ impl PluginRuntime { authentication_entries, model_aliases, } = contributions; - let mut alias_owners = BTreeMap::new(); - for alias in &model_aliases { - if let Some(previous) = alias_owners.insert(&alias.id, &alias.owner) { - return Err(AdminError::invalid(format!( - "模型别名 {} 在插件实例 {} 与 {} 之间冲突", - alias.id, previous, alias.owner - ))); - } - } let id = ExtensionSetId::new(uuid::Uuid::new_v4().to_string()) .map_err(|_| AdminError::internal("插件集合 ID 无效"))?; let observers = crate::adapter::observer::PluginObserverPlan::compile( @@ -223,7 +225,7 @@ impl PluginRuntime { ) .map(|plan| plan as Arc); let upstream_adapters = - crate::adapter::upstream_adapter::PluginUpstreamAdapterPlan::compile(upstream_entries)?; + crate::adapter::upstream_adapter::PluginUpstreamAdapterPlan::compile(upstream_entries); let execution = if middleware.is_some() || upstream_adapters.is_some() { Some( self.execution @@ -292,11 +294,24 @@ impl PluginPreparation for PluginRuntime { if instance.artifact_sha256 != metadata.sha256 { return Err(AdminError::invalid("插件配置与制品不匹配")); } - // 安装时已验证声明;只读列表不重新解包,也不占用安装与启用的校验配额 + // 安装时已验证声明;只读列表不重新解包,CPU 工作仍受统一校验容量约束 + let slot = self + .validators + .clone() + .acquire_owned() + .await + .map_err(|_| AdminError::unavailable("插件校验已停止"))?; let schema = metadata.configuration_schema.clone(); let secret_fields = metadata.secret_fields.iter().cloned().collect(); + let schemas = Arc::clone(&self.configuration_schemas); tokio::task::spawn_blocking(move || { - super::super::configuration::configuration_ready(&instance, &schema, &secret_fields) + let _slot = slot; + super::super::configuration::configuration_ready( + &instance, + &schema, + &secret_fields, + &schemas, + ) }) .await .map_err(|_| AdminError::internal("插件校验任务失败"))? @@ -306,23 +321,26 @@ impl PluginPreparation for PluginRuntime { &self, instance: PluginInstance, ) -> Result { + let slot = self + .validators + .clone() + .acquire_owned() + .await + .map_err(|_| AdminError::unavailable("插件校验已停止"))?; let artifact = self .store .load_artifact(&instance.artifact_sha256) .await .map_err(|_| AdminError::not_found("插件制品不存在"))?; let limits = self.config.package_limits; - let slot = self - .validators - .clone() - .try_acquire_owned() - .map_err(|_| AdminError::unavailable("插件校验繁忙"))?; + let schemas = Arc::clone(&self.configuration_schemas); tokio::task::spawn_blocking(move || { let _slot = slot; let package = ValidatedPackage::read(artifact.archive, Some(&instance.artifact_sha256), limits) .map_err(|_| AdminError::invalid("插件制品校验失败"))?; - let (_, state) = super::super::configuration::validate(&instance, package.manifest())?; + let (_, state) = + super::super::configuration::validate(&instance, package.manifest(), &schemas)?; crate::adapter::observer::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::policy::validate_bindings(package.manifest(), &instance.bindings)?; crate::adapter::middleware::validate_bindings(package.manifest(), &instance.bindings)?; diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs index 26ae60470..436060a8a 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/dispatch.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use gateway_plugin_sdk::{ ErrorCode, Frame, Message, PluginFault, call::middleware::NEXT_METHOD, - client::{EncodedFrame, read_frame, validate_frame}, + client::{EncodedFrame, read_frame}, }; use tokio::{ io::{AsyncRead, AsyncWrite}, @@ -145,7 +145,7 @@ async fn read_loop( return; } drop(pause); - let mut reply = match result { + let reply = match result { Ok(reply) => Frame { message: Message::Result { id, @@ -156,16 +156,16 @@ async fn read_loop( Err(error) => Frame::control(Message::Error { id, error }), }; // 宿主回调的局部编码错误只能结束该调用,不能关闭共享写通道 - if validate_frame(&reply).is_err() { - reply = Frame::control(Message::Error { + match EncodedFrame::new(reply) { + Ok(reply) => response.send_encoded_control(reply), + Err(_) => response.send_control(Frame::control(Message::Error { id, error: PluginFault::new( ErrorCode::Fault, "host callback response cannot be encoded", ), - }); + })), } - response.send_control(reply); drop(permit); }); if shared.track_callback(parent_id, task.abort_handle()) { diff --git a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs index 4fee85cf4..e6e0d49cb 100644 --- a/backend/crates/gateway-plugin/runtime/src/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/src/rpc/session.rs @@ -199,11 +199,9 @@ pub struct RpcSession { } impl RpcSession { - /// 只用于已确认来自插件的合同错误,不接收业务错误或原始响应内容 + /// 响应合同错误只结束所属调用,不影响共享传输与其他在途调用 pub(crate) fn invalid_response(&self, stage: Stage) -> RpcError { - let error = RpcError::InvalidResponse(stage, None); - self.shared.fail(error.clone()); - error + RpcError::InvalidResponse(stage, None) } pub(crate) fn decode_response( @@ -212,12 +210,7 @@ impl RpcSession { value: serde_json::Value, ) -> Result { serde_json::from_value(value).map_err(|source| { - let error = RpcError::InvalidResponse( - stage, - Some(gateway_core::error::ErrorSource::new(source)), - ); - self.shared.fail(error.clone()); - error + RpcError::InvalidResponse(stage, Some(gateway_core::error::ErrorSource::new(source))) }) } } @@ -258,10 +251,14 @@ impl PendingCall { for callback in self.callbacks.drain(..) { callback.abort(); } - if let Some(stream) = self.stream.take() { + let stream = self.stream.take(); + let sender = self.result.take(); + // 先同步关闭调用资源,Core 才能在收到结果时看到全部发送副作用 + drop(self); + if let Some(stream) = stream { stream.finish(result.as_ref().map(|_| ()).map_err(Clone::clone)); } - if let Some(sender) = self.result.take() { + if let Some(sender) = sender { let _ = sender.send(result); } } @@ -544,6 +541,10 @@ impl Shared { self.fail(RpcError::Protocol(None)); return; }; + self.send_encoded_control(frame); + } + + pub(super) fn send_encoded_control(&self, frame: EncodedFrame) { if self.control.try_send(frame).is_err() { self.fail(RpcError::Capacity); } diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/mod.rs index c16434bd0..f9a15fb7c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/mod.rs @@ -125,7 +125,10 @@ async fn command_help_does_not_execute_and_stale_authorization_cannot_execute() async fn command_returns_separate_output_and_exit_code_without_saving_on_failure() { let mut config = configuration(json!([])); config["command_echo"] = json!(false); - config["command_result"] = json!({"stdout":"out\n","stderr":"err\n","exit_code":17}); + config["extra_result_fields"] = json!(true); + config["command_registration"]["commands"][0]["plugin_extra"] = json!(true); + config["command_result"] = + json!({"stdout":"out\n","stderr":"err\n","exit_code":17,"plugin_extra":true}); let (_directory, _store, runtime) = setup(config).await; let commands = runtime.prepare_command_line().await.unwrap(); let output = commands.execute("commands", "inspect", &[]).await.unwrap(); diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/parameters.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/parameters.rs index 75bed8a6e..c41f05fe3 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/parameters.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/command_line/parameters.rs @@ -76,7 +76,7 @@ async fn command_parser_preserves_all_types_defaults_and_signed_nanosecond_bound } #[tokio::test] -async fn command_registration_rejects_reserved_duplicate_and_mistyped_parameters() { +async fn command_registration_preserves_plugin_owned_parameter_definitions() { let (_directory, store, runtime) = super::setup(super::configuration(json!([]))).await; for parameters in [ json!([{"name":"help","description":"保留名","value_type":"bool"}]), @@ -89,14 +89,14 @@ async fn command_registration_rejects_reserved_duplicate_and_mistyped_parameters let commands = runtime .prepare_command_line() .await - .expect("invalid plugin is isolated from the host"); - assert!(commands.help(Some("commands"), None).is_err()); + .expect("plugin definitions are not host business rules"); + assert!(commands.help(Some("commands"), None).is_ok()); let snapshot = store.snapshot.lock().unwrap().clone(); assert!( gateway_admin::ports::plugins::PluginPreparation::prepare(&runtime, snapshot) .await - .is_err(), - "invalid registration still rejects an explicit candidate" + .is_ok(), + "host preparation accepts plugin-owned command semantics" ); } for name in ["help", "serve", "plugin", "version"] { @@ -106,14 +106,14 @@ async fn command_registration_rejects_reserved_duplicate_and_mistyped_parameters let commands = runtime .prepare_command_line() .await - .expect("invalid plugin is isolated from the host"); - assert!(commands.help(Some("commands"), None).is_err()); + .expect("plugin definitions are not host business rules"); + assert!(commands.help(Some("commands"), None).is_ok()); let snapshot = store.snapshot.lock().unwrap().clone(); assert!( gateway_admin::ports::plugins::PluginPreparation::prepare(&runtime, snapshot) .await - .is_err(), - "invalid registration still rejects an explicit candidate" + .is_ok(), + "host preparation accepts plugin-owned command semantics" ); } } diff --git a/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs b/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs index 9488bd611..cfb3f51b7 100644 --- a/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/adapter/management/mod.rs @@ -284,8 +284,8 @@ async fn management_model_stream_retains_selected_identity_until_completion_and_ } #[tokio::test] -async fn invalid_management_responses_stop_the_plugin_but_business_statuses_do_not() { - for (response, faulted) in [ +async fn invalid_management_responses_fail_the_call_without_stopping_the_plugin() { + for (response, invalid) in [ ( json!({"status":"invalid","content_type":"application/octet-stream"}), true, @@ -311,19 +311,18 @@ async fn invalid_management_responses_stop_the_plugin_but_business_statuses_do_n ); let views = service.views().await.unwrap(); let result = service.handle(&views[0].target, request()).await; - if faulted { + if invalid { assert!(result.is_err()); } else { assert_eq!(result.unwrap().status, 429); } - assert_eq!( + assert!( core.snapshots() .acquire() .unwrap() .extensions() .unwrap() - .is_ready(), - !faulted + .is_ready() ); for probe in core.health_probes() { assert!(matches!( diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs index fd50e5155..e08c01510 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/authentication.rs @@ -48,6 +48,7 @@ fn configuration(result: Value, exclusive: bool, expected_authorization: &str) - "frontend_authentication_client_key_id": "key_frontend", "frontend_authentication_exclusive": exclusive, "expected_frontend_authorization": expected_authorization, + "extra_result_fields": true, }) } @@ -112,7 +113,7 @@ async fn delegate_falls_back_only_for_not_matched_while_exclusive_never_falls_ba } #[tokio::test] -async fn rejection_unmapped_principal_and_plugin_supplied_key_id_fail_closed() { +async fn authentication_rejects_unmapped_principals_and_ignores_plugin_selected_keys() { for result in [ json!({"outcome":"rejected"}), json!({"outcome":"authenticated","principal":"unmapped"}), @@ -156,13 +157,12 @@ async fn rejection_unmapped_principal_and_plugin_supplied_key_id_fail_closed() { else { return; }; - assert!(matches!( - authentication, - Err(ClientAuthenticationError::ProviderUnavailable) - )); + assert_eq!( + authentication.unwrap().policy().key_id().as_str(), + "key_frontend" + ); assert!( - !core - .snapshots() + core.snapshots() .acquire() .unwrap() .extensions() diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs index c6c46c7a7..163140ed9 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/maintenance.rs @@ -198,30 +198,32 @@ async fn wait_done(path: &std::path::Path, after: usize, phase: &str) -> Value { } #[tokio::test] -async fn malformed_maintenance_response_does_not_stop_the_host_worker() { +async fn unused_maintenance_response_does_not_stop_the_host_worker() { let Some(environment) = Environment::create().await else { return; }; + let marker = environment.directory.path().join("invalid-response.jsonl"); environment .install_plugin( - json!({"maintenance_fixture":true,"invalid_response_method":"plugin.reconcile"}), + json!({"maintenance_fixture":true,"invalid_response_method":"plugin.reconcile","invalid_response_marker":marker}), ) .await; let (runtime, core, resources, stop, task) = start(&environment, "malformed response").await; tokio::time::timeout(Duration::from_secs(5), async { - while core - .snapshots() - .acquire() - .unwrap() - .extensions() - .unwrap() - .is_ready() - { + while !marker.exists() { tokio::time::sleep(Duration::from_millis(20)).await; } }) .await .unwrap(); + assert!( + core.snapshots() + .acquire() + .unwrap() + .extensions() + .unwrap() + .is_ready() + ); assert!(!task.is_finished(), "单个插件失败不能终止宿主维护任务"); for probe in core.health_probes() { assert!(matches!( diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs index f29517173..5d26d78ba 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/observer.rs @@ -290,10 +290,10 @@ async fn frozen_plan_applies_scope_order_and_instance_deduplication() { assert_eq!(captured["request"]["uri"], "/v1/responses?observer=1"); assert_eq!(captured["request"]["peer_addr"], "[2001:db8::1]:42000"); assert_eq!(captured["request"]["client_ip"], "203.0.113.7"); - assert_eq!( - captured["request"]["headers"][0]["value"], - serde_json::json!([128, 255]) - ); + let request: gateway_plugin_sdk::RequestContext = + serde_json::from_value(captured["request"].clone()).unwrap(); + assert_eq!(request.headers[0].name, "x-binary"); + assert_eq!(request.headers[0].value, [128, 255]); } assert_eq!(lines[0]["label"], "early"); assert!(lines[0]["observation"]["terminal"].is_object()); @@ -554,7 +554,7 @@ async fn websocket_observer_bounds_event_count_and_payload_bytes_without_blockin } #[tokio::test] -async fn malformed_observer_response_stops_only_the_observer() { +async fn unused_observer_response_does_not_stop_the_shared_session() { let marker_directory = tempfile::tempdir().unwrap(); let completed = marker_directory.path().join("healthy.jsonl"); let (cache, _store, runtime) = setup( @@ -586,7 +586,7 @@ async fn malformed_observer_response_stops_only_the_observer() { .with_provider(ProviderKind::new("openai").unwrap()), ); wait_for_lines(&completed, 1).await; - assert!(!generation.is_ready()); + assert!(generation.is_ready()); assert!(generation.can_serve()); drop(plan); drop(generation); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/compatibility.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/compatibility.rs index ae7892aa4..072d92e69 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/compatibility.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/compatibility.rs @@ -124,7 +124,7 @@ async fn middleware_preserves_three_state_fast_and_sources() { } #[tokio::test] -async fn unsupported_contract_starts_and_runtime_faults_only_stop_the_failed_instance() { +async fn unsupported_contract_starts_and_response_errors_do_not_stop_the_instance() { use gateway_admin::{ model::plugins::instances::PluginInstanceRuntimeStatus, ports::plugins::PluginRuntimeDiagnostics as _, @@ -201,17 +201,11 @@ async fn unsupported_contract_starts_and_runtime_faults_only_stop_the_failed_ins PluginInstanceRuntimeStatus::Running ); let failed = &diagnostics["instance-one"]; - if failure == "business_error" { + if failure != "crash" { assert_eq!(failed.status, PluginInstanceRuntimeStatus::Running); assert!(generation.is_ready()); } else { assert_eq!(failed.status, PluginInstanceRuntimeStatus::Faulted); - let reason = failed.failure.as_ref().unwrap(); - if failure == "invalid_head" { - assert_eq!(reason.code, "invalid_response"); - assert!(reason.message.contains("请求中间件")); - assert!(!reason.message.contains("sensitive")); - } } drop(plan); drop(generation); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/mod.rs index 2bc02f9cd..e9bcf020a 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/http/mod.rs @@ -544,11 +544,21 @@ async fn http_onion_performance_baseline() { .unwrap(); let settings = gateway_core::routing::request_settings::RequestSettings::new(Arc::new(snapshot)); - for size in [64 * 1024, 2 * 1024 * 1024] { + for (size, header_bytes) in [64 * 1024, 2 * 1024 * 1024].into_iter().flat_map(|size| { + [0, 32 * 1024] + .into_iter() + .map(move |headers| (size, headers)) + }) { let payload = Bytes::from(vec![b'a'; size]); for _ in 0..5 { - benchmark_http_request(plan.as_ref(), &settings, payload.clone(), mode == "map") - .await; + benchmark_http_request( + plan.as_ref(), + &settings, + payload.clone(), + header_bytes, + mode == "map", + ) + .await; } let mut first_frames = Vec::new(); let mut totals = Vec::new(); @@ -557,6 +567,7 @@ async fn http_onion_performance_baseline() { plan.as_ref(), &settings, payload.clone(), + header_bytes, mode == "map", ) .await; @@ -568,14 +579,20 @@ async fn http_onion_performance_baseline() { let started = std::time::Instant::now(); for _ in 0..5 { futures::future::join_all((0..8).map(|_| { - benchmark_http_request(plan.as_ref(), &settings, payload.clone(), mode == "map") + benchmark_http_request( + plan.as_ref(), + &settings, + payload.clone(), + header_bytes, + mode == "map", + ) })) .await; } println!( "{}", serde_json::json!({ - "case":label,"bytes":size,"samples":50,"concurrency":8, + "case":label,"bytes":size,"header_bytes":header_bytes,"samples":50,"concurrency":8, "first_frame_p50_ms":first_frames[24],"first_frame_p95_ms":first_frames[47], "complete_p50_ms":totals[24],"complete_p95_ms":totals[47], "concurrent_mib_per_second":(size as f64 * 40.0 / 1048576.0) / started.elapsed().as_secs_f64(), @@ -593,6 +610,7 @@ async fn benchmark_http_request( plan: Option<&gateway_core::engine::middleware::FrozenMiddlewarePlan>, settings: &gateway_core::routing::request_settings::RequestSettings, payload: Bytes, + header_bytes: usize, uppercase: bool, ) -> (Duration, Duration) { let length = payload.len(); @@ -610,10 +628,33 @@ async fn benchmark_http_request( .boxed_unsync(), ) .unwrap(); + if header_bytes > 0 { + let value = ::http::HeaderValue::from_bytes(&vec![b'a'; header_bytes / 8]).unwrap(); + for _ in 0..8 { + request.headers_mut().append("x-benchmark", value.clone()); + } + } request.extensions_mut().insert(core::Settings { runtime: Some(settings.clone()), timeout: None, }); + let entry = Arc::new(gateway_core::engine::extensions::RequestContext { + method: request.method().to_string(), + uri: request.uri().to_string(), + http_version: format!("{:?}", request.version()), + headers: request + .headers() + .iter() + .map(|(name, value)| { + gateway_core::engine::middleware::MiddlewareHeader::new( + name.as_str(), + value.as_bytes().to_vec().into(), + ) + }) + .collect(), + peer_addr: None, + client_ip: None, + }); let next = compose(Vec::new(), |request: core::Request| { Box::pin(async { Ok(core::Response::new(request.into_body())) }) }); @@ -626,7 +667,8 @@ async fn benchmark_http_request( request_id: "benchmark".into(), call_id: "benchmark".into(), parent_call_id: None, - extensions: Default::default(), + extensions: gateway_core::engine::extensions::ExtensionCallScope::default() + .with_request(entry), cancellation: CancellationToken::new(), plan: Some(plan.clone()), }, diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs index b693d097e..76528d043 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/mod.rs @@ -5,6 +5,7 @@ mod facts; mod http; mod lifecycle; mod mounts; +mod retry; mod scheduling; mod service; mod websocket; @@ -210,6 +211,7 @@ fn operation() -> Operation { ["session_id", "c2VjcmV0"], ["thread_id", "c2VjcmV0"], ["X_Extension!#$%&'*+-.^_`|~", "b24="], + ["x-plugin opaque", "not-base64?"], ] }) .as_object() @@ -282,7 +284,7 @@ async fn retry_callback_preserves_entry_snapshot_and_ancestor_scope() { } #[tokio::test] -async fn retry_policy_uses_ordered_delegation_and_rejects_disallowed_actions() { +async fn retry_policy_preserves_decisions_and_leaves_replay_safety_to_core() { use gateway_core::{ engine::policy::{RetryDecision, RetryFacts}, error::ProviderErrorKind, @@ -302,12 +304,12 @@ async fn retry_policy_uses_ordered_delegation_and_rejects_disallowed_actions() { ( serde_json::json!({"decision":"retry"}), false, - RetryDecision::Stop, + RetryDecision::Retry, ), ( serde_json::json!({"decision":"retry","account_id":"acct_forbidden"}), true, - RetryDecision::Stop, + RetryDecision::Retry, ), ] { let records = tempfile::tempdir().unwrap(); @@ -506,7 +508,8 @@ async fn model_router_preserves_order_and_exposes_complete_request_data() { {"name":"authorization","value_base64":"c2VjcmV0"}, {"name":"session_id","value_base64":"c2VjcmV0"}, {"name":"thread_id","value_base64":"c2VjcmV0"}, - {"name":"x_extension!#$%&'*+-.^_`|~","value_base64":"b24="} + {"name":"x_extension!#$%&'*+-.^_`|~","value_base64":"b24="}, + {"name":"x-plugin opaque","value_base64":"not-base64?"} ]) ); assert!(!lines[1].to_string().contains("secret")); diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/policy/retry.rs b/backend/crates/gateway-plugin/runtime/tests/generation/policy/retry.rs new file mode 100644 index 000000000..425ed4d93 --- /dev/null +++ b/backend/crates/gateway-plugin/runtime/tests/generation/policy/retry.rs @@ -0,0 +1,229 @@ +//! 验证真实重试插件的 HTTP 与服务回调副作用会阻止 Core 透明重放 + +use super::*; +use gateway_core::{ + engine::{ + AttemptContext, + execution::{ExecutionRequestMetadata, StartExecution}, + provider::{ + Provider, ProviderCallMetadata, ProviderRegistry, ProviderRequest, ProviderStream, + }, + }, + error::{ProviderError, ProviderErrorKind}, + routing::{ + ModelCapabilities, ProviderCatalogGeneration, ProviderModelCapabilities, UpstreamModelId, + }, + upstream::{UpstreamSendState, UpstreamTransport}, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +struct RetryableProvider { + account: ProviderAccountId, + calls: AtomicUsize, +} + +#[async_trait::async_trait] +impl Provider for RetryableProvider { + fn name(&self) -> &'static str { + "openai" + } + + fn catalog_generation(&self) -> ProviderCatalogGeneration { + ProviderCatalogGeneration::default() + } + + async fn query_model_capabilities( + &self, + ) -> Result, ProviderError> { + Ok(vec![ProviderModelCapabilities::new( + UpstreamModelId::new("retry-fixture").unwrap(), + ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None), + )]) + } + + async fn execute( + self: Arc, + request: ProviderRequest, + _: AttemptContext, + ) -> Result { + if self.calls.fetch_add(1, Ordering::SeqCst) != 0 { + return Err(ProviderError::new( + ProviderErrorKind::NoEligibleAccount, + UpstreamSendState::NotSent, + )); + } + Ok(ProviderStream::new( + ProviderCallMetadata::new( + request.candidate().provider().clone(), + request.candidate().upstream_model().cloned().unwrap(), + self.account.clone(), + UpstreamTransport::new("http").unwrap(), + ), + futures::stream::iter([Err(ProviderError::new( + ProviderErrorKind::Transport, + UpstreamSendState::NotSent, + ) + .with_replay_safe())]), + (), + )) + } +} + +#[tokio::test] +async fn retry_callback_effects_survive_success_failure_and_cancellation() { + for outcome in [ + "none", + "not_sent", + "sent", + "ambiguous", + "fault", + "cancelled", + "early_result", + "service", + "service_error", + "service_missing", + "service_invalid", + "service_unbound", + ] { + let Some(environment) = Environment::create().await else { + return; + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = if outcome == "not_sent" { + "not-an-http-url".to_owned() + } else { + format!("http://{}/effect", listener.local_addr().unwrap()) + }; + let accepted = Arc::new(AtomicUsize::new(0)); + let requests = accepted.clone(); + let marker = environment.directory.path().join("http-received"); + let received = marker.clone(); + let service_marker = environment.directory.path().join("service-result.jsonl"); + let is_service = outcome.starts_with("service"); + let is_http = outcome != "none" && !is_service; + let service_request = match outcome { + "service_invalid" => serde_json::json!({"operation":123}), + "service_missing" => serde_json::json!({"operation":"settings.missing", "input":null}), + "service_error" => { + serde_json::json!({"operation":"settings.preview_client_profile", "input":["missing-provider", null]}) + } + _ => serde_json::json!({"operation":"settings.load", "input":null}), + }; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 1024]; + assert_ne!( + stream.read(&mut buffer).await.unwrap(), + 0, + "HTTP request bytes" + ); + requests.fetch_add(1, Ordering::SeqCst); + std::fs::write(received, []).unwrap(); + if matches!(outcome, "cancelled" | "early_result") { + tokio::time::sleep(Duration::from_secs(5)).await; + } + if outcome != "ambiguous" { + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .await; + } + }); + environment + .install_plugin(serde_json::json!({ + "retry_decision":{"decision":"retry"}, + "retry_http_url":is_http.then_some(url), + "retry_fault":outcome == "fault", + "retry_http_return_marker":(outcome == "early_result").then_some(marker), + "retry_service_request":is_service.then_some(service_request), + "retry_service_marker":service_marker, + })) + .await; + let key = "sk-retry-effects-fixture"; + environment.client_key("key_retry_effects", key).await; + let provider = Arc::new(RetryableProvider { + account: environment.account(None).await, + calls: AtomicUsize::new(0), + }); + let (runtime, core) = environment + .runtime_with_providers( + RpcLimits { + maximum_call_timeout: Duration::from_millis(500), + ..RpcLimits::default() + }, + ProviderRegistry::new([provider.clone() as Arc]).unwrap(), + ) + .await; + let admin = if is_service && outcome != "service_unbound" { + let admin = environment.bind_admin_accounts(&runtime, &core).await; + runtime + .bind_services(&admin.services().public_services()) + .unwrap(); + Some(admin) + } else { + None + }; + let execution = core.execution_service(); + let mut started = execution + .start(StartExecution { + client: execution.authenticate(key).unwrap(), + public_model: PublicModelId::new("retry-fixture").unwrap(), + operation: operation(), + metadata: ExecutionRequestMetadata { + protocol: "openai".into(), + endpoint: "/v1/responses".into(), + transport: ClientTransport::HttpJson, + stream: false, + client_ip: None, + user_agent: None, + previous_response_id: None, + }, + }) + .await + .unwrap(); + assert!(started.session.collect_uncommitted().await.is_err()); + started.session.detach_finalize().await; + assert_eq!( + provider.calls.load(Ordering::SeqCst), + if matches!( + outcome, + "none" | "not_sent" | "service_missing" | "service_invalid" | "service_unbound" + ) { + 2 + } else { + 1 + }, + "{outcome}" + ); + assert_eq!( + accepted.load(Ordering::SeqCst), + usize::from(is_http && outcome != "not_sent"), + "{outcome}" + ); + if is_service { + let record: serde_json::Value = serde_json::from_str( + std::fs::read_to_string(service_marker) + .unwrap() + .lines() + .next() + .unwrap(), + ) + .unwrap(); + match outcome { + "service" => assert!(record.get("Ok").is_some(), "{record}"), + "service_error" | "service_missing" => { + assert!(record.get("Err").is_some(), "{record}") + } + _ => assert!(record.get("callback_error").is_some(), "{record}"), + } + } + server.abort(); + let _ = server.await; + if admin.is_none() { + environment.release_plugin_accounts(&runtime); + } + drop(admin); + drop(core); + runtime.shutdown().await; + environment.close().await; + } +} diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs index 336075e19..f9f729af2 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/instance.rs @@ -19,6 +19,50 @@ fn startup_count(marker: &Path) -> usize { .unwrap_or_default() } +#[tokio::test] +async fn cold_preparation_overlaps_startup_with_bounded_concurrency() { + let (cache, store, runtime) = super::super::setup().await; + let mut snapshot = store.snapshot.lock().unwrap().clone(); + let template = snapshot.instances[0].clone(); + let gate = cache.path().join("startup-gate"); + let markers: Vec<_> = (0..3) + .map(|index| cache.path().join(format!("startup-{index}"))) + .collect(); + snapshot.instances = markers + .iter() + .enumerate() + .map(|(index, marker)| { + let mut instance = template.clone(); + instance.id = format!("instance-{index}"); + instance.configuration = serde_json::json!({ + "startup_marker": marker, + "startup_gate": gate, + }); + instance + }) + .collect(); + let release = async { + tokio::time::timeout(Duration::from_secs(5), async { + while startup_count(&markers[0]) == 0 || startup_count(&markers[1]) == 0 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("both startup handshakes must progress before either is ready"); + assert_eq!( + startup_count(&markers[2]), + 0, + "preparation keeps its concurrency budget" + ); + std::fs::write(&gate, []).unwrap(); + }; + let (prepared, ()) = tokio::join!(PluginPreparation::prepare(&runtime, snapshot), release); + let prepared = prepared.unwrap(); + assert!(markers.iter().all(|marker| startup_count(marker) == 1)); + drop(prepared); + runtime.shutdown().await; +} + #[tokio::test] async fn registration_with_a_changed_contribution_id_is_rejected() { let (cache, store, runtime) = super::super::setup_with_contributions(Contributions::from([ diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs index fe01dcb11..c5041941e 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/prepare/snapshot.rs @@ -75,7 +75,7 @@ async fn model_catalog_is_frozen_without_entering_the_request_chain() { } #[tokio::test] -async fn model_catalog_rejects_invalid_registrations_and_cross_instance_conflicts() { +async fn model_catalog_prepares_plugin_aliases_without_semantic_validation() { for models in [ serde_json::json!([{"id":"alias","provider":"custom","model":"target"}]), serde_json::json!([{"id":"alias","provider":"openai","model":"alias"}]), @@ -98,11 +98,12 @@ async fn model_catalog_rejects_invalid_registrations_and_cross_instance_conflict let mut snapshot = store.snapshot.lock().unwrap().clone(); snapshot.instances[0].configuration = serde_json::json!({"model_catalog":{"models":models}}); - assert!( - PluginPreparation::prepare(&runtime, snapshot) - .await - .is_err() - ); + let expected = models.as_array().unwrap().len(); + let prepared = PluginPreparation::prepare(&runtime, snapshot) + .await + .unwrap(); + assert_eq!(prepared.model_aliases().len(), expected); + drop(prepared); runtime.shutdown().await; super::super::wait_until_empty(cache.path()).await; } @@ -123,11 +124,13 @@ async fn model_catalog_rejects_invalid_registrations_and_cross_instance_conflict let mut other = snapshot.instances[0].clone(); other.id = "instance-two".into(); snapshot.instances.push(other); - let error = PluginPreparation::prepare(&runtime, snapshot) + let prepared = PluginPreparation::prepare(&runtime, snapshot) .await - .unwrap_err(); - assert!(error.to_string().contains("instance-one")); - assert!(error.to_string().contains("instance-two")); + .unwrap(); + assert_eq!(prepared.model_aliases().len(), 2); + assert_eq!(prepared.model_aliases()[0].owner, "instance-one"); + assert_eq!(prepared.model_aliases()[1].owner, "instance-two"); + drop(prepared); runtime.shutdown().await; super::super::wait_until_empty(cache.path()).await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs index df2e28051..4ce7b42f0 100644 --- a/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs +++ b/backend/crates/gateway-plugin/runtime/tests/generation/upstream_adapter.rs @@ -503,8 +503,38 @@ async fn adapter_does_not_publish_completed_when_rpc_fails_after_terminal_frame( } #[tokio::test] -async fn adapter_registration_rejects_target_escape_and_custom_providers() { - for invalid in [ +async fn overlapping_adapter_declarations_keep_first_match_without_blocking_preparation() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/adapter/responses")) + .respond_with(ResponseTemplate::new(200).set_body_string("{}")) + .expect(1) + .mount(&server) + .await; + let mut config = configuration(&server.uri(), false); + let mut duplicate = config["upstream_registration"]["adapters"][0].clone(); + duplicate["base_url"] = json!("https://unused.invalid/adapter/"); + config["upstream_registration"]["adapters"] + .as_array_mut() + .unwrap() + .push(duplicate); + let (cache, _, runtime) = setup(config).await; + let generation = ExtensionPreparationPort::prepare(&runtime, ConfigRevision::new(1).unwrap()) + .await + .unwrap(); + let mut stream = execute(&runtime, &generation, Account::new(), "key-one", None); + while let Some(event) = stream.next().await { + event.unwrap(); + } + drop(stream); + drop(generation); + runtime.shutdown().await; + super::wait_until_empty(cache.path()).await; +} + +#[tokio::test] +async fn adapter_registration_preserves_plugin_targets_with_typed_provider_decoding() { + for target in [ "../responses", "https://elsewhere.invalid/responses", "responses?other=1", @@ -512,13 +542,13 @@ async fn adapter_registration_rejects_target_escape_and_custom_providers() { "/responses", ] { let mut config = configuration("https://example.invalid", false); - config["upstream_registration"]["adapters"][0]["paths"][0]["path"] = json!(invalid); + config["upstream_registration"]["adapters"][0]["paths"][0]["path"] = json!(target); let (_, store, runtime) = setup(config).await; let snapshot = store.snapshot.lock().unwrap().clone(); assert!( gateway_admin::ports::plugins::PluginPreparation::prepare(&runtime, snapshot) .await - .is_err() + .is_ok() ); runtime.shutdown().await; } @@ -1006,7 +1036,7 @@ async fn upstream_adapter_can_dispatch_http_after_its_instance_entered_the_scope } #[tokio::test] -async fn malformed_adapter_events_stop_only_the_plugin_session() { +async fn malformed_adapter_events_fail_only_the_current_stream() { use gateway_admin::ports::plugins::PluginRuntimeDiagnostics as _; use std::error::Error as _; for (fact, has_sequence_error) in [ @@ -1043,7 +1073,7 @@ async fn malformed_adapter_events_stop_only_the_plugin_session() { ); } assert_eq!(error.diagnostic().unwrap().code(), Some("invalid_event")); - assert!(!generation.is_ready()); + assert!(generation.is_ready()); assert!(generation.can_serve()); let snapshot = store.snapshot.lock().unwrap().clone(); assert!(snapshot.instances[0].enabled); @@ -1051,10 +1081,7 @@ async fn malformed_adapter_events_stop_only_the_plugin_session() { .runtime_diagnostics(&snapshot, Some(1), Some(&generation)) .await .unwrap(); - assert_eq!( - diagnostics["instance-one"].failure.as_ref().unwrap().code, - "invalid_response" - ); + assert!(diagnostics["instance-one"].failure.is_none()); drop(stream); drop(generation); runtime.shutdown().await; diff --git a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs index fe9dcf67e..1be5f5e35 100644 --- a/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs +++ b/backend/crates/gateway-plugin/runtime/tests/rpc/session.rs @@ -376,23 +376,24 @@ async fn slow_management_call_does_not_block_an_independent_call() { } #[tokio::test] -async fn large_binary_payload_round_trips_through_calls_and_callbacks() { +async fn large_json_and_binary_payload_round_trip_through_calls_and_callbacks() { let callbacks = Arc::new(Callbacks::default()); let (_cache, session) = session(Arc::clone(&callbacks)).await; let payload = (0..4 * 1024 * 1024 + 17) .map(|index| (index % 251) as u8) .collect::>(); + let params = json!({"large": "x".repeat(256 * 1024)}); for method in ["echo", "callback"] { let reply = session .call( method, session.context(Stage::Request, Duration::from_secs(30)), - json!({"large": true}), + params.clone(), payload.clone(), ) .await .unwrap(); - assert_eq!(reply.result, json!({"large": true})); + assert_eq!(reply.result, params); assert_eq!(reply.payload, payload); } assert_eq!(callbacks.called.load(Ordering::Relaxed), 1); @@ -400,78 +401,83 @@ async fn large_binary_payload_round_trips_through_calls_and_callbacks() { } #[tokio::test] -async fn oversized_call_frames_do_not_close_the_session_or_begin_callbacks() { +async fn oversized_control_metadata_does_not_close_the_session_or_begin_callbacks() { let callbacks = Arc::new(LifecycleCallbacks::default()); let (_cache, session) = session(Arc::clone(&callbacks)).await; - let cases = [(json!({"text": "x".repeat(64 * 1024)}), vec![])]; - for (params, payload) in cases { - let begun = callbacks.begun.load(Ordering::Relaxed); - let error = session - .call( - "echo", + let method = "x".repeat(64 * 1024); + let error = session + .call( + &method, + session.context(Stage::Request, Duration::from_secs(2)), + json!({}), + vec![], + ) + .await + .err() + .expect("oversized control metadata must fail"); + assert!(matches!(error, RpcError::Context(_))); + assert!(matches!( + session + .call_stream( + &method, session.context(Stage::Request, Duration::from_secs(2)), - params.clone(), - payload.clone(), + json!({}), + vec![], ) - .await - .err() - .expect("oversized call must fail"); - assert!(matches!(error, RpcError::Context(_))); - assert!(matches!( - session - .call_stream( - "stream", - session.context(Stage::Request, Duration::from_secs(2)), - params, - payload, - ) - .await, - Err(RpcError::Context(_)) - )); - assert_eq!(callbacks.begun.load(Ordering::Relaxed), begun); - assert!(session.is_ready()); - let reply = session - .call( - "echo", - session.context(Stage::Request, Duration::from_secs(2)), - json!({"still": "ready"}), - vec![1, 2, 3], - ) - .await - .unwrap(); - assert_eq!(reply.result, json!({"still": "ready"})); - assert_eq!(reply.payload, [1, 2, 3]); - } + .await, + Err(RpcError::Context(_)) + )); + assert_eq!(callbacks.begun.load(Ordering::Relaxed), 0); + assert!(session.is_ready()); + let reply = session + .call( + "echo", + session.context(Stage::Request, Duration::from_secs(2)), + json!({"still": "ready"}), + vec![1, 2, 3], + ) + .await + .unwrap(); + assert_eq!(reply.result, json!({"still": "ready"})); + assert_eq!(reply.payload, [1, 2, 3]); session.shutdown(Duration::from_secs(1)).await; } #[tokio::test] -async fn oversized_callback_metadata_only_fails_its_parent_call() { +async fn large_callback_results_succeed_and_oversized_errors_only_fail_the_parent_call() { let (_cache, session) = session(Arc::new(OversizedReplyCallbacks)).await; - for error in [false, true] { - assert!(matches!( - session - .call( - "callback", - session.context(Stage::Request, Duration::from_secs(2)), - json!({"error": error}), - vec![], - ) - .await, - Err(RpcError::Remote(_)) - )); - assert!(session.is_ready()); - let reply = session + let reply = session + .call( + "callback", + session.context(Stage::Request, Duration::from_secs(2)), + json!({"error": false}), + vec![], + ) + .await + .unwrap(); + assert_eq!(reply.result, json!({"text": "x".repeat(64 * 1024)})); + assert!(matches!( + session .call( - "echo", + "callback", session.context(Stage::Request, Duration::from_secs(2)), - json!({"still": "ready"}), - vec![1, 2, 3], + json!({"error": true}), + vec![], ) - .await - .unwrap(); - assert_eq!(reply.payload, [1, 2, 3]); - } + .await, + Err(RpcError::Remote(_)) + )); + assert!(session.is_ready()); + let reply = session + .call( + "echo", + session.context(Stage::Request, Duration::from_secs(2)), + json!({"still": "ready"}), + vec![1, 2, 3], + ) + .await + .unwrap(); + assert_eq!(reply.payload, [1, 2, 3]); session.shutdown(Duration::from_secs(1)).await; } diff --git a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs index cbd2632b4..52f74e974 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/environment.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/environment.rs @@ -301,6 +301,16 @@ impl Environment { vec![], )]); } + let has_retry = configuration.get("retry_decision").is_some(); + if has_retry { + contributes.extend([super::contribution_for_id( + &plugin_id, + Capability::RetryPolicy, + vec![Stage::Retry], + vec![], + vec![], + )]); + } let service_worker = (has_service || has_http) .then(|| std::fs::read(env!("CARGO_BIN_EXE_gateway-plugin-test-middleware")).unwrap()); let worker = service_worker.as_deref().unwrap_or_else(|| super::worker()); @@ -338,6 +348,18 @@ impl Environment { secrets: BTreeMap::new(), bindings: [ + has_retry.then(|| PluginCapabilityBinding { + contribution: format!("{plugin_id}.retryPolicy"), + stage: "retry".into(), + order: 0, + failure_policy: PluginFailurePolicy::Delegate, + client_key_ids: vec![], + account_group_ids: vec![], + provider_ids: vec![], + models: vec![], + event: None, + identity_bindings: vec![], + }), has_upstream_adapter.then(|| PluginCapabilityBinding { contribution: format!("{plugin_id}.upstreamAdapter"), stage: "upstream".into(), @@ -432,11 +454,20 @@ impl Environment { pub async fn runtime_with_limits( &self, limits: RpcLimits, + ) -> (Arc, gateway_core::CoreBundle) { + self.runtime_with_providers(limits, super::native::provider_registry()) + .await + } + + pub async fn runtime_with_providers( + &self, + limits: RpcLimits, + providers: gateway_core::engine::provider::ProviderRegistry, ) -> (Arc, gateway_core::CoreBundle) { let runtime = self.plugin_runtime_with_limits(limits); let core = gateway_core::prepare( self.store.core_ports(), - super::native::provider_registry(), + providers, Some(runtime.clone()), Some(runtime.observer_registry()), Some(runtime.policy_registry()), diff --git a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs index 7727690a4..38b8e562c 100644 --- a/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs +++ b/backend/crates/gateway-plugin/runtime/tests/support/worker/mod.rs @@ -376,7 +376,15 @@ impl Peer { } } - async fn send(&self, message: Message, payload: Vec) { + async fn send(&self, mut message: Message, payload: Vec) { + if self.configuration["extra_result_fields"] == true + && let Message::Result { + result: Value::Object(result), + .. + } = &mut message + { + result.insert("plugin_extra".into(), json!(true)); + } self.output.send(Frame { message, payload }).await.unwrap(); } @@ -394,6 +402,7 @@ impl Peer { .unwrap(); } if self.configuration["invalid_response_method"].as_str() == Some(method.as_str()) { + self.append_observation_marker("invalid_response_marker", &json!({"method":method})); self.send( Message::Result { id, @@ -476,6 +485,51 @@ impl Peer { "policy.retry_decision" => { self.append_observation_marker("retry_marker", ¶ms); + if let Some(url) = self.configuration["retry_http_url"].as_str() { + let callback = + self.callback(id, "host.http.do", json!({"method":"POST", "url":url})); + if let Some(marker) = self.configuration["retry_http_return_marker"].as_str() { + tokio::select! { + _ = callback => {}, + () = async { + while !std::path::Path::new(marker).exists() { + tokio::time::sleep(Duration::from_millis(1)).await; + } + } => {}, + } + } else { + let _ = callback.await; + } + } + if let Some(request) = self + .configuration + .get("retry_service_request") + .filter(|value| !value.is_null()) + { + let result = match self + .callback( + id, + gateway_plugin_sdk::call::services::CALL_METHOD, + request.clone(), + ) + .await + { + Ok((result, _)) => result, + Err(error) => json!({"callback_error":error.code}), + }; + self.append_observation_marker("retry_service_marker", &result); + } + if self.configuration["retry_fault"] == true { + self.send( + Message::Error { + id, + error: PluginFault::new(ErrorCode::Fault, "fixture retry failure"), + }, + vec![], + ) + .await; + return; + } if let Some(delay) = self.configuration["retry_delay_ms"].as_u64() { tokio::time::sleep(Duration::from_millis(delay)).await; } @@ -1294,6 +1348,11 @@ async fn main() { handshake.configuration["execution_events"] = events; } let startup = record_startup(&handshake.configuration); + if let Some(gate) = handshake.configuration["startup_gate"].as_str() { + while !std::path::Path::new(gate).exists() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } if handshake .configuration .get("startup") diff --git a/backend/crates/gateway-plugin/sdk/Cargo.toml b/backend/crates/gateway-plugin/sdk/Cargo.toml index 4900ed5d7..5d2933b10 100644 --- a/backend/crates/gateway-plugin/sdk/Cargo.toml +++ b/backend/crates/gateway-plugin/sdk/Cargo.toml @@ -12,6 +12,7 @@ default = [] io = ["dep:tokio"] [dependencies] +base64 = "0.23.0" semver = { version = "1.0.27", features = ["serde"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" diff --git a/backend/crates/gateway-plugin/sdk/README.md b/backend/crates/gateway-plugin/sdk/README.md index 825d2f07d..3bf95dac9 100644 --- a/backend/crates/gateway-plugin/sdk/README.md +++ b/backend/crates/gateway-plugin/sdk/README.md @@ -8,7 +8,7 @@ SDK 处于实验阶段;能力是否可用取决于宿主支持、清单声明 | --- | --- | --- | | SDK 包 | `0.1.0` | Rust 开发依赖,独立于网关版本 | | `manifestVersion` | `2` | 安装清单格式 | -| `package.protocolVersion` | `3` | 宿主与插件的进程通信 | +| `package.protocolVersion` | `4` | 宿主与插件的进程通信 | | `contributes.*.version` | `middleware` 为 `5`,`scheduler` / `upstream_adapter` 为 `3`,其余见声明 | 单项能力合同,见[扩展项声明](docs/manifest.md#扩展项简写) | 开发顺序:编写[清单](docs/manifest.md) → 实现[能力处理器](docs/capabilities.md) → 用 [CLI](../../../apps/plugin-cli/README.md) @@ -92,14 +92,23 @@ sequenceDiagram 首个流式结果成功返回后,正文、会话及关联回调由资源取消与终态回收,不再沿用初始期限。 普通调用继续使用原期限;资源模式仍保留并发容量、Credit、父调用关联及取消约束 -协议 v3 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。 +协议 v4 的帧头由大端 `u32` 元数据长度和大端 `u64` 载荷长度组成,随后依次传输 JSON 元数据和二进制载荷。 元数据保留 64 KiB 上限;正文总量不由插件传输层额外限制。传输内部以 256 KiB 为单次 I/O 块, 不逐块发起 RPC,也不要求插件配置与宿主同步。读取按进度扩展缓冲区;截断正文必须报错,不能作为完整消息交付 -`Call` 带入口快照时,元数据的 `request_context_bytes` 指定载荷前缀中 JSON 快照的字节数,其后仍为原始业务正文。SDK 帧读写器自动拆装并还原 `CallContext.request`,不拼接复制业务正文;无快照时省略该长度字段 +JSON 元数据只承载消息类别、调用关联及分段长度。载荷依次包含入口快照 JSON、业务 JSON 和原始二进制正文: + +- `request_context_bytes` 指定 `CallContext.request` 的快照长度;无快照时省略 +- `data_bytes` 指定 `Hello.configuration`、`Call.params`、`Callback.params` 或 `Result.result` 的 JSON 长度;这些消息必须携带该段,其他消息省略 +- 两个 JSON 段之后的剩余字节是 `Frame.payload`,其内容不经过 JSON 编码 + +入口快照的 `headers[].value` 使用标准带填充 base64 字符串,保留重复头与任意字节;Rust API 仍为 `Vec`。这个表示法仅用于 `RequestContext`,不改变中间件业务数据的头值合同 + +SDK 帧读写器自动拆装这些字段,不拼接复制二进制正文。`Message` 与 `Handshake` 的 serde 表示只包含控制字段;完整消息应通过 `read_frame` / `write_frame` 读写 帧写入队列前用 `EncodedFrame::new` 完成编码和校验,写泵通过 `EncodedFrame::write` 复用编码结果, -避免重复序列化入口快照或让本地输入错误关闭共享传输。只需校验时可使用 `validate_frame`。 +避免重复序列化或让本地输入错误关闭共享传输。编码帧不保留业务 JSON 与入口快照的原始副本, +`EncodedFrame::message` 仅供关联消息类别和 ID。只需校验时可使用 `validate_frame`。 协议校验不会自动重试业务调用;部分读写中断后不能把当前位置当作新帧边界。 底层帧读写方法不是取消安全的;自行编排会话时,不能在 `select!` 的其他分支完成后 重建半途中的帧读写。`PluginSession` 会在处理调用完成通知时保留同一个读取 future,直到完整帧到达或会话关闭 diff --git a/backend/crates/gateway-plugin/sdk/docs/capabilities.md b/backend/crates/gateway-plugin/sdk/docs/capabilities.md index d828e14d8..173d78445 100644 --- a/backend/crates/gateway-plugin/sdk/docs/capabilities.md +++ b/backend/crates/gateway-plugin/sdk/docs/capabilities.md @@ -139,7 +139,7 @@ Provider 不支持刷新(如 OpenAI URL + Key 账号)返回 `invalid_input` 声明 `maintenance` 并注册 `methods::RECONCILE`,宿主会在实例启用发布、进程恢复及配置变更后调用 `plugin.reconcile`,并每 30 秒补偿一次。处理器必须幂等;调用可能重复,配置通知会合并,不表示逐条账号事件。 -每个实例串行执行,不同实例相互独立;调用限时 30 秒,失败后等待 5 秒重试。维护失败不回滚已经提交的资源, +每个实例串行执行,不同实例相互独立;调用限时 30 秒,返回值不消费,调用失败后等待 5 秒重试。维护失败不回滚已经提交的资源, 下一次对账继续补齐。只读校验、准备候选与 CLI 帮助不会启动维护;停用、替换和宿主关闭时取消旧任务 维护与其他处理器共用以下资源方法: @@ -444,9 +444,9 @@ restore_and_validate_response(response).await ### 模型目录 `model_catalog` v1 在 registration 阶段一次提交 `ModelCatalogRegistration { models: Vec }`,不需要请求级 binding。 -每条 `ModelAlias { id, provider, model }` 定义公开 ID、内置 Provider(`openai` 或 `xai`)及直接上游目标。 -上游目标须已存在于该 Provider 的当前目录;同名原生模型、静态映射、其他插件条目、别名链和循环均拒绝发布。 -每实例最多 256 条,整个二进制 JSON 最多 64 KiB;不允许覆盖原生元数据或自行宣称目标不支持的功能 +每条 `ModelAlias { id, provider, model }` 定义公开 ID、Provider 及直接上游目标,条目正确性由插件负责。 +每实例最多 256 条,整个二进制 JSON 最多 64 KiB;条目重复、目标不存在或 Provider 未注册不阻止宿主快照发布。 +请求按发布顺序取首个匹配条目,插件别名优先于静态映射,目标直接用于上游选路,不递归展开别名或目标上的静态映射 ```rust,ignore use gateway_plugin_sdk::call::catalog::{ModelAlias, ModelCatalogRegistration}; @@ -458,7 +458,7 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? ``` 有效目录、路由和目标能力随同一个不可变快照发布,`/v1/models`、详情、原生目录和 `host.models.list` 共用该事实及现有 Key/账号模型范围。 -原生别名继承目标的完整可公开对象;更新失败保留旧快照,禁用后新请求不再使用该别名,在途请求持有旧代次。 +目标存在时,别名复用目标的可公开对象;禁用后新请求不再使用该别名,在途请求持有旧代次。 v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须通过宿主的权限和能力校验,不提供多目标虚拟模型注册合同 ### 重试决策 @@ -467,7 +467,7 @@ v1 是直接别名,动态选择可组合 `model_router`;实际路由仍须 输入包含失败分类、必要状态码、发送状态、attempt 序号、Provider/上游模型、剩余路由次数和期限,以及 `allowed_actions`。 不发送账号凭据、请求正文或上游错误转储。路由次数不等同于 Provider 自有传输恢复预算 -返回 `RetryDecision::Delegate`、`Stop` 或 `Retry`。`Retry` 必须出现在宿主允许动作中,并且仅继续宿主已经选定的恢复路径; +返回 `RetryDecision::Delegate`、`Stop` 或 `Retry`。Core 仅在允许动作包含 `Retry` 时继续已经选定的恢复路径; 不能指定账号、延迟、目标或提高预算。响应已交付、发送不明确、外部副作用及续接绑定等既有安全门不可绕过。 Core 处理建立响应流前与流中的失败,并在策略返回后再次检查期限、取消和副作用 @@ -483,8 +483,8 @@ let plugin = PluginBuilder::from_json(include_bytes!("../plugin.json"))? .build()?; ``` -按 binding 顺序、插件 ID、实例 ID 确定委托链,首个合法非委托结果生效。整条链最多等待 2 秒,并受请求剩余期限约束。 -退出、超时、未知字段及不允许的动作委托后续处理,最终回到宿主决定;故障不改写原始上游错误。 +按 binding 顺序、插件 ID、实例 ID 确定委托链,首个非委托决定交给 Core 处理。整条链最多等待 2 秒,并受请求剩余期限约束。 +退出、超时或无法解码的决定委托后续处理,最终回到宿主决定;额外字段忽略,故障不改写原始上游错误。 middleware 的 `next` 最多调用一次,重试与额外模型执行通过独立调用创建 ### 路由、调度与观察 @@ -621,5 +621,5 @@ observer 也按常规计划执行;它不是插件执行期间的嵌套 `host.m ## 数据面入口认证 `frontend_auth.identifier` 返回稳定认证器标识;`frontend_auth.authenticate` 接收敏感的 Authorization 载荷并返回 -外部 principal。插件不能指定 Client Key,宿主根据当前入口配置完成映射,并继续检查 Key 的启用状态、模型规则、 +外部 principal。宿主忽略结果中的额外字段,包括附带的 `client_key_id`,只根据当前入口配置完成映射,并继续检查 Key 的启用状态、模型规则、 账号组、并发和预算。该能力只处理数据面身份,不接管管理端登录 diff --git a/backend/crates/gateway-plugin/sdk/docs/manifest.md b/backend/crates/gateway-plugin/sdk/docs/manifest.md index 61aa551ad..0d142a530 100644 --- a/backend/crates/gateway-plugin/sdk/docs/manifest.md +++ b/backend/crates/gateway-plugin/sdk/docs/manifest.md @@ -32,9 +32,9 @@ `capabilities` 使用扩展能力标识;Provider、模型和账号 ID 不属于这个集合 -清单与宿主兼容声明的格式版本为 `2`,进程协议版本为 `3`;清单不接受 `permissions` 字段 +清单与宿主兼容声明的格式版本为 `2`,进程协议版本为 `4`;清单不接受 `permissions` 字段 -RPC v3 的调用帧支持入口请求快照前缀,v2 插件须使用当前 SDK 重新构建,并由当前 CLI 重新打包;仅修改包内版本号不能迁移帧编解码器 +RPC v4 将入口请求快照和业务 JSON 放入载荷前缀,元数据只保留控制字段;插件须使用当前 SDK 重新构建,并由当前 CLI 重新打包,仅修改包内版本号不能迁移帧编解码器 安装包清单校验与已安装元数据读取是两个边界:清单拒绝未知字段,并检查必需字段、类型和版本; 数据库元数据的读取规则见[制品与发布](../../../../../docs/architecture.md#制品配置与发布),不改变清单或 RPC 合同。 diff --git a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md index f5774b1b1..34da7bfae 100644 --- a/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md +++ b/backend/crates/gateway-plugin/sdk/docs/upstream-adapters.md @@ -10,21 +10,21 @@ 使用 `PluginBuilder::on` 注册 `methods::UPSTREAM_ADAPTER_REGISTER` 与 `methods::UPSTREAM_ADAPTER_EXECUTE` -注册返回 `UpstreamAdapterRegistration`,包含 1 至 16 个适配器: +注册返回 `UpstreamAdapterRegistration`,最多包含 16 个适配器: | 字段 | 含义 | | --- | --- | -| `id` | 实例内唯一标识,不是 Provider 或账号类型 | +| `id` | 插件提供的适配器标识,不是 Provider 或账号类型 | | `provider` | `openai` 或 `xai` | -| `base_url` | 以 `/` 结尾的 HTTP(S) 基址,无用户信息、查询串或 fragment | -| `paths` | 不以 `/` 开头的基址相对路径,含 `inference` 或 `auxiliary` 用途,无通配符 | +| `base_url` | 解析请求目标的基址,路径拼接遵循 URL join 规则 | +| `paths` | 请求路径与 `inference` 或 `auxiliary` 用途的对应关系 | | `authentication_kinds` | 可使用的现有认证类型,宿主复核已选账号 | | `transport` | `http_json`、`http_sse` 或 `websocket` | -| `protocol` | 请求和客户端 wire 使用的协议,须在清单输入、输出格式内 | +| `protocol` | 请求和客户端 wire 使用的协议 | | `models` | 公开请求模型的精确匹配,空集合沿用绑定范围 | 实例绑定沿用 Key、账号组、Provider、模型范围,只允许一次 `upstream` 绑定和 `reject` 故障策略。 -同一请求不能同时命中两个适配器;配置准备时拒绝重叠,执行时再次复核。适配器未命中时仍使用原生上游, +多个声明命中时按发布顺序选择首个适配器,插件负责声明的业务含义。适配器未命中时仍使用原生上游, 已命中但不可用时拒绝请求。安装、配置、启停和升级使用已有插件管理 能力、清单、进程 RPC 和宿主兼容声明分别校验各自版本,见[清单](manifest.md)。 diff --git a/backend/crates/gateway-plugin/sdk/src/call/catalog.rs b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs index a0502c78c..2df185e3b 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/catalog.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/catalog.rs @@ -4,14 +4,12 @@ use serde::{Deserialize, Serialize}; /// 一次实例准备产生的完整目录,随实例配置原子发布 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ModelCatalogRegistration { pub models: Vec, } /// 指向内置 Provider 上游模型的直接别名,不创建新的 Provider 执行器 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ModelAlias { pub id: String, pub provider: String, diff --git a/backend/crates/gateway-plugin/sdk/src/call/frontend_authentication.rs b/backend/crates/gateway-plugin/sdk/src/call/frontend_authentication.rs index f823191c1..30946dd05 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/frontend_authentication.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/frontend_authentication.rs @@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize}; /// 插件在注册阶段返回的稳定认证器标识 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct FrontendAuthenticationIdentifier { pub identifier: String, } @@ -29,7 +28,7 @@ impl fmt::Debug for FrontendAuthenticationRequest { /// 插件只声明外部 principal,不得选择宿主 Client Key #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "outcome", rename_all = "snake_case")] pub enum FrontendAuthenticationResult { Authenticated { principal: String }, NotMatched {}, diff --git a/backend/crates/gateway-plugin/sdk/src/call/management.rs b/backend/crates/gateway-plugin/sdk/src/call/management.rs index 7b7d7bda8..4ea44ebe0 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/management.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/management.rs @@ -8,7 +8,6 @@ use super::host::AuthSaveRequest; /// `management.register` 冻结的路由与页面;路径均相对插件实例命名空间 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementRegistration { #[serde(default)] pub routes: Vec, @@ -22,7 +21,6 @@ pub struct ManagementRegistration { /// 公开登录回调;宿主签发的一次性 state 用于关联已发起的流程 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementCallback { pub path: String, pub response_content_types: Vec, @@ -30,7 +28,6 @@ pub struct ManagementCallback { /// 管理 handler 默认要求管理员身份;原始请求头完整传入插件 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementRoute { pub method: String, pub path: String, @@ -41,7 +38,6 @@ pub struct ManagementRoute { /// 资源须在包清单 resources 中声明并校验摘要;`public` 决定是否允许未登录访问 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementResource { pub path: String, #[serde(default)] @@ -49,7 +45,6 @@ pub struct ManagementResource { } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementPage { pub id: String, pub title: String, @@ -74,7 +69,6 @@ pub struct ManagementRequest { /// 原始响应体独立放在帧 payload;显式 headers 可覆盖宿主的默认响应头 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ManagementResponse { pub status: u16, pub content_type: String, @@ -84,14 +78,12 @@ pub struct ManagementResponse { /// `command_line.register` 的只读结果;注册和帮助查询不能执行命令或登录 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct CommandRegistration { pub commands: Vec, } /// 命令名只在插件实例的命名空间内生效,不注册宿主全局启动参数 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct CommandDescriptor { pub name: String, pub description: String, @@ -100,7 +92,6 @@ pub struct CommandDescriptor { } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct CommandParameter { pub name: String, pub description: String, @@ -126,12 +117,7 @@ pub enum CommandParameterType { /// 参数可能含登录材料,故意不实现内容型 Debug #[derive(Clone, PartialEq, Serialize, Deserialize)] -#[serde( - tag = "type", - content = "value", - rename_all = "snake_case", - deny_unknown_fields -)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] pub enum CommandValue { Bool(bool), String(String), @@ -151,7 +137,6 @@ pub struct CommandInvocation { /// 输出由宿主原样交付 CLI 调用方,不进入普通诊断 /// 待保存账号仅在退出码为零时按顺序经 Admin 提交;每项独立 CAS,失败不自动重试 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct CommandResult { pub stdout: String, pub stderr: String, diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/http.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/http.rs index 7664dc2a3..de08818a6 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware/http.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/http.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// 正文句柄消费一次;Bytes 使用当前 RPC 的 binary payload,Stream 使用响应流 #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Body { Empty, Handle { handle: String }, @@ -28,7 +28,6 @@ pub struct Request { } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Response { pub status: u16, pub version: Version, diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs index 777f750b2..249449c84 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/mod.rs @@ -45,7 +45,6 @@ pub enum MiddlewareBodyFraming { /// /// Header 值可能包含不适合日志的客户端数据,因此故意不实现 `Debug` #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct MiddlewareHeader { pub name: String, pub value: Vec, @@ -55,7 +54,7 @@ pub struct MiddlewareHeader { /// /// Header 值可能包含不适合日志的客户端数据,因此故意不实现 `Debug` #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "operation", rename_all = "snake_case")] pub enum MiddlewareHeaderMutation { Remove { name: String }, Append { name: String, value: Vec }, @@ -135,7 +134,6 @@ pub struct MiddlewareNextRequest { /// 受父调用约束的不透明响应正文句柄 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct MiddlewareBodyHandle { pub handle: String, pub framing: MiddlewareBodyFraming, @@ -160,7 +158,7 @@ pub struct MiddlewareNextResponse { /// 最终正文来源;`PassThrough` 直接转交下游正文,不触发读取 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum MiddlewareResponseBody { Empty, PassThrough { body: MiddlewareBodyHandle }, @@ -169,7 +167,6 @@ pub enum MiddlewareResponseBody { /// `middleware.handle` 的最终响应头;插件输出流仍使用既有 RPC Stream/Credit/End #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct MiddlewareResponseHead { /// `Some` 表示基于本层 next 返回的原响应;`None` 表示中间件短路响应 #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket.rs b/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket.rs index 66275a3dc..47bfa5d69 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/middleware/websocket.rs @@ -9,7 +9,7 @@ pub enum Direction { Outgoing, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Kind { Text, Binary, @@ -18,13 +18,12 @@ pub enum Kind { Close { code: Option }, } #[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum Payload { Bytes, Handle { handle: String }, } #[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Message { pub kind: Kind, pub payload: Payload, diff --git a/backend/crates/gateway-plugin/sdk/src/call/policy.rs b/backend/crates/gateway-plugin/sdk/src/call/policy.rs index 9bb162dc1..e532b1fdb 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/policy.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/policy.rs @@ -29,7 +29,7 @@ pub struct RetryDecisionRequest { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "decision", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "decision", rename_all = "snake_case")] pub enum RetryDecision { Delegate, Stop, @@ -60,7 +60,7 @@ pub struct ModelRouteRequest { /// 模型路由决定;宿主仍会复核目标、Key 权限和模型能力 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "decision", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "decision", rename_all = "snake_case")] pub enum ModelRouteDecision { Unhandled, Route { @@ -108,7 +108,7 @@ pub struct AccountScheduleRequest { /// 账号调度结果;账号租约始终由宿主在决定返回后取得 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "decision", rename_all = "snake_case", deny_unknown_fields)] +#[serde(tag = "decision", rename_all = "snake_case")] pub enum AccountScheduleDecision { Pick { account_id: String, diff --git a/backend/crates/gateway-plugin/sdk/src/call/registration.rs b/backend/crates/gateway-plugin/sdk/src/call/registration.rs index 8c26731a1..fd72255d4 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/registration.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/registration.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; use crate::Contributions; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct Registration { #[serde(deserialize_with = "crate::capability::deserialize_contributions")] pub contributes: Contributions, diff --git a/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs index dc62e49e8..e3edd7dd0 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/services/mod.rs @@ -22,7 +22,6 @@ pub struct Request { } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct ServiceError { pub kind: String, pub message: String, diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs index a8f0c47f1..111eb0c77 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/codec.rs @@ -9,7 +9,6 @@ use crate::call::model::{ }; #[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] struct Metadata { service_tier: Option, continuation: Option, diff --git a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs index b4e7a929a..62cde6323 100644 --- a/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs +++ b/backend/crates/gateway-plugin/sdk/src/call/upstream_adapter/mod.rs @@ -48,18 +48,16 @@ impl UpstreamTransport { /// 注册结果随实例配置冻结;同一实例可为两个内置 Provider 分别声明适配器 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct UpstreamAdapterRegistration { pub adapters: Vec, } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct UpstreamAdapterDeclaration { pub id: String, pub provider: BuiltinProvider, pub base_url: String, - /// 相对 Base URL 的精确业务路径,不接受绝对 URL、查询串或通配符 + /// 标记请求路径的业务用途;实际目标按 URL join 规则相对 Base URL 解析 pub paths: Vec, pub authentication_kinds: Vec, pub transport: UpstreamTransport, @@ -78,7 +76,6 @@ pub enum UpstreamPathPurpose { } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct UpstreamPath { pub path: String, pub purpose: UpstreamPathPurpose, @@ -122,7 +119,6 @@ pub enum ContinuationScope { } #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct UpstreamContinuation { pub scope: ContinuationScope, pub upstream_response_id: String, @@ -177,7 +173,6 @@ pub enum UpstreamFailureKind { /// 解析后的上游拒绝事实;发送状态始终由宿主网络观测,不接受插件声明 #[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] pub struct UpstreamFailure { pub kind: UpstreamFailureKind, pub status: Option, diff --git a/backend/crates/gateway-plugin/sdk/src/client/frame.rs b/backend/crates/gateway-plugin/sdk/src/client/frame.rs index 9fd466811..451ae9275 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/frame.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/frame.rs @@ -14,6 +14,8 @@ struct EncodedMetadata<'a> { message: &'a Message, #[serde(skip_serializing_if = "is_zero")] request_context_bytes: usize, + #[serde(skip_serializing_if = "is_zero")] + data_bytes: usize, } #[derive(Deserialize)] @@ -22,6 +24,8 @@ struct DecodedMetadata { message: Message, #[serde(default)] request_context_bytes: usize, + #[serde(default)] + data_bytes: usize, } fn is_zero(value: &usize) -> bool { @@ -31,27 +35,43 @@ fn is_zero(value: &usize) -> bool { /// 入队前完成编码;写泵复用已验证的字节,不重复序列化入口快照 pub struct EncodedFrame { frame: Frame, + sections: EncodedSections, +} + +struct EncodedSections { metadata: Vec, request: Vec, + data: Vec, } impl EncodedFrame { - pub fn new(frame: Frame) -> Result { - let (metadata, request) = encode_metadata(&frame)?; - Ok(Self { - frame, - metadata, - request, - }) + pub fn new(mut frame: Frame) -> Result { + let sections = encode_sections(&frame)?; + // 排队只持有编码后的业务数据,避免大型 JSON 与其编码副本同时驻留 + if let Some(value) = frame.message.data_mut() { + *value = serde_json::Value::Null; + } + if let Message::Call { context, .. } = &mut frame.message { + context.request = None; + } + Ok(Self { frame, sections }) } + /// 仅供写泵关联消息类别和 ID;业务 JSON 与入口快照已移入编码字节 #[must_use] pub fn message(&self) -> &Message { &self.frame.message } pub async fn write(&self, writer: &mut W) -> Result<(), FrameError> { - write_encoded(writer, &self.metadata, &self.request, &self.frame.payload).await + write_encoded( + writer, + &self.sections.metadata, + &self.sections.request, + &self.sections.data, + &self.frame.payload, + ) + .await } } @@ -65,10 +85,15 @@ pub async fn read_frame(reader: &mut R) -> Result 0) { + return Err(FrameError::Length); + } if request_context_bytes > 0 { let Message::Call { context, .. } = &mut message else { return Err(FrameError::Length); @@ -78,6 +103,10 @@ pub async fn read_frame(reader: &mut R) -> Result( writer: &mut W, frame: &Frame, ) -> Result<(), FrameError> { - let (metadata, request) = encode_metadata(frame)?; - write_encoded(writer, &metadata, &request, &frame.payload).await + let sections = encode_sections(frame)?; + write_encoded( + writer, + §ions.metadata, + §ions.request, + §ions.data, + &frame.payload, + ) + .await } async fn write_encoded( writer: &mut W, metadata: &[u8], request: &[u8], + data: &[u8], payload: &[u8], ) -> Result<(), FrameError> { let metadata_len = u32::try_from(metadata.len()).map_err(|_| FrameError::Length)?; let payload_len = payload .len() .checked_add(request.len()) + .and_then(|bytes| bytes.checked_add(data.len())) .ok_or(FrameError::Length)?; let payload_len = u64::try_from(payload_len).map_err(|_| FrameError::Length)?; writer.write_u32(metadata_len).await?; writer.write_u64(payload_len).await?; writer.write_all(metadata).await?; - // 两段分别写入,避免为入口快照复制或拼接业务正文 + // 各段分别写入,避免为 JSON 前缀复制或拼接二进制正文 for chunk in request .chunks(IO_CHUNK_BYTES) + .chain(data.chunks(IO_CHUNK_BYTES)) .chain(payload.chunks(IO_CHUNK_BYTES)) { writer.write_all(chunk).await?; @@ -136,25 +175,56 @@ async fn write_encoded( /// 入队前校验本地元数据,避免单次编码失败中断共享传输 pub fn validate_frame(frame: &Frame) -> Result<(), FrameError> { - encode_metadata(frame).map(drop) + encode_sections(frame).map(drop) } -fn encode_metadata(frame: &Frame) -> Result<(Vec, Vec), FrameError> { +fn encode_sections(frame: &Frame) -> Result { let request = match &frame.message { Message::Call { context, .. } => context.request.as_deref(), _ => None, } - .map(serde_json::to_vec) - .transpose() - .map_err(FrameError::Metadata)? + .map(encode_json) + .transpose()? .unwrap_or_default(); - let metadata = serde_json::to_vec(&EncodedMetadata { + let data = frame + .message + .data() + .map(encode_json) + .transpose()? + .unwrap_or_default(); + let metadata = encode_json(&EncodedMetadata { message: &frame.message, request_context_bytes: request.len(), - }) - .map_err(FrameError::Metadata)?; + data_bytes: data.len(), + })?; check_metadata_length(metadata.len())?; - Ok((metadata, request)) + Ok(EncodedSections { + metadata, + request, + data, + }) +} + +fn encode_json(value: &impl Serialize) -> Result, FrameError> { + struct Buffer(Vec); + + impl std::io::Write for Buffer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .try_reserve(bytes.len()) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?; + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let mut buffer = Buffer(Vec::new()); + serde_json::to_writer(&mut buffer, value).map_err(FrameError::Metadata)?; + Ok(buffer.0) } fn check_metadata_length(metadata: usize) -> Result<(), FrameError> { diff --git a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs index 0656f6358..ff33fb2b4 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/middleware/plugin.rs @@ -27,7 +27,7 @@ use super::{ /// /// let contributes = Contributions::from([(Capability::Middleware, ContributionDeclaration { /// id: "acme.request-tags.tagRequest".into(), -/// version: 4, +/// version: 5, /// stages: vec![Stage::Request], /// input_formats: vec!["openai".into()], /// output_formats: vec!["openai".into()], diff --git a/backend/crates/gateway-plugin/sdk/src/client/session/driver.rs b/backend/crates/gateway-plugin/sdk/src/client/session/driver.rs index d4d60868d..fbe4408ba 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/session/driver.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/session/driver.rs @@ -14,7 +14,7 @@ use crate::{ CallContext, ErrorCode, Frame, FrameError, Handshake, Message, PROTOCOL_VERSION, PluginFault, }; -use super::super::frame::{read_frame, validate_frame, write_frame}; +use super::super::frame::{EncodedFrame, read_frame, write_frame}; use super::{ CallCancellation, CallDeadline, HostClient, PluginCall, PluginHandler, SessionConfig, SessionError, @@ -480,7 +480,7 @@ async fn run_call( Err(error) => { let _ = send_initial( &output, - bounded_fault_frame(id, error), + bounded_fault_frame(id, error)?, &context, &lifecycle, &deadline, @@ -497,16 +497,16 @@ async fn run_call( }, payload: reply.payload, }; - if validate_frame(&initial).is_err() { - output.send_control(fault_frame( - id, - PluginFault::new( - ErrorCode::InvalidInput, - "plugin response metadata exceeds its limit", - ), - ))?; - return Ok(()); - } + let initial = match EncodedFrame::new(initial) { + Ok(initial) => initial, + Err(_) => { + output.send_control(fault_frame( + id, + PluginFault::new(ErrorCode::InvalidInput, "plugin response cannot be encoded"), + ))?; + return Ok(()); + } + }; let Some(mut stream) = reply.stream.take() else { let _ = send_initial( &output, @@ -575,7 +575,7 @@ async fn run_call( let payload = match item { Ok(payload) => payload, Err(fault) => { - output.send_control(bounded_end_frame(id, fault))?; + output.send_encoded_control(bounded_end_frame(id, fault)?)?; return Ok(()); } }; @@ -627,7 +627,7 @@ async fn run_call( async fn send_initial( output: &Output, - frame: Frame, + frame: EncodedFrame, context: &CallContext, lifecycle: &LifecycleHooks, deadline: &CallDeadline, @@ -657,7 +657,10 @@ async fn send_stream_data( deadline: &CallDeadline, cancellation: &CallCancellation, ) -> Result { - match output.send_data_until(frame, deadline, cancellation).await { + match output + .send_data_until(EncodedFrame::new(frame)?, deadline, cancellation) + .await + { Ok(()) => Ok(true), Err(SessionError::Cancelled) => Ok(false), Err(SessionError::Timeout) => { @@ -683,29 +686,27 @@ fn end_frame(id: u64, error: Option) -> Frame { Frame::control(Message::End { id, error }) } -fn bounded_fault_frame(id: u64, error: PluginFault) -> Frame { - let frame = fault_frame(id, error); - if validate_frame(&frame).is_ok() { - frame - } else { - fault_frame( - id, - PluginFault::new(ErrorCode::Fault, "plugin error metadata exceeds its limit"), - ) - } +fn bounded_fault_frame(id: u64, error: PluginFault) -> Result { + EncodedFrame::new(fault_frame(id, error)) + .or_else(|_| { + EncodedFrame::new(fault_frame( + id, + PluginFault::new(ErrorCode::Fault, "plugin error metadata exceeds its limit"), + )) + }) + .map_err(SessionError::from) } -fn bounded_end_frame(id: u64, error: PluginFault) -> Frame { - let frame = end_frame(id, Some(error)); - if validate_frame(&frame).is_ok() { - frame - } else { - end_frame( - id, - Some(PluginFault::new( - ErrorCode::Fault, - "plugin stream error metadata exceeds its limit", - )), - ) - } +fn bounded_end_frame(id: u64, error: PluginFault) -> Result { + EncodedFrame::new(end_frame(id, Some(error))) + .or_else(|_| { + EncodedFrame::new(end_frame( + id, + Some(PluginFault::new( + ErrorCode::Fault, + "plugin stream error metadata exceeds its limit", + )), + )) + }) + .map_err(SessionError::from) } diff --git a/backend/crates/gateway-plugin/sdk/src/client/session/output.rs b/backend/crates/gateway-plugin/sdk/src/client/session/output.rs index 32e991c39..acaadeca8 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/session/output.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/session/output.rs @@ -27,8 +27,7 @@ impl Clone for Output { } impl Output { - pub(super) async fn send_data(&self, frame: Frame) -> Result<(), SessionError> { - let frame = EncodedFrame::new(frame)?; + pub(super) async fn send_data(&self, frame: EncodedFrame) -> Result<(), SessionError> { let permit = Arc::clone(&self.data_slots) .acquire_owned() .await @@ -44,7 +43,7 @@ impl Output { pub(super) async fn send_data_until( &self, - frame: Frame, + frame: EncodedFrame, deadline: &CallDeadline, cancellation: &CallCancellation, ) -> Result<(), SessionError> { @@ -57,7 +56,10 @@ impl Output { } pub(super) fn send_control(&self, frame: Frame) -> Result<(), SessionError> { - let frame = EncodedFrame::new(frame)?; + self.send_encoded_control(EncodedFrame::new(frame)?) + } + + pub(super) fn send_encoded_control(&self, frame: EncodedFrame) -> Result<(), SessionError> { self.sender .try_send(Outbound { frame, diff --git a/backend/crates/gateway-plugin/sdk/src/client/session/registry.rs b/backend/crates/gateway-plugin/sdk/src/client/session/registry.rs index 06f9fc62f..2e9fec9a8 100644 --- a/backend/crates/gateway-plugin/sdk/src/client/session/registry.rs +++ b/backend/crates/gateway-plugin/sdk/src/client/session/registry.rs @@ -10,7 +10,7 @@ use tokio::sync::oneshot; use crate::{Frame, Message, call::middleware::NEXT_METHOD}; -use super::super::frame::validate_frame; +use super::super::frame::EncodedFrame; use super::{ CallCancellation, CallDeadline, CallDeadlinePause, SessionError, credit::CreditWindow, output::Output, @@ -77,10 +77,10 @@ impl HostClient { }, payload, }; - if validate_frame(&frame).is_err() { + let frame = EncodedFrame::new(frame).map_err(|_| { self.callbacks.retire(id); - return Err(SessionError::Protocol); - } + SessionError::Protocol + })?; let sent = tokio::select! { biased; () = self.cancellation.cancelled() => Err(SessionError::Cancelled), diff --git a/backend/crates/gateway-plugin/sdk/src/context/headers.rs b/backend/crates/gateway-plugin/sdk/src/context/headers.rs new file mode 100644 index 000000000..e5fe12fdf --- /dev/null +++ b/backend/crates/gateway-plugin/sdk/src/context/headers.rs @@ -0,0 +1,74 @@ +//! 入口快照头值的紧凑表示;不改变中间件业务合同或 Rust 字节 API + +use base64::{Engine as _, display::Base64Display, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +use crate::call::middleware::MiddlewareHeader; + +#[derive(Serialize)] +struct EncodedHeader<'a> { + name: &'a str, + #[serde(serialize_with = "serialize_value")] + value: &'a [u8], +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DecodedHeader { + name: String, + #[serde(deserialize_with = "deserialize_value")] + value: Vec, +} + +pub(super) fn serialize( + headers: &[MiddlewareHeader], + serializer: S, +) -> Result { + serializer.collect_seq(headers.iter().map(|header| EncodedHeader { + name: &header.name, + value: &header.value, + })) +} + +pub(super) fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Ok(Vec::::deserialize(deserializer)? + .into_iter() + .map(|header| MiddlewareHeader { + name: header.name, + value: header.value, + }) + .collect()) +} + +fn serialize_value(value: &[u8], serializer: S) -> Result { + // 直接写入序列化器,不为每个头值再分配一份 base64 字符串 + serializer.collect_str(&Base64Display::new(value, &STANDARD)) +} + +fn deserialize_value<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + struct ValueVisitor; + + impl de::Visitor<'_> for ValueVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a base64-encoded header value") + } + + fn visit_str(self, value: &str) -> Result { + let mut bytes = Vec::new(); + let capacity = base64::decoded_len_estimate(value.len()); + bytes.try_reserve_exact(capacity).map_err(E::custom)?; + bytes.resize(capacity, 0); + let length = STANDARD + .decode_slice(value, &mut bytes) + .map_err(E::custom)?; + bytes.truncate(length); + Ok(bytes) + } + } + + deserializer.deserialize_str(ValueVisitor) +} diff --git a/backend/crates/gateway-plugin/sdk/src/context.rs b/backend/crates/gateway-plugin/sdk/src/context/mod.rs similarity index 93% rename from backend/crates/gateway-plugin/sdk/src/context.rs rename to backend/crates/gateway-plugin/sdk/src/context/mod.rs index d6a161ed3..1db5b7c75 100644 --- a/backend/crates/gateway-plugin/sdk/src/context.rs +++ b/backend/crates/gateway-plugin/sdk/src/context/mod.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::{Contributions, Stage}; +mod headers; + /// 一次会话绑定不可变制品与配置;重启必须使用新的 incarnation #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -14,6 +16,8 @@ pub struct Handshake { pub instance_id: String, pub generation: u64, pub incarnation: String, + /// 配置由帧编码器放入业务 JSON 前缀 + #[serde(skip)] pub configuration: serde_json::Value, #[serde(deserialize_with = "crate::capability::deserialize_contributions")] pub contributes: Contributions, @@ -67,6 +71,8 @@ pub struct RequestContext { pub method: String, pub uri: String, pub http_version: String, + /// 帧内快照的头值使用 base64 字符串,避免 JSON 数字数组的逐字节开销 + #[serde(with = "headers")] pub headers: Vec, pub peer_addr: Option, pub client_ip: Option, diff --git a/backend/crates/gateway-plugin/sdk/src/message.rs b/backend/crates/gateway-plugin/sdk/src/message.rs index 7754e632c..55b390c5e 100644 --- a/backend/crates/gateway-plugin/sdk/src/message.rs +++ b/backend/crates/gateway-plugin/sdk/src/message.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::{CallContext, Handshake, PluginFault}; -pub const PROTOCOL_VERSION: u32 = 3; +pub const PROTOCOL_VERSION: u32 = 4; /// 元数据与二进制载荷分开,流分块不经过 JSON/base64 #[derive(Clone, PartialEq, Serialize, Deserialize)] @@ -21,10 +21,13 @@ pub enum Message { id: u64, method: String, context: CallContext, + // 业务 JSON 由帧编码器放入载荷前缀,不占用控制元数据预算 + #[serde(skip)] params: serde_json::Value, }, Result { id: u64, + #[serde(skip)] result: serde_json::Value, }, Error { @@ -35,6 +38,7 @@ pub enum Message { id: u64, parent_id: u64, method: String, + #[serde(skip)] params: serde_json::Value, }, Cancel { @@ -60,6 +64,27 @@ pub enum Message { Shutdown, } +#[cfg(feature = "io")] +impl Message { + pub(crate) const fn data(&self) -> Option<&serde_json::Value> { + match self { + Self::Hello { handshake } => Some(&handshake.configuration), + Self::Call { params, .. } | Self::Callback { params, .. } => Some(params), + Self::Result { result, .. } => Some(result), + _ => None, + } + } + + pub(crate) const fn data_mut(&mut self) -> Option<&mut serde_json::Value> { + match self { + Self::Hello { handshake } => Some(&mut handshake.configuration), + Self::Call { params, .. } | Self::Callback { params, .. } => Some(params), + Self::Result { result, .. } => Some(result), + _ => None, + } + } +} + #[derive(Clone, PartialEq)] pub struct Frame { pub message: Message, diff --git a/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs b/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs index 332865aec..8507aff67 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/frontend_authentication.rs @@ -6,13 +6,18 @@ use gateway_plugin_sdk::call::frontend_authentication::{ use serde_json::json; #[test] -fn authentication_contract_never_accepts_a_plugin_selected_client_key() { +fn authentication_result_only_consumes_the_principal_and_ignores_extra_fields() { let result = serde_json::from_value::(json!({ "outcome":"authenticated", "principal":"controlled-principal", "client_key_id":"attacker-selected-key" })); - assert!(result.is_err()); + assert_eq!( + result.unwrap(), + FrontendAuthenticationResult::Authenticated { + principal: "controlled-principal".into(), + } + ); } #[test] diff --git a/backend/crates/gateway-plugin/sdk/tests/call/management.rs b/backend/crates/gateway-plugin/sdk/tests/call/management.rs index bfcc5bec2..ec2f4d4bc 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/management.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/management.rs @@ -20,12 +20,11 @@ fn management_contract_keeps_http_bodies_separate_and_rejects_authority_headers( let mut forged = request; forged["headers"] = json!({"Cookie":"must-not-cross-wire"}); assert!(serde_json::from_value::(forged).is_err()); - assert!( - serde_json::from_value::( - json!({"status":200,"content_type":"text/html","set_cookie":"forged"}) - ) - .is_err() - ); + let response = serde_json::from_value::( + json!({"status":200,"content_type":"text/html","set_cookie":"forged"}), + ) + .unwrap(); + assert!(response.headers.is_empty()); } #[test] @@ -58,14 +57,13 @@ fn command_values_preserve_types_and_portable_numeric_boundaries() { json!({"type":"int","value":2147483648_i64}), json!({"type":"duration","value":0.5}), json!({"type":"bool","value":"true"}), - json!({"type":"string","value":"test","authority":"admin"}), ] { assert!(serde_json::from_value::(invalid).is_err()); } } #[test] -fn command_registration_and_result_reject_unknown_authority_fields() { +fn command_registration_and_result_ignore_extra_fields_without_granting_authority() { let registration = json!({"commands":[{ "name":"login", "description":"登录", "parameters":[{ "name":"timeout", "description":"等待时长", "value_type":"duration", @@ -76,9 +74,24 @@ fn command_registration_and_result_reject_unknown_authority_fields() { assert_eq!(value.commands[0].name, "login"); let mut invalid = registration; invalid["commands"][0]["admin_id"] = json!("forged"); - assert!(serde_json::from_value::(invalid).is_err()); - let result: CommandResult = - serde_json::from_value(json!({"stdout":"out\n","stderr":"err\n","exit_code":17})).unwrap(); + assert_eq!( + serde_json::from_value::(invalid) + .unwrap() + .commands[0] + .name, + "login" + ); + assert!( + serde_json::from_value::( + json!({"type":"string","value":"test","authority":"admin"}) + ) + .unwrap() + == CommandValue::String("test".into()) + ); + let result: CommandResult = serde_json::from_value( + json!({"stdout":"out\n","stderr":"err\n","exit_code":17,"admin_id":"ignored"}), + ) + .unwrap(); assert_eq!(result.exit_code, 17); assert!(result.accounts.is_empty()); assert!( diff --git a/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs index 76df76167..c6bc5f224 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/middleware/mod.rs @@ -133,6 +133,11 @@ fn response_body_modes_distinguish_transfer_empty_and_plugin_stream() { header_mutations: Vec::new(), body: MiddlewareResponseBody::PassThrough { body: handle }, }; + let mut extra = serde_json::to_value(&pass_through).unwrap(); + extra["plugin_extra"] = json!(true); + extra["body"]["plugin_extra"] = json!(true); + extra["body"]["body"]["plugin_extra"] = json!(true); + assert!(serde_json::from_value::(extra).unwrap() == pass_through); assert_eq!( serde_json::to_value(pass_through).unwrap()["body"]["kind"], "pass_through" diff --git a/backend/crates/gateway-plugin/sdk/tests/call/policy.rs b/backend/crates/gateway-plugin/sdk/tests/call/policy.rs index 7a3ec9987..64eb1de86 100644 --- a/backend/crates/gateway-plugin/sdk/tests/call/policy.rs +++ b/backend/crates/gateway-plugin/sdk/tests/call/policy.rs @@ -1,4 +1,4 @@ -//! 验证插件路由和账号调度决策使用显式且严格的线协议值 +//! 验证插件路由和账号调度决策的类型合同与附加字段透传边界 use gateway_plugin_sdk::call::policy::{ AccountScheduleCandidate, AccountScheduleDecision, AccountScheduleRequest, ModelRouteDecision, @@ -7,7 +7,7 @@ use gateway_plugin_sdk::call::policy::{ use serde_json::json; #[test] -fn route_and_scheduler_decisions_are_explicit_and_strict() { +fn route_and_scheduler_decisions_ignore_unused_fields() { let route = ModelRouteRequest { request_id: "req_policy".into(), operation: "generate".into(), @@ -31,11 +31,15 @@ fn route_and_scheduler_decisions_are_explicit_and_strict() { .unwrap(), json!({"decision":"route","provider":"openai"}) ); - assert!( + assert_eq!( serde_json::from_value::( json!({"decision":"route","provider":"openai","unexpected":true}) ) - .is_err() + .unwrap(), + ModelRouteDecision::Route { + provider: Some("openai".into()), + model: None, + } ); let schedule = AccountScheduleRequest { @@ -84,8 +88,16 @@ fn filtered_delegation_has_an_explicit_exclusion_list() { for invalid in [ json!({"decision":"delegate_filtered"}), json!({"decision":"delegate_filtered","exclude_account_ids":null}), - json!({"decision":"delegate_filtered","exclude_account_ids":[],"threshold":90}), ] { assert!(serde_json::from_value::(invalid).is_err()); } + assert_eq!( + serde_json::from_value::( + json!({"decision":"delegate_filtered","exclude_account_ids":[],"threshold":90}) + ) + .unwrap(), + AccountScheduleDecision::DelegateFiltered { + exclude_account_ids: vec![] + } + ); } diff --git a/backend/crates/gateway-plugin/sdk/tests/client/frame.rs b/backend/crates/gateway-plugin/sdk/tests/client/frame.rs index 134f14a4b..671f0fce2 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/frame.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/frame.rs @@ -57,11 +57,7 @@ async fn large_payload_is_exact_across_multiple_io_buffers_and_following_frames( #[tokio::test] async fn truncated_large_payload_never_becomes_a_successful_message() { - let message = serde_json::to_vec(&Message::Result { - id: 1, - result: json!({}), - }) - .unwrap(); + let message = serde_json::to_vec(&Message::Stream { id: 1, sequence: 0 }).unwrap(); let mut bytes = Vec::new(); bytes.extend_from_slice(&(message.len() as u32).to_be_bytes()); bytes.extend_from_slice(&(101_u64 * 1024 * 1024).to_be_bytes()); @@ -73,6 +69,138 @@ async fn truncated_large_payload_never_becomes_a_successful_message() { )); } +#[tokio::test] +async fn business_json_uses_payload_prefix_for_configuration_calls_callbacks_and_results() { + let data = + json!({"value": "x".repeat(256 * 1024), "empty": null, "zero": 0, "disabled": false}); + let messages = [ + Message::Hello { + handshake: gateway_plugin_sdk::Handshake { + protocol_version: gateway_plugin_sdk::PROTOCOL_VERSION, + artifact_sha256: "a".repeat(64), + plugin_id: "test.example".into(), + instance_id: "test-instance".into(), + generation: 1, + incarnation: "test-incarnation".into(), + configuration: data.clone(), + contributes: Default::default(), + }, + }, + Message::Call { + id: 1, + method: "policy.schedule_account".into(), + context: super::session::context(1, std::time::Duration::from_secs(5)), + params: data.clone(), + }, + Message::Callback { + id: 2, + parent_id: 1, + method: "host.state.put".into(), + params: data.clone(), + }, + Message::Result { + id: 2, + result: data, + }, + ]; + for message in messages { + let frame = Frame { + message, + payload: vec![0, 255, 128, 42], + }; + let encoded = gateway_plugin_sdk::client::EncodedFrame::new(frame.clone()).unwrap(); + let mut bytes = Vec::new(); + encoded.write(&mut bytes).await.unwrap(); + let metadata_length = u32::from_be_bytes(bytes[..4].try_into().unwrap()) as usize; + assert!(metadata_length < 1024); + let metadata: serde_json::Value = + serde_json::from_slice(&bytes[12..12 + metadata_length]).unwrap(); + assert!(metadata["data_bytes"].as_u64().unwrap() > 256 * 1024); + assert!(metadata.get("params").is_none()); + assert!(metadata.get("result").is_none()); + assert!(metadata["handshake"].get("configuration").is_none()); + let next = Frame::control(Message::Cancel { id: 3 }); + write_frame(&mut bytes, &next).await.unwrap(); + let mut reader = bytes.as_slice(); + assert_eq!(read_frame(&mut reader).await.unwrap(), frame); + assert_eq!(read_frame(&mut reader).await.unwrap(), next); + assert!(reader.is_empty()); + } +} + +#[tokio::test] +async fn data_prefix_lengths_cannot_consume_the_following_frame() { + for (message, request_bytes, data_bytes, payload_bytes) in [ + (Message::Cancel { id: 1 }, 0, 100, 100), + ( + Message::Result { + id: 1, + result: json!(null), + }, + 0, + 101, + 100, + ), + ( + Message::Result { + id: 1, + result: json!(null), + }, + 0, + 0, + 0, + ), + ( + Message::Call { + id: 1, + method: "echo".into(), + context: super::session::context(1, std::time::Duration::from_secs(5)), + params: json!(null), + }, + 11, + 10, + 20, + ), + ] { + let mut metadata = serde_json::to_value(message).unwrap(); + metadata["request_context_bytes"] = json!(request_bytes); + metadata["data_bytes"] = json!(data_bytes); + let metadata = serde_json::to_vec(&metadata).unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&(metadata.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&(payload_bytes as u64).to_be_bytes()); + bytes.extend_from_slice(&metadata); + let next = Frame::control(Message::Cancel { id: 3 }); + write_frame(&mut bytes, &next).await.unwrap(); + let mut reader = bytes.as_slice(); + assert!(matches!( + read_frame(&mut reader).await, + Err(FrameError::Length) + )); + assert_eq!(read_frame(&mut reader).await.unwrap(), next); + } +} + +#[tokio::test] +async fn deeply_nested_business_json_is_rejected_within_the_parser_stack_budget() { + let data = format!("{}0{}", "[".repeat(4096), "]".repeat(4096)); + let metadata = serde_json::to_vec(&json!({ + "type": "result", + "id": 1, + "data_bytes": data.len(), + })) + .unwrap(); + let mut bytes = Vec::new(); + bytes.extend_from_slice(&(metadata.len() as u32).to_be_bytes()); + bytes.extend_from_slice(&(data.len() as u64).to_be_bytes()); + bytes.extend_from_slice(&metadata); + bytes.extend_from_slice(data.as_bytes()); + assert!(matches!( + read_frame(&mut bytes.as_slice()).await, + Err(FrameError::Metadata(error)) if error.is_syntax() + )); +} + #[tokio::test] async fn truncated_frame_never_becomes_a_successful_message() { let mut bytes = Vec::new(); @@ -96,11 +224,15 @@ async fn request_context_uses_payload_prefix_without_consuming_metadata_budget_o headers: vec![ gateway_plugin_sdk::call::middleware::MiddlewareHeader { name: "x-repeat".into(), - value: vec![255; 30_000], + value: vec![255; 60_000], }, gateway_plugin_sdk::call::middleware::MiddlewareHeader { name: "x-repeat".into(), - value: vec![128, 0, 255], + value: (0..=255).collect(), + }, + gateway_plugin_sdk::call::middleware::MiddlewareHeader { + name: "x-empty".into(), + value: vec![], }, ], peer_addr: Some("[2001:db8::1]:1234".into()), @@ -130,6 +262,16 @@ async fn request_context_uses_payload_prefix_without_consuming_metadata_budget_o serde_json::from_slice(&bytes[12..12 + metadata_length]).unwrap(); assert!(metadata["request_context_bytes"].as_u64().unwrap() > 64 * 1024); assert!(metadata["context"].get("request").is_none()); + let request_start = 12 + metadata_length; + let request_end = request_start + metadata["request_context_bytes"].as_u64().unwrap() as usize; + let request: serde_json::Value = + serde_json::from_slice(&bytes[request_start..request_end]).unwrap(); + assert_eq!( + request["headers"][0]["value"].as_str().unwrap().len(), + 80_000 + ); + assert_eq!(request["headers"][1]["value"].as_str().unwrap().len(), 344); + assert_eq!(request["headers"][2]["value"], ""); write_frame(&mut bytes, &next).await.unwrap(); let mut reader = bytes.as_slice(); assert_eq!(read_frame(&mut reader).await.unwrap(), frame); @@ -137,6 +279,24 @@ async fn request_context_uses_payload_prefix_without_consuming_metadata_budget_o assert!(reader.is_empty()); } +#[test] +fn request_context_header_values_accept_json_escapes_and_reject_invalid_base64() { + let json = r#"{ + "method":"GET","uri":"/","http_version":"HTTP/1.1", + "headers":[{"name":"x-binary","value":"\u0041P8="}], + "peer_addr":null,"client_ip":null + }"#; + let request: gateway_plugin_sdk::RequestContext = serde_json::from_str(json).unwrap(); + assert_eq!(request.headers[0].value, [0, 255]); + let invalid = json.replace("\\u0041P8=", "not base64"); + assert!(serde_json::from_str::(&invalid).is_err()); + // 入口快照的紧凑表示不改变中间件业务数据的头值合同 + assert_eq!( + serde_json::to_value(&request.headers[0]).unwrap()["value"], + json!([0, 255]) + ); +} + #[tokio::test] async fn context_prefix_cannot_exceed_payload_or_appear_on_non_call_messages() { for payload_length in [0_u64, 100] { diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session/driver.rs b/backend/crates/gateway-plugin/sdk/tests/client/session/driver.rs index 06a3acff6..2fa299514 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session/driver.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session/driver.rs @@ -476,18 +476,13 @@ async fn untouched_middleware_response_transfers_opaque_body_without_reading_it( } #[tokio::test] -async fn oversized_handler_faults_are_replaced_without_closing_the_session() { +async fn large_handler_results_succeed_and_oversized_faults_keep_the_session_available() { let (mut host, task) = start_session(TestHandler::default()).await; - send_call(&mut host, 1, "oversized_result", json!({}), Vec::new()).await; + send_call(&mut host, 1, "large_result", json!({}), Vec::new()).await; assert!(matches!( receive(&mut host).await.message, - Message::Error { - id: 1, - error: PluginFault { - code: ErrorCode::InvalidInput, - .. - }, - } + Message::Result { id: 1, result } + if result == json!("x".repeat(MAXIMUM_STREAM_CHUNK_BYTES)) )); send_call(&mut host, 3, "oversized_fault", json!({}), Vec::new()).await; diff --git a/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs b/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs index 05e332c67..f955cf54a 100644 --- a/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs +++ b/backend/crates/gateway-plugin/sdk/tests/client/session/mod.rs @@ -193,7 +193,7 @@ impl PluginHandler for TestHandler { ErrorCode::Upstream, "x".repeat(MAXIMUM_STREAM_CHUNK_BYTES), )), - "oversized_result" => Ok(CallReply::unary( + "large_result" => Ok(CallReply::unary( Value::String("x".repeat(MAXIMUM_STREAM_CHUNK_BYTES)), Vec::new(), )), diff --git a/backend/crates/gateway-plugin/sdk/tests/manifest.rs b/backend/crates/gateway-plugin/sdk/tests/manifest.rs index 329b1c4c7..9838d78de 100644 --- a/backend/crates/gateway-plugin/sdk/tests/manifest.rs +++ b/backend/crates/gateway-plugin/sdk/tests/manifest.rs @@ -121,7 +121,7 @@ fn installation_checks_protocol_engine_and_target() { manifest.package_for(&host, "windows", "aarch64"), Err(ManifestError::Platform) ); - for protocol_version in [2, PROTOCOL_VERSION + 1] { + for protocol_version in [PROTOCOL_VERSION - 1, PROTOCOL_VERSION + 1] { manifest.package.as_mut().unwrap().protocol_version = protocol_version; assert_eq!(manifest.validate(), Err(ManifestError::Incompatible)); } @@ -409,7 +409,6 @@ fn contributes_rejects_duplicate_capability_keys_in_direct_json_parsing() { r#"{{ "protocol_version":4,"artifact_sha256":"digest","plugin_id":"test.example", "instance_id":"instance","generation":1,"incarnation":"incarnation", - "configuration":{{}}, "contributes":{{"middleware":{declaration},"middleware":{declaration}}} }}"# ); diff --git a/backend/crates/gateway-store/src/bundle.rs b/backend/crates/gateway-store/src/bundle.rs index 10921bc35..136ee1288 100644 --- a/backend/crates/gateway-store/src/bundle.rs +++ b/backend/crates/gateway-store/src/bundle.rs @@ -172,6 +172,9 @@ async fn connect( )?); let plugins = Arc::new(postgres::PgPluginStore::new(pool.clone())); + if !read_only { + plugins.recover_abandoned_state_transitions().await?; + } let usage_data = Arc::new(postgres::PgUsageDataStore::new( pool.clone(), observability_query_budget.clone(), diff --git a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs index 0a235055c..0dfd3b00d 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/artifacts.rs @@ -17,18 +17,25 @@ use gateway_admin::{ }, }; use sqlx::{PgPool, Row as _}; +use std::sync::Arc; +use tokio::sync::Semaphore; use super::super::{append_admin_audit_event_in_transaction, bump_config_revision_in_transaction}; use crate::{admin_revision, admin_store_error, mutation_audit}; pub struct PgPluginStore { pub(super) pool: PgPool, + pub(super) transition_slot: Arc, } impl PgPluginStore { #[must_use] pub fn new(pool: PgPool) -> Self { - Self { pool } + Self { + pool, + // 实例发布共享全局 revision;每个宿主只保留一个迁移会话,避免无效竞争占满连接 + transition_slot: Arc::new(Semaphore::new(1)), + } } } diff --git a/backend/crates/gateway-store/src/postgres/plugins/state.rs b/backend/crates/gateway-store/src/postgres/plugins/state.rs index abda3a9b1..08782f8d5 100644 --- a/backend/crates/gateway-store/src/postgres/plugins/state.rs +++ b/backend/crates/gateway-store/src/postgres/plugins/state.rs @@ -1,6 +1,9 @@ //! 插件私有状态的 PostgreSQL 读写、代次隔离、配额与迁移事务 -use std::collections::{BTreeMap, BTreeSet}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; use async_trait::async_trait; use gateway_admin::{ @@ -25,12 +28,60 @@ use gateway_admin::{ store::{AdminStoreError, AdminStoreErrorKind, AdminStoreResult}, }, }; -use sqlx::{Postgres, Row as _, Transaction}; +use sqlx::{PgConnection, Postgres, Row as _, Transaction}; +use tokio::sync::OwnedSemaphorePermit; use super::artifacts::PgPluginStore; const MIGRATION_BATCH_MAXIMUM: u32 = 100; +struct TransitionGuard { + // 独立会话不占连接池名额,也不持有空闲事务;取消或宿主退出后断连释放咨询锁 + _connection: PgConnection, + _slot: OwnedSemaphorePermit, +} + +impl PgPluginStore { + /// 启动时只回收没有迁移所有者的暂存代次,不改变实例启用状态或重放迁移 + pub async fn recover_abandoned_state_transitions(&self) -> crate::StoreResult { + let instances: Vec = sqlx::query_scalar( + "select distinct instance_id from plugin_state_generations where status='staging'", + ) + .fetch_all(&self.pool) + .await + .map_err(|error| crate::postgres_unavailable("list abandoned plugin transitions", error))?; + let mut removed = 0; + for instance in instances { + let mut transaction = self.pool.begin().await.map_err(|error| { + crate::postgres_unavailable("begin plugin transition recovery", error) + })?; + let acquired: bool = sqlx::query_scalar( + "select pg_try_advisory_xact_lock(hashtextextended($1, 739220))", + ) + .bind(instance.to_string()) + .fetch_one(&mut *transaction) + .await + .map_err(|error| { + crate::postgres_unavailable("lock abandoned plugin transition", error) + })?; + if acquired { + removed += sqlx::query( + "delete from plugin_state_generations where instance_id=$1 and status='staging'", + ) + .bind(instance) + .execute(&mut *transaction) + .await + .map_err(|error| crate::postgres_unavailable("discard abandoned plugin transition", error))? + .rows_affected(); + } + transaction.commit().await.map_err(|error| { + crate::postgres_unavailable("commit plugin transition recovery", error) + })?; + } + Ok(removed) + } +} + struct ActiveGeneration { id: uuid::Uuid, namespace: String, @@ -549,6 +600,30 @@ impl PluginStateStore for PgPluginStore { ) -> PluginStateStoreResult { validate_configuration(&target)?; let instance_uuid = uuid::Uuid::parse_str(instance_id).map_err(|_| invalid())?; + let slot = self + .transition_slot + .clone() + .try_acquire_owned() + .map_err(|_| conflict())?; + let mut connection = self + .pool + .acquire() + .await + .map_err(|_| unavailable())? + .detach(); + let acquired: bool = + sqlx::query_scalar("select pg_try_advisory_lock(hashtextextended($1, 739220))") + .bind(instance_uuid.to_string()) + .fetch_one(&mut connection) + .await + .map_err(|_| unavailable())?; + if !acquired { + return Err(conflict()); + } + let guard = Arc::new(TransitionGuard { + _connection: connection, + _slot: slot, + }); let mut transaction = self.pool.begin().await.map_err(|_| unavailable())?; let current = sqlx::query("select enabled, revision from plugin_instances where id=$1 for update") @@ -567,17 +642,14 @@ impl PluginStateStore for PgPluginStore { { return Err(conflict()); } - let staging_exists: bool = sqlx::query_scalar( - "select exists(select 1 from plugin_state_generations \ - where instance_id=$1 and status='staging')", + // 咨询锁已排除所有活跃所有者,遗留暂存数据只可能属于中断的迁移 + sqlx::query( + "delete from plugin_state_generations where instance_id=$1 and status='staging'", ) .bind(instance_uuid) - .fetch_one(&mut *transaction) + .execute(&mut *transaction) .await .map_err(|_| unavailable())?; - if staging_exists { - return Err(conflict()); - } let active = active_generations(&mut transaction, instance_uuid, true).await?; let targets = target_map(&target); let transition_id = uuid::Uuid::new_v4(); @@ -642,6 +714,7 @@ impl PluginStateStore for PgPluginStore { instance_id: instance_id.to_owned(), artifact_sha256: artifact_sha256.to_owned(), namespaces, + guard, }) } diff --git a/backend/crates/gateway-store/tests/postgres/plugins/state.rs b/backend/crates/gateway-store/tests/postgres/plugins/state.rs index 9f257f8ee..0ec133225 100644 --- a/backend/crates/gateway-store/tests/postgres/plugins/state.rs +++ b/backend/crates/gateway-store/tests/postgres/plugins/state.rs @@ -1,6 +1,6 @@ //! 验证插件私有状态迁移的顺序、配额与代次资源回收 -use std::collections::BTreeMap; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use gateway_admin::{ model::{ @@ -140,6 +140,243 @@ async fn migrate_all( } } +async fn transition_fixture( + store: &PgPluginStore, +) -> (PluginInstance, String, PluginStateConfiguration) { + let installed = install_version(store, 'a', "1.0.0").await; + let saved = store + .save_instance_with_state( + instance(installed.artifact.metadata.sha256), + installed.config_revision, + PluginStateCommit { + configuration: configuration(schema(1, vec![], 10)), + transition_id: None, + }, + &context(), + ) + .await + .unwrap(); + let target = install_version(store, 'b', "2.0.0").await; + ( + saved.instance, + target.artifact.metadata.sha256, + configuration(schema(2, vec![1], 10)), + ) +} + +#[tokio::test] +async fn cancelled_migration_releases_ownership_and_can_be_retried() { + let Some(database) = TestDatabase::create("plugin_state_cancel").await else { + return; + }; + initialize_revision(&database).await; + let store = Arc::new(PgPluginStore::new(database.pool.clone())); + let (instance, digest, target) = transition_fixture(&store).await; + let (started, migration) = tokio::sync::oneshot::channel(); + let task = { + let store = store.clone(); + let instance = instance.clone(); + let digest = digest.clone(); + let target = target.clone(); + tokio::spawn(async move { + let transition = store + .begin_transition(&instance.id, instance.revision, &digest, target) + .await + .unwrap(); + started.send(transition.id.clone()).unwrap(); + std::future::pending::<()>().await; + drop(transition); + }) + }; + let abandoned_id = migration.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + // 关闭会话由 PostgreSQL 异步处理,重试只等待所有权释放,不调用清理接口补偿 + let retried = tokio::time::timeout(Duration::from_secs(3), async { + loop { + match store + .begin_transition(&instance.id, instance.revision, &digest, target.clone()) + .await + { + Ok(transition) => break transition, + Err(error) if error.kind() == PluginStateStoreErrorKind::Conflict => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => panic!("unexpected retry failure: {error:?}"), + } + } + }) + .await + .expect("cancelled migration must release its session lock"); + assert_ne!(retried.id, abandoned_id); + let retained: bool = sqlx::query_scalar( + "select exists(select 1 from plugin_state_generations where transition_id=$1)", + ) + .bind(uuid::Uuid::parse_str(&abandoned_id).unwrap()) + .fetch_one(&database.pool) + .await + .unwrap(); + assert!(!retained); + let current = store.load_instances().await.unwrap(); + assert_eq!(current.instances[0].revision, instance.revision); + assert!(!current.instances[0].enabled); + drop(retried); + database.close().await; +} + +#[tokio::test] +async fn startup_recovery_preserves_live_owners_and_does_not_starve_a_single_connection_pool() { + let Some(database) = TestDatabase::create("plugin_state_recovery").await else { + return; + }; + initialize_revision(&database).await; + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(database.pool.connect_options().as_ref().clone()) + .await + .unwrap(); + let store = PgPluginStore::new(pool.clone()); + let (instance, digest, target) = transition_fixture(&store).await; + let transition = tokio::time::timeout( + Duration::from_secs(3), + store.begin_transition(&instance.id, instance.revision, &digest, target.clone()), + ) + .await + .expect("migration must not retain a pooled connection") + .unwrap(); + let reopened = PgPluginStore::new(pool.clone()); + assert_eq!( + reopened + .recover_abandoned_state_transitions() + .await + .unwrap(), + 0 + ); + let error = reopened + .begin_transition(&instance.id, instance.revision, &digest, target) + .await + .err() + .unwrap(); + assert_eq!(error.kind(), PluginStateStoreErrorKind::Conflict); + let retained = transition.clone(); + drop(transition); + assert_eq!( + reopened + .recover_abandoned_state_transitions() + .await + .unwrap(), + 0 + ); + drop(retained); + tokio::time::timeout(Duration::from_secs(3), async { + while reopened + .recover_abandoned_state_transitions() + .await + .unwrap() + == 0 + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("startup recovery must discard ownerless staging"); + let current = reopened.load_instances().await.unwrap(); + assert_eq!(current.instances[0].revision, instance.revision); + assert!(!current.instances[0].enabled); + pool.close().await; + database.close().await; +} + +#[tokio::test] +async fn lost_migration_session_cannot_promote_after_another_owner_recovers() { + let Some(database) = TestDatabase::create("plugin_state_crash").await else { + return; + }; + initialize_revision(&database).await; + let store = PgPluginStore::new(database.pool.clone()); + let (instance, digest, target) = transition_fixture(&store).await; + let transition = store + .begin_transition(&instance.id, instance.revision, &digest, target.clone()) + .await + .unwrap(); + migrate_all(&store, &transition.id, "cache", 100).await; + // 直接中断持锁数据库会话,覆盖宿主崩溃后的服务端锁释放,无需执行 Rust 清理逻辑 + let terminated: Vec = sqlx::query_scalar( + "select pg_terminate_backend(pid) from pg_locks where locktype='advisory' and granted \ + and classid::bigint=((hashtextextended($1,739220)>>32)&4294967295) \ + and objid::bigint=(hashtextextended($1,739220)&4294967295) and objsubid=1", + ) + .bind(&instance.id) + .fetch_all(&database.pool) + .await + .unwrap(); + assert_eq!(terminated, vec![true]); + let reopened = PgPluginStore::new(database.pool.clone()); + tokio::time::timeout(Duration::from_secs(3), async { + while reopened + .recover_abandoned_state_transitions() + .await + .unwrap() + == 0 + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("recovery must reclaim the terminated session's staging"); + assert!( + store + .migration_batch(&transition.id, "cache", 100) + .await + .is_err() + ); + let retried = reopened + .begin_transition(&instance.id, instance.revision, &digest, target.clone()) + .await + .unwrap(); + assert_ne!(retried.id, transition.id); + let current = store.load_instances().await.unwrap(); + let mut upgraded = instance.clone(); + upgraded.artifact_sha256 = digest; + // 旧任务即使已完成全部批次,接管后也不能晋升或覆盖新迁移 + assert_eq!( + store + .save_instance_with_state( + upgraded, + current.config_revision, + PluginStateCommit { + configuration: target, + transition_id: Some(transition.id.clone()), + }, + &context(), + ) + .await + .err() + .unwrap() + .kind(), + AdminStoreErrorKind::Conflict + ); + let unchanged = store.load_instances().await.unwrap(); + assert_eq!(unchanged.config_revision, current.config_revision); + assert_eq!(unchanged.instances[0].revision, instance.revision); + assert_eq!( + unchanged.instances[0].artifact_sha256, + instance.artifact_sha256 + ); + assert!(!unchanged.instances[0].enabled); + // 迟到的旧迁移清理不能删除新所有者的暂存代次 + store.abort_transition(&transition.id).await.unwrap(); + assert!( + reopened + .migration_batch(&retried.id, "cache", 100) + .await + .is_ok() + ); + drop(retried); + drop(transition); + database.close().await; +} + #[tokio::test] async fn migration_preserves_byte_order_for_mixed_case_and_unicode_keys() { let Some(database) = TestDatabase::create("plugin_state_key_order").await else { @@ -790,7 +1027,7 @@ async fn migration_promotes_atomically_and_incomplete_staging_cannot_replace_act saved.config_revision, PluginStateCommit { configuration: second_state.clone(), - transition_id: Some(transition.id), + transition_id: Some(transition.id.clone()), }, &context(), ) @@ -810,6 +1047,7 @@ async fn migration_promotes_atomically_and_incomplete_staging_cannot_replace_act .unwrap(); assert_eq!(migrated.schema_version, 2); assert_eq!(migrated.value, json!({"value": 20})); + drop(transition); let third_state = configuration(schema(3, vec![2], 4)); let incomplete = store diff --git a/docs/architecture.md b/docs/architecture.md index 1ec1be91e..e56c0c998 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -128,7 +128,8 @@ flowchart LR 本节说明模型扩展的执行约束;配置与发布见[制品、配置与发布](#制品配置与发布),调用链见[请求组合与设置](#请求组合与设置),资源回收见[调用资源与状态](#调用资源与状态) -- **模型目录与路由共用事实**:`model_catalog` 的直接别名保存在发布代次引用中,Core 校验原生模型、静态映射与插件 ID 冲突及目标存在性后发布。 +- **模型目录与路由共用事实**:`model_catalog` 的直接别名保存在发布代次引用中,插件负责条目正确性,条目冲突或目标不存在不阻止宿主快照发布。 + 请求按顺序取首个匹配别名并直接使用其目标,不递归解析插件别名;Provider、账号权限与目标能力沿原选路流程处理。 公开列表、详情、原生目录和受管模型查询复用相同别名与账号访问范围,不另维护插件专用目录 - **重试由 Core 裁决**:`retry_policy` 只能停止或继续 Core 已允许的恢复路径;绑定按确定顺序委托,故障回退宿主。 发送、交付、续接、外部副作用、预算和取消限制不进入插件控制面,策略返回后继续复核 @@ -147,12 +148,13 @@ flowchart LR - **一份发布快照**:数据面和管理页面使用同一不可变扩展集合;CLI 复用准备与调用合同,按次读取持久化配置。 新配置准备成功后才提交发布,在途请求持有旧集合直到结束;管理目标和回调另按当前身份与版本复验 - **集合与实例分别保活**:实例 ID、制品、实例 revision、信任、配置、密钥与绑定均相同且进程就绪时,不同发布集合共享该实例及其注册贡献。 - 变更实例独立准备,进程由最后一个实例持有者释放时关闭;弱索引不延长寿命。共享进程保留原握手身份,各次调用仍携带所属请求的冻结计划;显式排空按实例、摘要与 revision 定位 + 变更实例有界并行准备,贡献仍按快照顺序组合;配置 schema 的编译结果按制品摘要共享,准备与配置就绪检查共用有界校验容量。 + 进程由最后一个实例持有者释放时关闭,弱索引不延长寿命。共享进程保留原握手身份,各次调用仍携带所属请求的冻结计划;显式排空按实例、摘要与 revision 定位 - **发布能力显式实现**:Admin 分别定义准备、运行诊断和状态生命周期端口,完整发布实现必须满足全部合同。 诊断可以明确返回不可用;状态激活、实例排空与迁移必须由实现显式处理,不以缺省空操作或静默成功代替 - **插件故障按实例隔离**:恢复时将启动失败的实例及其错误保留在发布集合中,正常实例和原生能力继续发布; 用户正在修改的实例仍须准备成功才能提交。集合区分进程就绪、可继续服务与需要后台重建,单个进程退出不撤销全部请求快照。 - 能力适配器将已确认的响应合同错误交给 RPC 会话统一停止进程和记录脱敏诊断,正常业务拒绝与上游错误不触发实例停止。 + 能力响应无法解码或消费时只结束所属调用,不连带停止共享进程;帧封装、调用关联或传输损坏才终止 RPC 会话。 故障绑定保留原有作用范围和拒绝/委托策略,入口认证故障不降级为其他认证方式;宿主配置存储与 revision 无法确认时仍停止新请求 - **宿主持有业务事实**:插件返回能力结果,Core 继续决定账号资格、发送状态、标准用量与费用;账号变更由 Admin / Store 执行版本校验、事务和审计。Provider 由组合根静态注册为 OpenAI 与 xAI;插件停用不删除账号或历史记录,失败的观察回调不改变响应和账单 @@ -170,8 +172,8 @@ flowchart LR `request` 包裹逻辑模型请求,`attempt` 包裹每次已选号的执行;请求顺序进入、响应逆序返回。 中间件读取和改写完整 headers、正文及响应帧,不进行字段裁剪或额外读取授权;响应正文按需读取,转换和改写保留 Core 的交付、取消与结算事实。 Provider 来源与执行事件快照可由插件读取,包含费用明细、上游观测、会话状态和原始 wire;读取快照不转移计量和资源所有权。 - 数据面插件通过受管 HTTP 已发送或无法证明未发送时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 - Provider 的 `not_sent` 结果透明重放 + 数据面插件通过受管 HTTP 已发送、无法证明未发送,或进入主动 HTTP/公开服务调用链时,Runtime 把该事实并入 Core 的请求副作用水位,后续不能按 + Provider 的 `not_sent` 结果透明重放;RPC 返回或取消前同步封存未完成的发送,并禁止已关闭调用再分派 HTTP/公开服务 - **主动 HTTP 请求复用总路由**:Runtime 通过 Core 的 `http::Dispatcher` 调用 API 已组装的路由,SDK 的 `dispatch_http` 与 `next` 共用正文资源池。 子调用持有父子关联、冻结计划和取消信号,跳过祖先发起实例;模型 request 与 attempt 延续该集合。内部管理请求使用宿主签发的插件身份,审计记录插件与调用来源,普通客户端不能用 headers 声明此身份。 正文转交延续真实交付寿命,EOF、显式关闭或父级取消释放资源;组合根保活 Dispatcher,Runtime 只保存 Weak,不增加逐接口注册清单 @@ -212,12 +214,14 @@ flowchart LR - **版本切换保持数据一致**:同一插件最多启用一个实例;每个实例与制品摘要保存最近启用时提交的配置、密钥和绑定, 快照与实例修改共享事务,停用草稿不覆盖恢复点。版本切换优先恢复目标快照,否则合并缺失默认值并保留显式设置。 私有状态按实例、schema 与配置版本隔离,写入使用精确记录版本。 - 不兼容升级须停用、排空并完成迁移,再原子提交配置与状态;迁移开始后的提交前失败统一清理暂存状态,恢复旧配置时复核版本,不能覆盖并发修改。提交后的状态激活与发布共用收尾入口,回滚也须通过兼容检查 + 不兼容升级须停用、排空并完成迁移,再原子提交配置与状态;迁移返回错误时清理暂存状态,恢复旧配置时复核版本,不能覆盖并发修改。 + 迁移取消或宿主退出后实例保持停用;Store 在启动和再次迁移时仅回收已无活跃所有者的暂存代次,不自动重放迁移。提交后的状态激活与发布共用收尾入口,回滚也须通过兼容检查 #### 信任与资源边界 插件以与宿主相同的 OS 身份运行,安装者承担插件安全;宿主不提供插件安全沙箱或访问域隔离。 进程、RPC、下载、缓存与回调均有容量和期限限制;Host 提供受管资源,Runtime 负责协议、调用关联与资源生命周期。 +RPC 的调用数、队列与流窗口有界,完整载荷尚无独立的总驻留内存配额。 下载来源、凭据与代理按目标匹配,网络操作不占用数据库事务,提交前重新校验来源与代理版本 管理页面使用无同源权限的 sandbox iframe,通过固定目标的宿主桥访问已声明路由。 diff --git a/docs/plugins.md b/docs/plugins.md index 2f693b057..11096e995 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -118,10 +118,10 @@ flowchart LR 宿主版本范围或能力版本不匹配时,管理页持续显示黄色兼容性提醒,允许手动启动和随宿主自动启动。 启动成功不代表语义兼容,警告不会自动消失。需由作者使用匹配的 [SDK](../backend/crates/gateway-plugin/sdk/README.md) 与 CLI 重新构建,不能只修改包内版本字段;宿主更新和回滚的重启检查见[部署说明](../deploy/README.md#插件兼容与发行目录) -包无法解析、平台不符或 RPC 握手失败时,只有对应插件无法启动。运行中进程崩溃、协议错误或无效响应会停止该实例, +包无法解析、平台不符或 RPC 握手失败时,只有对应插件无法启动。运行中进程崩溃、帧边界、调用关联或传输错误会停止该实例, 显示红色感叹号及脱敏错误详情,保留启用配置,可在详情中重新启动或切换版本。连续故障受重启次数限制 -上游 429、用户请求错误和插件主动拒绝请求等业务结果不会直接停止插件。其他插件、原生转发和管理端继续运行, +能力响应无法解码只使当前调用失败;上游 429、用户请求错误和插件主动拒绝请求等业务结果不会直接停止插件。其他插件、原生转发和管理端继续运行, 不因单个插件故障撤销整个宿主的运行配置。命中故障插件绑定的请求仍遵循既有拒绝或委托策略,入口认证插件失败不会绕过认证 宿主每个插件会话默认允许 64 个执行中的 RPC 调用、64 个初次排队调用和 256 个总在途上下文。