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 个总在途上下文。